Reorganize scripts into subfolders by responsibility
Move scripts under Assets/Scripts/ into Components, GameMode, Helper, Network, Enum, Struct and ScriptableObject folders. Pure file moves (plus folder .meta files); no code changes. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
1 parent
382bfb36df
commit
e23fae0c50
126 files changed
+112
No files matched your search
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 741ea71ad8a1001469a879e03bca0296
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,773 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
#if UNITY_EDITOR
|
||||
using UnityEditor;
|
||||
using UnityEditor.SceneManagement;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Editor tool that decomposes an environment mesh (typically an open-shell / non-watertight
|
||||
/// furniture mesh) into a small set of CONVEX MeshColliders via surface clustering, so
|
||||
/// <see cref="ConcealmentMeter"/> can treat the furniture as "embeddable" through its
|
||||
/// convex-collider ClosestPoint path WITHOUT a single convex hull filling the open compartments.
|
||||
///
|
||||
/// Approach (no voxels, no native plugin — "approximate convex decomposition" on the surface):
|
||||
/// 1. Weld coincident vertices so connectivity is detected across split-normal/UV seams.
|
||||
/// 2. Grow connected triangle regions into SLABS: a region keeps absorbing edge-adjacent
|
||||
/// triangles while it stays thinner than <see cref="maxSlabThickness"/> along its flattest
|
||||
/// axis (PCA minor axis). This spans both faces of one board (they are within a board's
|
||||
/// thickness) but refuses to jump the gap to the next board (that would thicken the region).
|
||||
/// 3. Convex-hull each slab into its own convex collider. Boards become thin convex slabs;
|
||||
/// the empty space between boards is covered by no collider, so it stays hollow.
|
||||
///
|
||||
/// The colliders are real child GameObjects referencing SAVED hull-mesh assets, so they survive
|
||||
/// domain reload / play-mode / scene save (unlike an in-memory-only bake).
|
||||
///
|
||||
/// Runtime-inert: the component exists only so <see cref="ConcealmentMeter"/> can detect it in a
|
||||
/// collider's parent and skip the furniture's own concave MeshCollider (exactly like a baked
|
||||
/// <c>ConcealmentVolume</c> does) — the generated convex slabs are the authoritative embedding shape.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class ConcealmentColliderBaker : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Optional. If empty, the shared mesh of a MeshFilter on this object (or a child) is used.")]
|
||||
[SerializeField] private Mesh sourceMesh;
|
||||
|
||||
[Header("Clustering")]
|
||||
[Tooltip("Vertices closer than this (local units) are welded, so shared edges are found across split-normal/UV seams. ~ a hair below the smallest real feature.")]
|
||||
[Min(1e-4f)] [SerializeField] private float weldEpsilon = 1e-3f;
|
||||
|
||||
[Tooltip("A connected region keeps growing while it stays thinner than this (local units) along its flattest axis. ~ board thickness. Larger => fewer, chunkier colliders (risk of bridging compartments); smaller => more, thinner slabs.")]
|
||||
[Min(1e-3f)] [SerializeField] private float maxSlabThickness = 0.12f;
|
||||
|
||||
[Tooltip("Flat/thin clusters are inflated to at least this thickness (local units) so the convex collider has usable volume for ClosestPoint tests.")]
|
||||
[Min(1e-3f)] [SerializeField] private float minThickness = 0.04f;
|
||||
|
||||
[Tooltip("A slab is recursively split until its convex-hull footprint is at least this fraction covered by real surface (0..1). Guards against one hull bridging open gaps between coplanar members (e.g. a table side = 2 legs + top rail merge into a flat panel whose hull fills the empty gap). Lower => tolerate more concavity/bridging; higher => split into more, tighter surface-hugging pieces. Solid boards (~fully covered) are never split.")]
|
||||
[Range(0f, 1f)] [SerializeField] private float hullFillThreshold = 0.6f;
|
||||
|
||||
[Tooltip("Clusters with fewer triangles than this are discarded as noise.")]
|
||||
[Min(1)] [SerializeField] private int minClusterTriangles = 1;
|
||||
|
||||
[Tooltip("Hard cap on generated colliders. The meter's broadphase is capped at 64 colliders TOTAL across all environment in range, so keep this modest. Largest clusters win.")]
|
||||
[Min(1)] [SerializeField] private int maxColliders = 24;
|
||||
|
||||
[Header("Output")]
|
||||
[Tooltip("If true, generated colliders inherit the source object's layer (so they land on the meter's environment mask). Otherwise 'Collider Layer' is used.")]
|
||||
[SerializeField] private bool useSourceLayer = true;
|
||||
[SerializeField] private int colliderLayer;
|
||||
|
||||
[SerializeField, HideInInspector] private List<string> generatedAssetPaths = new List<string>();
|
||||
|
||||
private const string ContainerName = "ConcealmentColliders (generated)";
|
||||
private const string AssetFolder = "Assets/Generated/ConcealmentColliders";
|
||||
private const int MaxRefineDepth = 8;
|
||||
|
||||
#if UNITY_EDITOR
|
||||
[ContextMenu("Bake Convex Colliders")]
|
||||
public void Bake()
|
||||
{
|
||||
Mesh mesh = sourceMesh;
|
||||
Transform meshSpace = transform;
|
||||
if (!mesh)
|
||||
{
|
||||
MeshFilter mf = GetComponentInChildren<MeshFilter>();
|
||||
if (mf && mf.sharedMesh) { mesh = mf.sharedMesh; meshSpace = mf.transform; }
|
||||
}
|
||||
if (!mesh)
|
||||
{
|
||||
Debug.LogError($"[ConcealmentColliderBaker] '{name}': no source mesh (assign one or add a MeshFilter).", this);
|
||||
return;
|
||||
}
|
||||
|
||||
Clear(); // remove previous container + assets first
|
||||
|
||||
string baseName = ResolveAssetBaseName();
|
||||
|
||||
Vector3[] rawVerts = mesh.vertices;
|
||||
int[] rawTris = mesh.triangles;
|
||||
|
||||
// 1. Weld coincident vertices.
|
||||
WeldVertices(rawVerts, weldEpsilon, out List<Vector3> wv, out int[] map);
|
||||
|
||||
// Welded triangles (drop degenerate).
|
||||
var triA = new List<int>(); var triB = new List<int>(); var triC = new List<int>();
|
||||
for (int i = 0; i + 2 < rawTris.Length; i += 3)
|
||||
{
|
||||
int a = map[rawTris[i]], b = map[rawTris[i + 1]], c = map[rawTris[i + 2]];
|
||||
if (a == b || b == c || a == c) continue;
|
||||
triA.Add(a); triB.Add(b); triC.Add(c);
|
||||
}
|
||||
int triCount = triA.Count;
|
||||
if (triCount == 0)
|
||||
{
|
||||
Debug.LogWarning($"[ConcealmentColliderBaker] '{name}': mesh has no valid triangles after welding.", this);
|
||||
return;
|
||||
}
|
||||
|
||||
// 2. Edge adjacency (welded edges shared by triangles).
|
||||
var edgeToTri = new Dictionary<long, int>(triCount * 3);
|
||||
var neighbors = new List<int>[triCount];
|
||||
for (int t = 0; t < triCount; t++) neighbors[t] = new List<int>();
|
||||
for (int t = 0; t < triCount; t++)
|
||||
{
|
||||
LinkEdge(edgeToTri, neighbors, triA[t], triB[t], t);
|
||||
LinkEdge(edgeToTri, neighbors, triB[t], triC[t], t);
|
||||
LinkEdge(edgeToTri, neighbors, triC[t], triA[t], t);
|
||||
}
|
||||
|
||||
// 3. Grow connected triangles into thickness-bounded slabs.
|
||||
// Disconnected boards never link (separate components); connected assemblies split
|
||||
// wherever a region would exceed maxSlabThickness.
|
||||
var clusters = new List<List<int>>();
|
||||
bool[] assigned = new bool[triCount];
|
||||
var queue = new Queue<int>();
|
||||
var vset = new HashSet<int>();
|
||||
var vpos = new List<Vector3>();
|
||||
var added = new List<int>();
|
||||
|
||||
for (int s = 0; s < triCount; s++)
|
||||
{
|
||||
if (assigned[s]) continue;
|
||||
|
||||
var cluster = new List<int> { s };
|
||||
assigned[s] = true;
|
||||
vset.Clear(); vpos.Clear();
|
||||
AddTriVerts(vset, vpos, wv, triA[s], triB[s], triC[s], null);
|
||||
|
||||
queue.Clear();
|
||||
for (int k = 0; k < neighbors[s].Count; k++) queue.Enqueue(neighbors[s][k]);
|
||||
|
||||
while (queue.Count > 0)
|
||||
{
|
||||
int t = queue.Dequeue();
|
||||
if (assigned[t]) continue;
|
||||
|
||||
int before = vpos.Count;
|
||||
AddTriVerts(vset, vpos, wv, triA[t], triB[t], triC[t], added);
|
||||
|
||||
float th = SlabThickness(vpos, out _);
|
||||
if (th <= maxSlabThickness)
|
||||
{
|
||||
assigned[t] = true;
|
||||
cluster.Add(t);
|
||||
for (int k = 0; k < neighbors[t].Count; k++)
|
||||
if (!assigned[neighbors[t][k]]) queue.Enqueue(neighbors[t][k]);
|
||||
}
|
||||
else
|
||||
{
|
||||
// Revert the tentatively added verts.
|
||||
for (int k = 0; k < added.Count; k++) vset.Remove(added[k]);
|
||||
if (vpos.Count > before) vpos.RemoveRange(before, vpos.Count - before);
|
||||
}
|
||||
}
|
||||
|
||||
clusters.Add(cluster);
|
||||
}
|
||||
|
||||
// 3b. Refine: split slabs whose convex hull would bridge open concavities. A table side is
|
||||
// 2 legs + a top rail — all coplanar and thin, so they merge into ONE slab whose hull
|
||||
// fills the empty gap between the legs. Each slab is recursively halved along its larger
|
||||
// in-plane axis until its hull footprint is mostly covered by real surface. Solid boards
|
||||
// (the shelf) are already ~fully covered => returned untouched.
|
||||
var refined = new List<List<int>>(clusters.Count);
|
||||
for (int c = 0; c < clusters.Count; c++)
|
||||
RefineCluster(clusters[c], wv, triA, triB, triC, 0, refined);
|
||||
clusters = refined;
|
||||
|
||||
// 4. Filter noise + cap count (keep largest slabs).
|
||||
clusters.RemoveAll(c => c.Count < minClusterTriangles);
|
||||
clusters.Sort((x, y) => y.Count.CompareTo(x.Count));
|
||||
int dropped = 0;
|
||||
if (clusters.Count > maxColliders)
|
||||
{
|
||||
dropped = clusters.Count - maxColliders;
|
||||
clusters.RemoveRange(maxColliders, dropped);
|
||||
}
|
||||
|
||||
// 5. Build a convex collider per slab.
|
||||
EnsureFolder();
|
||||
Transform container = new GameObject(ContainerName).transform;
|
||||
container.SetParent(meshSpace, false);
|
||||
container.localPosition = Vector3.zero;
|
||||
container.localRotation = Quaternion.identity;
|
||||
container.localScale = Vector3.one;
|
||||
int outLayer = useSourceLayer ? gameObject.layer : colliderLayer;
|
||||
|
||||
int made = 0;
|
||||
for (int ci = 0; ci < clusters.Count; ci++)
|
||||
{
|
||||
List<Vector3> pts = CollectClusterPoints(clusters[ci], wv, triA, triB, triC);
|
||||
|
||||
float thick = SlabThickness(pts, out Vector3 normal);
|
||||
List<Vector3> hullPts;
|
||||
if (thick < minThickness)
|
||||
{
|
||||
// Inflate a (near-)flat slab into a thin prism so the hull has volume.
|
||||
float half = minThickness * 0.5f;
|
||||
hullPts = new List<Vector3>(pts.Count * 2);
|
||||
for (int k = 0; k < pts.Count; k++)
|
||||
{
|
||||
hullPts.Add(pts[k] + normal * half);
|
||||
hullPts.Add(pts[k] - normal * half);
|
||||
}
|
||||
}
|
||||
else hullPts = pts;
|
||||
|
||||
if (!ComputeConvexHull(hullPts.ToArray(), out List<Vector3> hv, out List<int> ht) || hv.Count < 4)
|
||||
continue;
|
||||
|
||||
Mesh cm = new Mesh { name = $"{baseName}_{made}" };
|
||||
cm.SetVertices(hv);
|
||||
cm.SetTriangles(ht, 0);
|
||||
cm.RecalculateBounds();
|
||||
|
||||
string path = $"{AssetFolder}/{baseName}_{made}.asset";
|
||||
AssetDatabase.CreateAsset(cm, path);
|
||||
generatedAssetPaths.Add(path);
|
||||
|
||||
GameObject go = new GameObject($"hull_{made}");
|
||||
go.transform.SetParent(container, false);
|
||||
go.layer = outLayer;
|
||||
MeshCollider col = go.AddComponent<MeshCollider>();
|
||||
col.sharedMesh = cm;
|
||||
col.convex = true;
|
||||
col.isTrigger = false;
|
||||
|
||||
made++;
|
||||
}
|
||||
|
||||
AssetDatabase.SaveAssets();
|
||||
EditorUtility.SetDirty(this);
|
||||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
|
||||
|
||||
Debug.Log($"[ConcealmentColliderBaker] '{name}': baked {made} convex collider(s)" +
|
||||
(dropped > 0 ? $" (+{dropped} smaller slab(s) dropped by Max Colliders={maxColliders})" : "") +
|
||||
". Save the scene to persist.", this);
|
||||
}
|
||||
|
||||
[ContextMenu("Clear Baked Colliders")]
|
||||
public void Clear()
|
||||
{
|
||||
for (int i = 0; i < generatedAssetPaths.Count; i++)
|
||||
if (!string.IsNullOrEmpty(generatedAssetPaths[i]))
|
||||
AssetDatabase.DeleteAsset(generatedAssetPaths[i]);
|
||||
generatedAssetPaths.Clear();
|
||||
|
||||
var all = GetComponentsInChildren<Transform>(true);
|
||||
for (int i = 0; i < all.Length; i++)
|
||||
if (all[i] && all[i] != transform && all[i].name == ContainerName)
|
||||
DestroyImmediate(all[i].gameObject);
|
||||
|
||||
EditorUtility.SetDirty(this);
|
||||
if (gameObject.scene.IsValid())
|
||||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
|
||||
}
|
||||
|
||||
private static void EnsureFolder()
|
||||
{
|
||||
if (!AssetDatabase.IsValidFolder("Assets/Generated"))
|
||||
AssetDatabase.CreateFolder("Assets", "Generated");
|
||||
if (!AssetDatabase.IsValidFolder(AssetFolder))
|
||||
AssetDatabase.CreateFolder("Assets/Generated", "ConcealmentColliders");
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Readable, deterministic base name for the generated collider-mesh assets:
|
||||
/// - Prefab stage: "{assetPath without 'Assets/' and '.prefab'}/{path below the prefab root}"
|
||||
/// - Scene: "{sceneName}/{full hierarchy path from the scene root}"
|
||||
/// Sanitized to a filesystem-safe token; on collision with a DIFFERENT baker's assets, "_2", "_3", … is appended.
|
||||
/// </summary>
|
||||
private string ResolveAssetBaseName()
|
||||
{
|
||||
string raw;
|
||||
PrefabStage stage = PrefabStageUtility.GetPrefabStage(gameObject);
|
||||
if (stage != null)
|
||||
{
|
||||
string ap = stage.assetPath;
|
||||
if (ap.StartsWith("Assets/")) ap = ap.Substring("Assets/".Length);
|
||||
if (ap.EndsWith(".prefab")) ap = ap.Substring(0, ap.Length - ".prefab".Length);
|
||||
// Path below the prefab root — the root's name already lives in the asset path.
|
||||
string hier = GetHierarchyPath(transform, stage.prefabContentsRoot.transform);
|
||||
raw = hier.Length == 0 ? ap : ap + "/" + hier;
|
||||
}
|
||||
else
|
||||
{
|
||||
string scene = string.IsNullOrEmpty(gameObject.scene.name) ? "Untitled" : gameObject.scene.name;
|
||||
raw = scene + "/" + GetHierarchyPath(transform, null);
|
||||
}
|
||||
|
||||
string baseName = SanitizeFileName(raw);
|
||||
if (baseName.Length == 0) baseName = "Bake";
|
||||
|
||||
// Clear() already removed THIS baker's assets, so a hit here means another baker resolved to the
|
||||
// same name -> disambiguate. The per-collider "_0" file is the presence marker of a base name.
|
||||
string candidate = baseName;
|
||||
for (int dup = 2; System.IO.File.Exists($"{AssetFolder}/{candidate}_0.asset"); dup++)
|
||||
candidate = $"{baseName}_{dup}";
|
||||
return candidate;
|
||||
}
|
||||
|
||||
/// <summary>Slash-joined transform names from <paramref name="t"/> up to (but excluding) <paramref name="stopAt"/> (null = up to the root).</summary>
|
||||
private static string GetHierarchyPath(Transform t, Transform stopAt)
|
||||
{
|
||||
var names = new List<string>();
|
||||
for (Transform cur = t; cur && cur != stopAt; cur = cur.parent)
|
||||
names.Add(cur.name);
|
||||
names.Reverse();
|
||||
return string.Join("/", names);
|
||||
}
|
||||
|
||||
/// <summary>Collapses every run of non-[A-Za-z0-9-] characters to a single '_' and trims leading/trailing '_'.</summary>
|
||||
private static string SanitizeFileName(string s)
|
||||
{
|
||||
var sb = new System.Text.StringBuilder(s.Length);
|
||||
bool pendingSep = false;
|
||||
for (int i = 0; i < s.Length; i++)
|
||||
{
|
||||
char c = s[i];
|
||||
bool keep = (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-';
|
||||
if (keep)
|
||||
{
|
||||
if (pendingSep && sb.Length > 0) sb.Append('_');
|
||||
pendingSep = false;
|
||||
sb.Append(c);
|
||||
}
|
||||
else pendingSep = true;
|
||||
}
|
||||
return sb.ToString();
|
||||
}
|
||||
#endif
|
||||
|
||||
// ---- Welding & clustering helpers (runtime-safe: no editor API) ----
|
||||
|
||||
private static void WeldVertices(Vector3[] src, float eps, out List<Vector3> welded, out int[] map)
|
||||
{
|
||||
welded = new List<Vector3>(src.Length);
|
||||
map = new int[src.Length];
|
||||
float inv = 1f / Mathf.Max(eps, 1e-6f);
|
||||
var lut = new Dictionary<long, int>(src.Length);
|
||||
for (int i = 0; i < src.Length; i++)
|
||||
{
|
||||
Vector3 p = src[i];
|
||||
int qx = Mathf.RoundToInt(p.x * inv);
|
||||
int qy = Mathf.RoundToInt(p.y * inv);
|
||||
int qz = Mathf.RoundToInt(p.z * inv);
|
||||
long key = ((long)(qx & 0x1FFFFF) << 42) | ((long)(qy & 0x1FFFFF) << 21) | (long)(qz & 0x1FFFFF);
|
||||
if (lut.TryGetValue(key, out int idx)) { map[i] = idx; }
|
||||
else { idx = welded.Count; lut[key] = idx; welded.Add(p); map[i] = idx; }
|
||||
}
|
||||
}
|
||||
|
||||
private static void LinkEdge(Dictionary<long, int> edgeToTri, List<int>[] neighbors, int u, int v, int tri)
|
||||
{
|
||||
long key = EdgeKey(u, v);
|
||||
if (edgeToTri.TryGetValue(key, out int other))
|
||||
{
|
||||
neighbors[tri].Add(other);
|
||||
neighbors[other].Add(tri);
|
||||
edgeToTri[key] = tri; // chain non-manifold edges (>2 tris) so all get linked
|
||||
}
|
||||
else edgeToTri[key] = tri;
|
||||
}
|
||||
|
||||
private static void AddTriVerts(HashSet<int> set, List<Vector3> pos, List<Vector3> wv, int a, int b, int c, List<int> addedOut)
|
||||
{
|
||||
addedOut?.Clear();
|
||||
if (set.Add(a)) { pos.Add(wv[a]); addedOut?.Add(a); }
|
||||
if (set.Add(b)) { pos.Add(wv[b]); addedOut?.Add(b); }
|
||||
if (set.Add(c)) { pos.Add(wv[c]); addedOut?.Add(c); }
|
||||
}
|
||||
|
||||
/// <summary>Unique welded vertex positions of a triangle cluster.</summary>
|
||||
private static List<Vector3> CollectClusterPoints(List<int> cluster, List<Vector3> wv, List<int> triA, List<int> triB, List<int> triC)
|
||||
{
|
||||
var pts = new List<Vector3>(cluster.Count * 3);
|
||||
var seen = new HashSet<int>();
|
||||
for (int j = 0; j < cluster.Count; j++)
|
||||
{
|
||||
int t = cluster[j];
|
||||
if (seen.Add(triA[t])) pts.Add(wv[triA[t]]);
|
||||
if (seen.Add(triB[t])) pts.Add(wv[triB[t]]);
|
||||
if (seen.Add(triC[t])) pts.Add(wv[triC[t]]);
|
||||
}
|
||||
return pts;
|
||||
}
|
||||
|
||||
/// <summary>Mean-centered 3x3 covariance of the point cloud, eigendecomposed via <see cref="Jacobi3"/>.</summary>
|
||||
private static void CovarianceEigen(List<Vector3> pts, out double[,] vec, out double[] d)
|
||||
{
|
||||
int n = pts.Count;
|
||||
Vector3 mean = Vector3.zero;
|
||||
for (int i = 0; i < n; i++) mean += pts[i];
|
||||
mean /= n;
|
||||
|
||||
double c00 = 0, c01 = 0, c02 = 0, c11 = 0, c12 = 0, c22 = 0;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
double x = pts[i].x - mean.x, y = pts[i].y - mean.y, z = pts[i].z - mean.z;
|
||||
c00 += x * x; c01 += x * y; c02 += x * z;
|
||||
c11 += y * y; c12 += y * z; c22 += z * z;
|
||||
}
|
||||
|
||||
var a = new double[3, 3] { { c00, c01, c02 }, { c01, c11, c12 }, { c02, c12, c22 } };
|
||||
Jacobi3(a, out vec, out d);
|
||||
}
|
||||
|
||||
/// <summary>Extent of the point cloud along its PCA minor axis (= slab thickness), plus that axis.</summary>
|
||||
private static float SlabThickness(List<Vector3> pts, out Vector3 normal)
|
||||
{
|
||||
normal = Vector3.up;
|
||||
int n = pts.Count;
|
||||
if (n < 2) return 0f;
|
||||
|
||||
CovarianceEigen(pts, out double[,] v, out double[] d);
|
||||
|
||||
int mi = 0;
|
||||
if (d[1] < d[mi]) mi = 1;
|
||||
if (d[2] < d[mi]) mi = 2;
|
||||
normal = new Vector3((float)v[0, mi], (float)v[1, mi], (float)v[2, mi]);
|
||||
if (normal.sqrMagnitude < 1e-12f) { normal = Vector3.up; return 0f; }
|
||||
normal.Normalize();
|
||||
|
||||
float mn = float.MaxValue, mx = float.MinValue;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
float dp = Vector3.Dot(pts[i], normal);
|
||||
if (dp < mn) mn = dp;
|
||||
if (dp > mx) mx = dp;
|
||||
}
|
||||
return mx - mn;
|
||||
}
|
||||
|
||||
// ---- Concavity refinement (split slabs whose hull would bridge open gaps) ----
|
||||
|
||||
/// <summary>
|
||||
/// Recursively halves a slab cluster along its larger in-plane axis until its hull footprint is
|
||||
/// at least <see cref="hullFillThreshold"/> covered by real surface (or it gets too small / too deep).
|
||||
/// Prevents one convex hull from bridging the empty space between disjoint coplanar members (table legs).
|
||||
/// </summary>
|
||||
private void RefineCluster(List<int> cluster, List<Vector3> wv, List<int> triA, List<int> triB, List<int> triC, int depth, List<List<int>> outClusters)
|
||||
{
|
||||
if (cluster.Count <= 2 || depth >= MaxRefineDepth)
|
||||
{
|
||||
outClusters.Add(cluster);
|
||||
return;
|
||||
}
|
||||
|
||||
float fill = ClusterFill(cluster, wv, triA, triB, triC, out Vector3 axis);
|
||||
if (fill >= hullFillThreshold)
|
||||
{
|
||||
outClusters.Add(cluster);
|
||||
return;
|
||||
}
|
||||
|
||||
// Halve at the centroid median along the larger in-plane axis. Recursion + the fill test
|
||||
// converge on surface-hugging pieces; a solid board is >= threshold at once and never splits.
|
||||
int m = cluster.Count;
|
||||
var proj = new float[m];
|
||||
for (int j = 0; j < m; j++)
|
||||
proj[j] = Vector3.Dot((wv[triA[cluster[j]]] + wv[triB[cluster[j]]] + wv[triC[cluster[j]]]) * (1f / 3f), axis);
|
||||
var sorted = (float[])proj.Clone();
|
||||
Array.Sort(sorted);
|
||||
float median = sorted[m / 2];
|
||||
|
||||
var left = new List<int>();
|
||||
var right = new List<int>();
|
||||
for (int j = 0; j < m; j++)
|
||||
{
|
||||
if (proj[j] < median) left.Add(cluster[j]); else right.Add(cluster[j]);
|
||||
}
|
||||
if (left.Count == 0 || right.Count == 0)
|
||||
{
|
||||
outClusters.Add(cluster); // all centroids coincide on the axis — can't halve, keep as-is
|
||||
return;
|
||||
}
|
||||
|
||||
RefineCluster(left, wv, triA, triB, triC, depth + 1, outClusters);
|
||||
RefineCluster(right, wv, triA, triB, triC, depth + 1, outClusters);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fraction (0..1) of the cluster's in-plane bounding footprint actually covered by surface, via a
|
||||
/// coverage grid on the PCA plane (union of the projected triangles — single-/double-sided agnostic).
|
||||
/// Low fill => the convex hull would enclose empty space. Outputs the larger in-plane axis (split dir).
|
||||
/// </summary>
|
||||
private static float ClusterFill(List<int> cluster, List<Vector3> wv, List<int> triA, List<int> triB, List<int> triC, out Vector3 splitAxis)
|
||||
{
|
||||
List<Vector3> pts = CollectClusterPoints(cluster, wv, triA, triB, triC);
|
||||
|
||||
PcaFrame(pts, out Vector3 u, out Vector3 v, out _);
|
||||
|
||||
float umin = float.MaxValue, umax = float.MinValue, vmin = float.MaxValue, vmax = float.MinValue;
|
||||
for (int i = 0; i < pts.Count; i++)
|
||||
{
|
||||
float pu = Vector3.Dot(pts[i], u), pv = Vector3.Dot(pts[i], v);
|
||||
if (pu < umin) umin = pu; if (pu > umax) umax = pu;
|
||||
if (pv < vmin) vmin = pv; if (pv > vmax) vmax = pv;
|
||||
}
|
||||
float eu = Mathf.Max(umax - umin, 1e-5f), ev = Mathf.Max(vmax - vmin, 1e-5f);
|
||||
splitAxis = eu >= ev ? u : v;
|
||||
|
||||
float cell = Mathf.Max(Mathf.Max(eu, ev) / 32f, 1e-4f);
|
||||
int NU = Mathf.Clamp(Mathf.CeilToInt(eu / cell), 1, 40);
|
||||
int NV = Mathf.Clamp(Mathf.CeilToInt(ev / cell), 1, 40);
|
||||
var cov = new bool[NU, NV];
|
||||
for (int j = 0; j < cluster.Count; j++)
|
||||
{
|
||||
int t = cluster[j];
|
||||
RasterTri(cov, NU, NV,
|
||||
ToGrid(wv[triA[t]], u, v, umin, vmin, eu, ev, NU, NV),
|
||||
ToGrid(wv[triB[t]], u, v, umin, vmin, eu, ev, NU, NV),
|
||||
ToGrid(wv[triC[t]], u, v, umin, vmin, eu, ev, NU, NV));
|
||||
}
|
||||
|
||||
int covered = 0;
|
||||
for (int a = 0; a < NU; a++) for (int b = 0; b < NV; b++) if (cov[a, b]) covered++;
|
||||
return (float)covered / (NU * NV);
|
||||
}
|
||||
|
||||
private static Vector2 ToGrid(Vector3 p, Vector3 u, Vector3 v, float umin, float vmin, float eu, float ev, int NU, int NV)
|
||||
=> new Vector2((Vector3.Dot(p, u) - umin) / eu * NU, (Vector3.Dot(p, v) - vmin) / ev * NV);
|
||||
|
||||
/// <summary>Marks every grid cell whose center falls inside the 2D triangle (edge-on triangles paint their thin bbox strip).</summary>
|
||||
private static void RasterTri(bool[,] cov, int NU, int NV, Vector2 a, Vector2 b, Vector2 c)
|
||||
{
|
||||
float minx = Mathf.Min(a.x, Mathf.Min(b.x, c.x)), maxx = Mathf.Max(a.x, Mathf.Max(b.x, c.x));
|
||||
float miny = Mathf.Min(a.y, Mathf.Min(b.y, c.y)), maxy = Mathf.Max(a.y, Mathf.Max(b.y, c.y));
|
||||
int x0 = Mathf.Clamp(Mathf.FloorToInt(minx), 0, NU - 1), x1 = Mathf.Clamp(Mathf.CeilToInt(maxx), 0, NU - 1);
|
||||
int y0 = Mathf.Clamp(Mathf.FloorToInt(miny), 0, NV - 1), y1 = Mathf.Clamp(Mathf.CeilToInt(maxy), 0, NV - 1);
|
||||
|
||||
float d = (b.y - c.y) * (a.x - c.x) + (c.x - b.x) * (a.y - c.y);
|
||||
if (Mathf.Abs(d) < 1e-9f)
|
||||
{
|
||||
for (int x = x0; x <= x1; x++) for (int y = y0; y <= y1; y++) cov[x, y] = true;
|
||||
return;
|
||||
}
|
||||
for (int x = x0; x <= x1; x++)
|
||||
for (int y = y0; y <= y1; y++)
|
||||
{
|
||||
float px = x + 0.5f, py = y + 0.5f;
|
||||
float w0 = ((b.y - c.y) * (px - c.x) + (c.x - b.x) * (py - c.y)) / d;
|
||||
float w1 = ((c.y - a.y) * (px - c.x) + (a.x - c.x) * (py - c.y)) / d;
|
||||
float w2 = 1f - w0 - w1;
|
||||
if (w0 >= -1e-3f && w1 >= -1e-3f && w2 >= -1e-3f) cov[x, y] = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>PCA orthonormal frame of a point cloud: u = major axis, v = mid, n = minor (slab normal). Eigenvectors sorted by variance desc.</summary>
|
||||
private static void PcaFrame(List<Vector3> pts, out Vector3 u, out Vector3 v, out Vector3 n)
|
||||
{
|
||||
u = Vector3.right; v = Vector3.up; n = Vector3.forward;
|
||||
if (pts.Count < 2) return;
|
||||
|
||||
CovarianceEigen(pts, out double[,] vec, out double[] d);
|
||||
|
||||
int hi = 0, mid = 1, lo = 2;
|
||||
if (d[hi] < d[mid]) { int t = hi; hi = mid; mid = t; }
|
||||
if (d[mid] < d[lo]) { int t = mid; mid = lo; lo = t; }
|
||||
if (d[hi] < d[mid]) { int t = hi; hi = mid; mid = t; }
|
||||
|
||||
u = AxisOr(vec, hi, Vector3.right);
|
||||
v = AxisOr(vec, mid, Vector3.up);
|
||||
n = AxisOr(vec, lo, Vector3.forward);
|
||||
}
|
||||
|
||||
private static Vector3 AxisOr(double[,] vec, int col, Vector3 fallback)
|
||||
{
|
||||
Vector3 a = new Vector3((float)vec[0, col], (float)vec[1, col], (float)vec[2, col]);
|
||||
return a.sqrMagnitude < 1e-12f ? fallback : a.normalized;
|
||||
}
|
||||
|
||||
/// <summary>Cyclic Jacobi eigendecomposition of a symmetric 3x3 matrix (eigenvectors in columns of v).</summary>
|
||||
private static void Jacobi3(double[,] a, out double[,] v, out double[] d)
|
||||
{
|
||||
v = new double[3, 3] { { 1, 0, 0 }, { 0, 1, 0 }, { 0, 0, 1 } };
|
||||
for (int sweep = 0; sweep < 24; sweep++)
|
||||
{
|
||||
double off = Math.Abs(a[0, 1]) + Math.Abs(a[0, 2]) + Math.Abs(a[1, 2]);
|
||||
if (off < 1e-18) break;
|
||||
JacobiRotate(a, v, 0, 1);
|
||||
JacobiRotate(a, v, 0, 2);
|
||||
JacobiRotate(a, v, 1, 2);
|
||||
}
|
||||
d = new double[] { a[0, 0], a[1, 1], a[2, 2] };
|
||||
}
|
||||
|
||||
private static void JacobiRotate(double[,] a, double[,] v, int p, int q)
|
||||
{
|
||||
double apq = a[p, q];
|
||||
if (Math.Abs(apq) < 1e-20) return;
|
||||
|
||||
double app = a[p, p], aqq = a[q, q];
|
||||
double phi = 0.5 * Math.Atan2(2.0 * apq, aqq - app);
|
||||
double c = Math.Cos(phi), s = Math.Sin(phi);
|
||||
|
||||
a[p, p] = c * c * app - 2.0 * s * c * apq + s * s * aqq;
|
||||
a[q, q] = s * s * app + 2.0 * s * c * apq + c * c * aqq;
|
||||
a[p, q] = 0; a[q, p] = 0;
|
||||
|
||||
int r = 3 - p - q;
|
||||
double arp = a[r, p], arq = a[r, q];
|
||||
double nrp = c * arp - s * arq;
|
||||
double nrq = s * arp + c * arq;
|
||||
a[r, p] = nrp; a[p, r] = nrp;
|
||||
a[r, q] = nrq; a[q, r] = nrq;
|
||||
|
||||
for (int k = 0; k < 3; k++)
|
||||
{
|
||||
double vkp = v[k, p], vkq = v[k, q];
|
||||
v[k, p] = c * vkp - s * vkq;
|
||||
v[k, q] = s * vkp + c * vkq;
|
||||
}
|
||||
}
|
||||
|
||||
// ---- Convex hull (incremental; outputs a triangulated hull mesh) ----
|
||||
|
||||
private struct HFace { public int a, b, c; public Vector3 n; }
|
||||
|
||||
private static bool ComputeConvexHull(Vector3[] pts, out List<Vector3> hullVertsOut, out List<int> hullTrisOut)
|
||||
{
|
||||
hullVertsOut = new List<Vector3>();
|
||||
hullTrisOut = new List<int>();
|
||||
|
||||
int n = pts.Length;
|
||||
if (n < 4) return false;
|
||||
|
||||
Vector3 bmn = pts[0], bmx = pts[0];
|
||||
for (int i = 1; i < n; i++) { bmn = Vector3.Min(bmn, pts[i]); bmx = Vector3.Max(bmx, pts[i]); }
|
||||
float diag = (bmx - bmn).magnitude;
|
||||
if (diag < 1e-6f) return false;
|
||||
float eps = diag * 1e-6f;
|
||||
|
||||
int minX = 0, maxX = 0;
|
||||
for (int i = 1; i < n; i++) { if (pts[i].x < pts[minX].x) minX = i; if (pts[i].x > pts[maxX].x) maxX = i; }
|
||||
int i0 = minX, i1 = maxX;
|
||||
if (i0 == i1) return false;
|
||||
|
||||
Vector3 lineDir = pts[i1] - pts[i0];
|
||||
float lineLen = lineDir.magnitude;
|
||||
if (lineLen < eps) return false;
|
||||
lineDir /= lineLen;
|
||||
|
||||
int i2 = -1; float best = eps;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
float dd = Vector3.Cross(pts[i] - pts[i0], lineDir).magnitude;
|
||||
if (dd > best) { best = dd; i2 = i; }
|
||||
}
|
||||
if (i2 < 0) return false;
|
||||
|
||||
Vector3 nrm0 = Vector3.Cross(pts[i1] - pts[i0], pts[i2] - pts[i0]);
|
||||
float nl = nrm0.magnitude;
|
||||
if (nl < eps) return false;
|
||||
nrm0 /= nl;
|
||||
|
||||
int i3 = -1; best = eps;
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
float dd = Mathf.Abs(Vector3.Dot(pts[i] - pts[i0], nrm0));
|
||||
if (dd > best) { best = dd; i3 = i; }
|
||||
}
|
||||
if (i3 < 0) return false;
|
||||
|
||||
Vector3 interior = (pts[i0] + pts[i1] + pts[i2] + pts[i3]) * 0.25f;
|
||||
|
||||
var faces = new List<HFace>(4 + n);
|
||||
AddHullFace(faces, pts, interior, i0, i1, i2);
|
||||
AddHullFace(faces, pts, interior, i0, i1, i3);
|
||||
AddHullFace(faces, pts, interior, i0, i2, i3);
|
||||
AddHullFace(faces, pts, interior, i1, i2, i3);
|
||||
|
||||
var visible = new List<int>();
|
||||
var edgeCount = new Dictionary<long, int>();
|
||||
var horizonA = new List<int>();
|
||||
var horizonB = new List<int>();
|
||||
|
||||
for (int pi = 0; pi < n; pi++)
|
||||
{
|
||||
if (pi == i0 || pi == i1 || pi == i2 || pi == i3) continue;
|
||||
Vector3 p = pts[pi];
|
||||
|
||||
visible.Clear();
|
||||
for (int f = 0; f < faces.Count; f++)
|
||||
{
|
||||
HFace hf = faces[f];
|
||||
if (Vector3.Dot(hf.n, p - pts[hf.a]) > eps) visible.Add(f);
|
||||
}
|
||||
if (visible.Count == 0) continue;
|
||||
|
||||
edgeCount.Clear();
|
||||
for (int vi = 0; vi < visible.Count; vi++)
|
||||
{
|
||||
HFace hf = faces[visible[vi]];
|
||||
AccEdge(edgeCount, hf.a, hf.b);
|
||||
AccEdge(edgeCount, hf.b, hf.c);
|
||||
AccEdge(edgeCount, hf.c, hf.a);
|
||||
}
|
||||
horizonA.Clear(); horizonB.Clear();
|
||||
for (int vi = 0; vi < visible.Count; vi++)
|
||||
{
|
||||
HFace hf = faces[visible[vi]];
|
||||
if (EdgeIsHorizon(edgeCount, hf.a, hf.b)) { horizonA.Add(hf.a); horizonB.Add(hf.b); }
|
||||
if (EdgeIsHorizon(edgeCount, hf.b, hf.c)) { horizonA.Add(hf.b); horizonB.Add(hf.c); }
|
||||
if (EdgeIsHorizon(edgeCount, hf.c, hf.a)) { horizonA.Add(hf.c); horizonB.Add(hf.a); }
|
||||
}
|
||||
|
||||
visible.Sort();
|
||||
for (int vi = visible.Count - 1; vi >= 0; vi--) faces.RemoveAt(visible[vi]);
|
||||
|
||||
for (int h = 0; h < horizonA.Count; h++)
|
||||
AddHullFace(faces, pts, interior, horizonA[h], horizonB[h], pi);
|
||||
}
|
||||
|
||||
if (faces.Count < 4) return false;
|
||||
|
||||
var remap = new Dictionary<int, int>();
|
||||
for (int f = 0; f < faces.Count; f++)
|
||||
{
|
||||
HFace hf = faces[f];
|
||||
hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.a));
|
||||
hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.b));
|
||||
hullTrisOut.Add(RemapVert(remap, hullVertsOut, pts, hf.c));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void AddHullFace(List<HFace> faces, Vector3[] pts, Vector3 interior, int a, int b, int c)
|
||||
{
|
||||
Vector3 nn = Vector3.Cross(pts[b] - pts[a], pts[c] - pts[a]);
|
||||
float l = nn.magnitude;
|
||||
if (l < 1e-20f) return;
|
||||
nn /= l;
|
||||
if (Vector3.Dot(nn, pts[a] - interior) < 0f) nn = -nn;
|
||||
faces.Add(new HFace { a = a, b = b, c = c, n = nn });
|
||||
}
|
||||
|
||||
private static long EdgeKey(int u, int v)
|
||||
{
|
||||
int lo = u < v ? u : v, hi = u < v ? v : u;
|
||||
return ((long)lo << 32) | (uint)hi;
|
||||
}
|
||||
|
||||
private static void AccEdge(Dictionary<long, int> ec, int u, int v)
|
||||
{
|
||||
long k = EdgeKey(u, v);
|
||||
ec.TryGetValue(k, out int c);
|
||||
ec[k] = c + 1;
|
||||
}
|
||||
|
||||
private static bool EdgeIsHorizon(Dictionary<long, int> ec, int u, int v)
|
||||
{
|
||||
ec.TryGetValue(EdgeKey(u, v), out int c);
|
||||
return c == 1;
|
||||
}
|
||||
|
||||
private static int RemapVert(Dictionary<int, int> remap, List<Vector3> outV, Vector3[] pts, int idx)
|
||||
{
|
||||
if (remap.TryGetValue(idx, out int r)) return r;
|
||||
r = outV.Count; remap[idx] = r; outV.Add(pts[idx]);
|
||||
return r;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 80f683e75cf2f2a41bb32d8e9de0a2f0
|
||||
@@ -0,0 +1,393 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Standalone volumetric concealment meter for the painted character.
|
||||
///
|
||||
/// Each evaluation bakes the character's current (deformed) skinned mesh, voxelizes its
|
||||
/// SOLID volume, and reports what fraction of that volume sits INSIDE environment colliders
|
||||
/// (= embedded / hidden) versus exposed. <see cref="EmbeddedPercent"/> is the headline value,
|
||||
/// <see cref="visiblePercent"/> its complement.
|
||||
///
|
||||
/// Pipeline:
|
||||
/// 1. <see cref="SkinnedMeshRenderer.BakeMesh(Mesh)"/> -> mesh in the renderer's LOCAL space, then the
|
||||
/// player's world scale is divided back out (BakeMesh bakes that scale IN; see PlayerScaler) so the
|
||||
/// convention world = transform.localToWorldMatrix holds at ANY size — the same fixup SkinnedColliderBaker
|
||||
/// applies, and the unit-scale mesh MeshPainter borrows from it.
|
||||
/// 2. Column ray-stabbing in local space: each (x,z) grid column collects the Y where the mesh
|
||||
/// surface crosses it; parity (odd # of crossings below a voxel center) marks interior voxels.
|
||||
/// This is the same XZ triangle rasterization MeshPainter.GetIslandMask uses for UV islands.
|
||||
/// 3. Each interior voxel center -> world; tested against broadphase-filtered environment
|
||||
/// colliders: convex primitives / convex MeshCollider via ClosestPoint, Terrain via
|
||||
/// SampleHeight, optional concave MeshCollider via raycast parity.
|
||||
///
|
||||
/// Surface-watertightness caveat: parity assumes a closed mesh. After the Blender merge-by-distance
|
||||
/// the paintman rig is effectively watertight; small leaks only perturb the percentage slightly.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class ConcealmentMeter : MonoBehaviour
|
||||
{
|
||||
[Header("Targets")]
|
||||
[Tooltip("Skinned character to measure. Auto-resolved from this GameObject / children if empty.")]
|
||||
[SerializeField] private SkinnedMeshRenderer targetRenderer;
|
||||
|
||||
[Tooltip("Root of the character's OWN collider hierarchy, excluded from the environment test. " +
|
||||
"Auto = the renderer transform's root.")]
|
||||
[SerializeField] private Transform characterRoot;
|
||||
|
||||
[Header("Voxelization")]
|
||||
[Min(2)]
|
||||
[Tooltip("Voxels along the longest local bounds axis. Higher = more precise & more cost. ~24 is a good start.")]
|
||||
[SerializeField] private int voxelResolution = 24;
|
||||
|
||||
[Header("Environment")]
|
||||
[Tooltip("Layers that count as environment the body can be embedded in.")]
|
||||
[SerializeField] private LayerMask environmentMask = ~0;
|
||||
|
||||
[Tooltip("Treat concave (non-convex) MeshColliders as solid via raycast parity. Off = ignored " +
|
||||
"(ClosestPoint is unsupported for them, and flat floors are meaningless as 'inside').")]
|
||||
[SerializeField] private bool includeConcaveMeshColliders;
|
||||
|
||||
[Tooltip("Count being below a Terrain surface as embedded.")]
|
||||
[SerializeField] private bool includeTerrain = true;
|
||||
|
||||
[Header("Scheduling")]
|
||||
[Min(0f)]
|
||||
[Tooltip("Seconds between evaluations while playing. 0 = every frame.")]
|
||||
[SerializeField] private float evaluateInterval = 0.1f;
|
||||
|
||||
[SerializeField] private bool evaluateInPlayMode = true;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool drawGizmos;
|
||||
[SerializeField] private Color exposedColor = new Color(0.2f, 1f, 0.3f, 0.25f);
|
||||
[SerializeField] private Color embeddedColor = new Color(1f, 0.25f, 0.2f, 0.5f);
|
||||
|
||||
/// <summary>Percentage [0..100] of the character's solid volume embedded inside environment colliders.</summary>
|
||||
public float EmbeddedPercent { get; private set; }
|
||||
|
||||
public float visiblePercent; // Inspector debug readout (complement of EmbeddedPercent)
|
||||
|
||||
public bool HasResult { get; private set; }
|
||||
|
||||
private Mesh _baked;
|
||||
private readonly List<Vector3> _verts = new List<Vector3>(); // refilled per eval without allocating
|
||||
private int[] _tris; // topology is invariant across bakes of the same renderer -> fetched once
|
||||
private int _trisVertexCount = -1; // vertex count _tris was fetched for (detects a renderer/mesh swap)
|
||||
private bool _overlapSaturated; // log-once guard for a saturated broadphase buffer
|
||||
private float _timer;
|
||||
private readonly List<Vector3> _vbuf = new List<Vector3>(); // reused scale-fixup scratch; nothing allocated per eval
|
||||
|
||||
// Per-column Y crossings (index = iz * nx + ix). Reused across evals; sizes grow as needed.
|
||||
private List<float>[] _columns;
|
||||
|
||||
// Broadphase scratch + classified environment candidates (rebuilt each eval).
|
||||
private readonly Collider[] _overlap = new Collider[64];
|
||||
private readonly List<Collider> _convex = new List<Collider>();
|
||||
private readonly List<Terrain> _terrains = new List<Terrain>();
|
||||
private readonly List<MeshCollider> _concave = new List<MeshCollider>();
|
||||
private readonly List<ConcealmentVolume> _volumes = new List<ConcealmentVolume>();
|
||||
|
||||
private readonly List<Vector3> _gizPos = new List<Vector3>();
|
||||
private readonly List<bool> _gizEmbedded = new List<bool>();
|
||||
private float _gizVoxelWorld;
|
||||
|
||||
// ClosestPoint returns the input point unchanged when it is inside a (convex) collider.
|
||||
private const float InsideEpsSqr = 1e-8f;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (!targetRenderer) targetRenderer = GetComponentInChildren<SkinnedMeshRenderer>();
|
||||
if (!characterRoot && targetRenderer) characterRoot = targetRenderer.transform.root;
|
||||
_baked = new Mesh { name = "ConcealmentMeter_Baked" };
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_baked) Destroy(_baked);
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (!evaluateInPlayMode) return;
|
||||
if (evaluateInterval <= 0f) { Evaluate(); return; }
|
||||
_timer += Time.deltaTime;
|
||||
if (_timer < evaluateInterval) return;
|
||||
_timer = 0f;
|
||||
Evaluate();
|
||||
}
|
||||
|
||||
/// <summary>Bakes the current pose, voxelizes it, and recomputes the embedded percentage.</summary>
|
||||
[ContextMenu("Evaluate Now")]
|
||||
public void Evaluate()
|
||||
{
|
||||
if (!targetRenderer) return;
|
||||
|
||||
targetRenderer.BakeMesh(_baked);
|
||||
|
||||
// BakeMesh freezes the bone deformation — which already carries the player's world scale (PlayerScaler)
|
||||
// — into the mesh. Everything below treats the bake as unit-scale LOCAL geometry and raises it to world
|
||||
// via targetRenderer.localToWorldMatrix, which re-applies that same scale. Divide it back out first,
|
||||
// else a scaled player is measured at scale² (voxel world positions, broadphase box AND gizmos), which
|
||||
// also corrupts EmbeddedPercent. No-op at scale 1; the uniform divide is rotation-independent, which the
|
||||
// player's uniform scale guarantees. Mirrors SkinnedColliderBaker's identical fixup.
|
||||
GeometryUtil.RescaleToUnitScale(_baked, targetRenderer.transform.lossyScale, _vbuf);
|
||||
|
||||
// mesh.vertices/.triangles return fresh arrays per call - at 10 evals/s that is steady GC
|
||||
// pressure. Vertices go into a reused list; the (bake-invariant) index buffer is fetched only
|
||||
// when the source mesh actually changed.
|
||||
_baked.GetVertices(_verts);
|
||||
if (_tris == null || _trisVertexCount != _baked.vertexCount)
|
||||
{
|
||||
_tris = _baked.triangles;
|
||||
_trisVertexCount = _baked.vertexCount;
|
||||
}
|
||||
if (_verts.Count == 0 || _tris.Length < 3) return;
|
||||
|
||||
Bounds lb = _baked.bounds; // local-space AABB of the baked pose
|
||||
Vector3 size = lb.size;
|
||||
float maxExtent = Mathf.Max(size.x, Mathf.Max(size.y, size.z));
|
||||
if (maxExtent <= 1e-6f) return;
|
||||
|
||||
float voxel = maxExtent / voxelResolution;
|
||||
int nx = Mathf.Max(1, Mathf.CeilToInt(size.x / voxel));
|
||||
int ny = Mathf.Max(1, Mathf.CeilToInt(size.y / voxel));
|
||||
int nz = Mathf.Max(1, Mathf.CeilToInt(size.z / voxel));
|
||||
Vector3 origin = lb.min;
|
||||
|
||||
StabColumns(origin, voxel, nx, nz);
|
||||
|
||||
Matrix4x4 l2w = targetRenderer.transform.localToWorldMatrix;
|
||||
// Derive the broadphase box from the ACTUAL transformed geometry, not SkinnedMeshRenderer.bounds
|
||||
// (the latter is a root-bone approximation that can be badly offset for a deformed/posed rig).
|
||||
Bounds worldBounds = GeometryUtil.TransformBounds(l2w, lb);
|
||||
BuildEnvironmentCandidates(worldBounds);
|
||||
|
||||
bool anyEnv = _convex.Count > 0 ||
|
||||
_volumes.Count > 0 ||
|
||||
(includeTerrain && _terrains.Count > 0) ||
|
||||
(includeConcaveMeshColliders && _concave.Count > 0);
|
||||
|
||||
_gizPos.Clear();
|
||||
_gizEmbedded.Clear();
|
||||
_gizVoxelWorld = voxel * AverageScale(targetRenderer.transform);
|
||||
|
||||
int interior = 0, embedded = 0;
|
||||
|
||||
for (int iz = 0; iz < nz; iz++)
|
||||
{
|
||||
for (int ix = 0; ix < nx; ix++)
|
||||
{
|
||||
List<float> col = _columns[iz * nx + ix];
|
||||
if (col == null || col.Count < 2) continue;
|
||||
col.Sort();
|
||||
|
||||
// col is sorted and cy grows monotonically with iy, so a single running index
|
||||
// replaces a from-scratch scan per voxel: O(ny + crossings) per column.
|
||||
int below = 0;
|
||||
for (int iy = 0; iy < ny; iy++)
|
||||
{
|
||||
float cy = origin.y + (iy + 0.5f) * voxel;
|
||||
while (below < col.Count && col[below] <= cy) below++;
|
||||
if ((below & 1) == 0) continue; // even crossings below -> outside the solid
|
||||
|
||||
interior++;
|
||||
Vector3 localC = new Vector3(origin.x + (ix + 0.5f) * voxel, cy, origin.z + (iz + 0.5f) * voxel);
|
||||
Vector3 worldC = l2w.MultiplyPoint3x4(localC);
|
||||
|
||||
bool inside = anyEnv && IsInsideAnyEnvironment(worldC);
|
||||
if (inside) embedded++;
|
||||
|
||||
if (drawGizmos)
|
||||
{
|
||||
_gizPos.Add(worldC);
|
||||
_gizEmbedded.Add(inside);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
EmbeddedPercent = interior > 0 ? 100f * embedded / interior : 0f;
|
||||
visiblePercent = 100 - EmbeddedPercent;
|
||||
HasResult = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rasterizes every triangle into the XZ column grid; each column accumulates the Y values
|
||||
/// where the surface crosses it (one per triangle whose XZ projection covers the column center).
|
||||
/// </summary>
|
||||
private void StabColumns(Vector3 origin, float voxel, int nx, int nz)
|
||||
{
|
||||
int cells = nx * nz;
|
||||
if (_columns == null || _columns.Length < cells) _columns = new List<float>[cells];
|
||||
for (int i = 0; i < cells; i++) _columns[i]?.Clear();
|
||||
|
||||
int triCount = _tris.Length / 3;
|
||||
for (int t = 0; t < triCount; t++)
|
||||
{
|
||||
Vector3 a = _verts[_tris[t * 3]];
|
||||
Vector3 b = _verts[_tris[t * 3 + 1]];
|
||||
Vector3 c = _verts[_tris[t * 3 + 2]];
|
||||
|
||||
float ax = a.x, az = a.z, bx = b.x, bz = b.z, cx = c.x, cz = c.z;
|
||||
float d = (bz - cz) * (ax - cx) + (cx - bx) * (az - cz);
|
||||
if (Mathf.Abs(d) < 1e-12f) continue; // degenerate in XZ (e.g. a vertical wall triangle)
|
||||
float invD = 1f / d;
|
||||
|
||||
float minXf = Mathf.Min(ax, Mathf.Min(bx, cx));
|
||||
float maxXf = Mathf.Max(ax, Mathf.Max(bx, cx));
|
||||
float minZf = Mathf.Min(az, Mathf.Min(bz, cz));
|
||||
float maxZf = Mathf.Max(az, Mathf.Max(bz, cz));
|
||||
|
||||
int ix0 = Mathf.Clamp(Mathf.FloorToInt((minXf - origin.x) / voxel), 0, nx - 1);
|
||||
int ix1 = Mathf.Clamp(Mathf.CeilToInt((maxXf - origin.x) / voxel), 0, nx - 1);
|
||||
int iz0 = Mathf.Clamp(Mathf.FloorToInt((minZf - origin.z) / voxel), 0, nz - 1);
|
||||
int iz1 = Mathf.Clamp(Mathf.CeilToInt((maxZf - origin.z) / voxel), 0, nz - 1);
|
||||
|
||||
for (int iz = iz0; iz <= iz1; iz++)
|
||||
{
|
||||
float pz = origin.z + (iz + 0.5f) * voxel;
|
||||
for (int ix = ix0; ix <= ix1; ix++)
|
||||
{
|
||||
float px = origin.x + (ix + 0.5f) * voxel;
|
||||
|
||||
float wa = ((bz - cz) * (px - cx) + (cx - bx) * (pz - cz)) * invD;
|
||||
if (wa < 0f) continue;
|
||||
float wb = ((cz - az) * (px - cx) + (ax - cx) * (pz - cz)) * invD;
|
||||
if (wb < 0f) continue;
|
||||
float wc = 1f - wa - wb;
|
||||
if (wc < 0f) continue;
|
||||
|
||||
float y = wa * a.y + wb * b.y + wc * c.y;
|
||||
int ci = iz * nx + ix;
|
||||
(_columns[ci] ?? (_columns[ci] = new List<float>(8))).Add(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Broadphase-overlaps the character bounds and classifies the hit colliders by test type, excluding the character itself.</summary>
|
||||
private void BuildEnvironmentCandidates(Bounds wb)
|
||||
{
|
||||
_convex.Clear();
|
||||
_terrains.Clear();
|
||||
_concave.Clear();
|
||||
_volumes.Clear();
|
||||
|
||||
// Baked concealment volumes are authoritative for the (open-shell) furniture they cover;
|
||||
// gathered from the registry independent of colliders so they need no specific collider type.
|
||||
IReadOnlyList<ConcealmentVolume> vols = ConcealmentVolume.All;
|
||||
for (int i = 0; i < vols.Count; i++)
|
||||
{
|
||||
ConcealmentVolume v = vols[i];
|
||||
if (!v || !v.HasBake) continue;
|
||||
if (characterRoot && (v.transform == characterRoot || v.transform.IsChildOf(characterRoot))) continue;
|
||||
if (v.WorldBounds.Intersects(wb)) _volumes.Add(v);
|
||||
}
|
||||
|
||||
int n = Physics.OverlapBoxNonAlloc(wb.center, wb.extents, _overlap, Quaternion.identity,
|
||||
environmentMask, QueryTriggerInteraction.Ignore);
|
||||
if (n == _overlap.Length && !_overlapSaturated)
|
||||
{
|
||||
_overlapSaturated = true; // silently dropped colliders would skew EmbeddedPercent low
|
||||
Debug.LogWarning($"ConcealmentMeter: broadphase buffer saturated ({n}); environment colliders may be missed.", this);
|
||||
}
|
||||
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
Collider col = _overlap[i];
|
||||
if (!col) continue;
|
||||
if (characterRoot && (col.transform == characterRoot || col.transform.IsChildOf(characterRoot))) continue;
|
||||
|
||||
if (col is TerrainCollider)
|
||||
{
|
||||
Terrain terr = col.GetComponent<Terrain>();
|
||||
if (terr) _terrains.Add(terr);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (col is MeshCollider mc && !mc.convex)
|
||||
{
|
||||
// A baked ConcealmentVolume or ConcealmentColliderBaker supersedes raycast parity for this object.
|
||||
if (includeConcaveMeshColliders && !mc.GetComponentInParent<ConcealmentVolume>()
|
||||
&& !mc.GetComponentInParent<ConcealmentColliderBaker>())
|
||||
_concave.Add(mc);
|
||||
continue; // concave can't use ClosestPoint
|
||||
}
|
||||
|
||||
_convex.Add(col); // Box / Sphere / Capsule / convex MeshCollider
|
||||
}
|
||||
}
|
||||
|
||||
private bool IsInsideAnyEnvironment(Vector3 p)
|
||||
{
|
||||
for (int i = 0; i < _volumes.Count; i++)
|
||||
{
|
||||
if (_volumes[i].ContainsWorld(p)) return true;
|
||||
}
|
||||
|
||||
for (int i = 0; i < _convex.Count; i++)
|
||||
{
|
||||
if ((_convex[i].ClosestPoint(p) - p).sqrMagnitude <= InsideEpsSqr) return true;
|
||||
}
|
||||
|
||||
if (includeTerrain)
|
||||
{
|
||||
for (int i = 0; i < _terrains.Count; i++)
|
||||
{
|
||||
Terrain terr = _terrains[i];
|
||||
Vector3 tp = terr.transform.position;
|
||||
TerrainData td = terr.terrainData;
|
||||
float lx = p.x - tp.x, lz = p.z - tp.z;
|
||||
if (lx < 0f || lz < 0f || lx > td.size.x || lz > td.size.z) continue;
|
||||
if (p.y <= tp.y + terr.SampleHeight(p)) return true; // below the surface
|
||||
}
|
||||
}
|
||||
|
||||
if (includeConcaveMeshColliders)
|
||||
{
|
||||
for (int i = 0; i < _concave.Count; i++)
|
||||
{
|
||||
if (InsideConcave(_concave[i], p)) return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>Parity test for a closed concave MeshCollider: odd # of surface crossings straight up = inside.</summary>
|
||||
private static bool InsideConcave(MeshCollider mc, Vector3 p)
|
||||
{
|
||||
const float maxDist = 1000f;
|
||||
const float skin = 1e-4f;
|
||||
Vector3 dir = Vector3.up;
|
||||
Ray ray = new Ray(p, dir);
|
||||
int hits = 0;
|
||||
float travelled = 0f;
|
||||
|
||||
while (travelled < maxDist && mc.Raycast(ray, out RaycastHit h, maxDist - travelled))
|
||||
{
|
||||
hits++;
|
||||
travelled += h.distance + skin;
|
||||
ray = new Ray(h.point + dir * skin, dir);
|
||||
}
|
||||
return (hits & 1) == 1;
|
||||
}
|
||||
|
||||
private static float AverageScale(Transform t)
|
||||
{
|
||||
Vector3 s = t.lossyScale;
|
||||
return (Mathf.Abs(s.x) + Mathf.Abs(s.y) + Mathf.Abs(s.z)) / 3f;
|
||||
}
|
||||
|
||||
private void OnDrawGizmos()
|
||||
{
|
||||
if (!drawGizmos || _gizPos.Count == 0) return;
|
||||
Vector3 cube = Vector3.one * (_gizVoxelWorld * 0.9f);
|
||||
for (int i = 0; i < _gizPos.Count; i++)
|
||||
{
|
||||
Gizmos.color = _gizEmbedded[i] ? embeddedColor : exposedColor;
|
||||
Gizmos.DrawCube(_gizPos[i], cube);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 8baaa10e1e77f0944b3aa0b4312fc5a1
|
||||
@@ -0,0 +1,262 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Editor-baked solid/enclosed voxel volume for a single (typically non-convex, non-watertight)
|
||||
/// environment mesh. Runtime lookup is an O(1) grid test, so <see cref="ConcealmentMeter"/> can
|
||||
/// treat furniture as "embeddable" without per-voxel raycast parity (which is unreliable on the
|
||||
/// open-shell furniture meshes of asset kits).
|
||||
///
|
||||
/// Bake (editor only): voxelizes the source mesh in LOCAL space and marks a voxel as INSIDE when
|
||||
/// either classifier fires:
|
||||
/// * Generalized Winding Number > 0.5 -> robustly captures SOLID material even for open shells.
|
||||
/// * Exterior flood-fill cannot reach it -> captures fully ENCLOSED air cavities.
|
||||
/// The union is conservative in the safe direction: an unreachable solid OR an enclosed pocket both
|
||||
/// count as concealment; open regions (seat surface, under a thin table) correctly read as exposed.
|
||||
///
|
||||
/// Query is done via <see cref="ContainsWorld"/>; bake and query share the SAME mesh-local space
|
||||
/// (verts at bake time, transform.InverseTransformPoint at query time), so position/rotation/scale
|
||||
/// are handled uniformly.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class ConcealmentVolume : MonoBehaviour
|
||||
{
|
||||
public enum BakeMethod { WindingNumberOrFloodFill, WindingNumberOnly, FloodFillOnly }
|
||||
|
||||
[Header("Bake settings")]
|
||||
[Min(2)]
|
||||
[Tooltip("Voxels along the longest local-bounds axis. Higher = sharper & bigger bake. ~32 is a good start.")]
|
||||
[SerializeField] private int resolution = 32;
|
||||
|
||||
[Tooltip("Source mesh. Auto = MeshCollider.sharedMesh, else MeshFilter.sharedMesh on this object.")]
|
||||
[SerializeField] private Mesh sourceMesh;
|
||||
|
||||
[SerializeField] private BakeMethod method = BakeMethod.WindingNumberOrFloodFill;
|
||||
|
||||
[Header("Debug")]
|
||||
[SerializeField] private bool drawBakedGizmos;
|
||||
[SerializeField] private Color gizmoColor = new Color(0.2f, 0.6f, 1f, 0.35f);
|
||||
|
||||
// --- Baked data (local space) ---
|
||||
[SerializeField, HideInInspector] private Vector3 origin; // local min corner of the grid
|
||||
[SerializeField, HideInInspector] private float voxelSize;
|
||||
[SerializeField, HideInInspector] private int nx, ny, nz;
|
||||
[SerializeField, HideInInspector] private uint[] bits; // packed inside-flags, idx = ix + nx*(iy + ny*iz)
|
||||
[SerializeField, HideInInspector] private int insideCount;
|
||||
|
||||
public bool HasBake => bits != null && bits.Length > 0 && nx > 0;
|
||||
|
||||
private static readonly List<ConcealmentVolume> Active = new List<ConcealmentVolume>();
|
||||
public static IReadOnlyList<ConcealmentVolume> All => Active;
|
||||
|
||||
private void OnEnable() { if (!Active.Contains(this)) Active.Add(this); }
|
||||
private void OnDisable() { Active.Remove(this); }
|
||||
|
||||
/// <summary>World-space AABB of the baked grid (for broadphase rejection).</summary>
|
||||
public Bounds WorldBounds
|
||||
{
|
||||
get
|
||||
{
|
||||
Vector3 mn = origin;
|
||||
Vector3 mx = origin + new Vector3(nx * voxelSize, ny * voxelSize, nz * voxelSize);
|
||||
var local = new Bounds();
|
||||
local.SetMinMax(mn, mx);
|
||||
return GeometryUtil.TransformBounds(transform.localToWorldMatrix, local);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>O(1) test: is the world point inside a baked-as-solid/enclosed voxel?</summary>
|
||||
public bool ContainsWorld(Vector3 world)
|
||||
{
|
||||
if (!HasBake) return false;
|
||||
Vector3 lp = transform.InverseTransformPoint(world);
|
||||
int ix = Mathf.FloorToInt((lp.x - origin.x) / voxelSize);
|
||||
if (ix < 0 || ix >= nx) return false;
|
||||
int iy = Mathf.FloorToInt((lp.y - origin.y) / voxelSize);
|
||||
if (iy < 0 || iy >= ny) return false;
|
||||
int iz = Mathf.FloorToInt((lp.z - origin.z) / voxelSize);
|
||||
if (iz < 0 || iz >= nz) return false;
|
||||
int idx = ix + nx * (iy + ny * iz);
|
||||
return (bits[idx >> 5] & (1u << (idx & 31))) != 0;
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
[ContextMenu("Bake Concealment Volume")]
|
||||
public void Bake()
|
||||
{
|
||||
Mesh mesh = sourceMesh;
|
||||
if (!mesh)
|
||||
{
|
||||
var mc = GetComponent<MeshCollider>();
|
||||
if (mc) mesh = mc.sharedMesh;
|
||||
}
|
||||
if (!mesh)
|
||||
{
|
||||
var mf = GetComponent<MeshFilter>();
|
||||
if (mf) mesh = mf.sharedMesh;
|
||||
}
|
||||
if (!mesh) { Debug.LogError($"[ConcealmentVolume] {name}: no source mesh.", this); return; }
|
||||
|
||||
Vector3[] verts = mesh.vertices;
|
||||
int[] tris = mesh.triangles;
|
||||
if (verts.Length == 0 || tris.Length < 3) { Debug.LogError($"[ConcealmentVolume] {name}: empty mesh.", this); return; }
|
||||
|
||||
Bounds lb = mesh.bounds;
|
||||
float maxExtent = Mathf.Max(lb.size.x, Mathf.Max(lb.size.y, lb.size.z));
|
||||
if (maxExtent <= 1e-6f) { Debug.LogError($"[ConcealmentVolume] {name}: degenerate bounds.", this); return; }
|
||||
|
||||
float vs = maxExtent / resolution;
|
||||
const int pad = 1; // empty shell so exterior flood always has a seed
|
||||
nx = Mathf.CeilToInt(lb.size.x / vs) + 2 * pad;
|
||||
ny = Mathf.CeilToInt(lb.size.y / vs) + 2 * pad;
|
||||
nz = Mathf.CeilToInt(lb.size.z / vs) + 2 * pad;
|
||||
voxelSize = vs;
|
||||
origin = lb.min - new Vector3(vs, vs, vs) * pad;
|
||||
|
||||
int count = nx * ny * nz;
|
||||
bool[] wall = new bool[count];
|
||||
|
||||
// --- 1) Rasterize triangles into wall voxels (barycentric supersampling, step = vs/2) ---
|
||||
for (int t = 0; t < tris.Length; t += 3)
|
||||
{
|
||||
Vector3 a = verts[tris[t]], b = verts[tris[t + 1]], c = verts[tris[t + 2]];
|
||||
float eMax = Mathf.Max((b - a).magnitude, (c - a).magnitude);
|
||||
int steps = Mathf.Max(1, Mathf.CeilToInt(eMax / (vs * 0.5f)));
|
||||
float inv = 1f / steps;
|
||||
for (int u = 0; u <= steps; u++)
|
||||
{
|
||||
for (int v = 0; v <= steps - u; v++)
|
||||
{
|
||||
Vector3 p = a + (b - a) * (u * inv) + (c - a) * (v * inv);
|
||||
int ix = Mathf.Clamp(Mathf.FloorToInt((p.x - origin.x) / vs), 0, nx - 1);
|
||||
int iy = Mathf.Clamp(Mathf.FloorToInt((p.y - origin.y) / vs), 0, ny - 1);
|
||||
int iz = Mathf.Clamp(Mathf.FloorToInt((p.z - origin.z) / vs), 0, nz - 1);
|
||||
wall[ix + nx * (iy + ny * iz)] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// --- 2) Exterior flood-fill (6-connected) through non-wall voxels ---
|
||||
bool[] outside = new bool[count];
|
||||
var stack = new Stack<int>(count / 4 + 16);
|
||||
for (int iz = 0; iz < nz; iz++)
|
||||
for (int iy = 0; iy < ny; iy++)
|
||||
for (int ix = 0; ix < nx; ix++)
|
||||
{
|
||||
if (ix != 0 && ix != nx - 1 && iy != 0 && iy != ny - 1 && iz != 0 && iz != nz - 1) continue;
|
||||
int idx = ix + nx * (iy + ny * iz);
|
||||
if (!wall[idx] && !outside[idx]) { outside[idx] = true; stack.Push(idx); }
|
||||
}
|
||||
while (stack.Count > 0)
|
||||
{
|
||||
int idx = stack.Pop();
|
||||
int ix = idx % nx;
|
||||
int iy = (idx / nx) % ny;
|
||||
int iz = idx / (nx * ny);
|
||||
PushNeighbor(ix - 1, iy, iz, wall, outside, stack);
|
||||
PushNeighbor(ix + 1, iy, iz, wall, outside, stack);
|
||||
PushNeighbor(ix, iy - 1, iz, wall, outside, stack);
|
||||
PushNeighbor(ix, iy + 1, iz, wall, outside, stack);
|
||||
PushNeighbor(ix, iy, iz - 1, wall, outside, stack);
|
||||
PushNeighbor(ix, iy, iz + 1, wall, outside, stack);
|
||||
}
|
||||
|
||||
// --- 3) Classify each voxel; pack into bits ---
|
||||
bits = new uint[(count + 31) >> 5];
|
||||
insideCount = 0;
|
||||
int floodOnly = 0, windingOnly = 0;
|
||||
bool useWinding = method != BakeMethod.FloodFillOnly;
|
||||
bool useFlood = method != BakeMethod.WindingNumberOnly;
|
||||
|
||||
for (int iz = 0; iz < nz; iz++)
|
||||
for (int iy = 0; iy < ny; iy++)
|
||||
for (int ix = 0; ix < nx; ix++)
|
||||
{
|
||||
int idx = ix + nx * (iy + ny * iz);
|
||||
bool enclosed = useFlood && !outside[idx];
|
||||
bool solid = false;
|
||||
if (useWinding)
|
||||
solid = WindingNumber(VoxelCenter(origin, ix, iy, iz, vs), verts, tris) > 0.5;
|
||||
bool inside = enclosed || solid;
|
||||
if (!inside) continue;
|
||||
bits[idx >> 5] |= 1u << (idx & 31);
|
||||
insideCount++;
|
||||
if (enclosed && !solid) floodOnly++;
|
||||
if (solid && !enclosed) windingOnly++;
|
||||
}
|
||||
|
||||
UnityEditor.Undo.RecordObject(this, "Bake Concealment Volume");
|
||||
UnityEditor.EditorUtility.SetDirty(this);
|
||||
if (!Application.isPlaying)
|
||||
UnityEditor.SceneManagement.EditorSceneManager.MarkSceneDirty(gameObject.scene);
|
||||
|
||||
Debug.Log($"[ConcealmentVolume] {name}: baked {nx}x{ny}x{nz} (vs={vs:F3}) -> inside={insideCount}/{count} " +
|
||||
$"(winding-only={windingOnly}, flood-only={floodOnly}), bits={bits.Length * 4}B", this);
|
||||
}
|
||||
|
||||
[ContextMenu("Clear Bake")]
|
||||
public void ClearBake()
|
||||
{
|
||||
bits = null; nx = ny = nz = 0; insideCount = 0;
|
||||
UnityEditor.EditorUtility.SetDirty(this);
|
||||
}
|
||||
|
||||
private void PushNeighbor(int ix, int iy, int iz, bool[] wall, bool[] outside, Stack<int> stack)
|
||||
{
|
||||
if (ix < 0 || ix >= nx || iy < 0 || iy >= ny || iz < 0 || iz >= nz) return;
|
||||
int idx = ix + nx * (iy + ny * iz);
|
||||
if (wall[idx] || outside[idx]) return;
|
||||
outside[idx] = true;
|
||||
stack.Push(idx);
|
||||
}
|
||||
|
||||
/// <summary>Generalized winding number at p (mesh-local). ~1 inside a solid, ~0 outside; robust for open shells.</summary>
|
||||
private static double WindingNumber(Vector3 p, Vector3[] verts, int[] tris)
|
||||
{
|
||||
double sum = 0.0;
|
||||
double px = p.x, py = p.y, pz = p.z;
|
||||
for (int t = 0; t < tris.Length; t += 3)
|
||||
{
|
||||
Vector3 A = verts[tris[t]], B = verts[tris[t + 1]], C = verts[tris[t + 2]];
|
||||
double ax = A.x - px, ay = A.y - py, az = A.z - pz;
|
||||
double bx = B.x - px, by = B.y - py, bz = B.z - pz;
|
||||
double cx = C.x - px, cy = C.y - py, cz = C.z - pz;
|
||||
double la = System.Math.Sqrt(ax * ax + ay * ay + az * az);
|
||||
double lb = System.Math.Sqrt(bx * bx + by * by + bz * bz);
|
||||
double lc = System.Math.Sqrt(cx * cx + cy * cy + cz * cz);
|
||||
if (la < 1e-12 || lb < 1e-12 || lc < 1e-12) continue;
|
||||
// triple product a . (b x c)
|
||||
double crx = by * cz - bz * cy;
|
||||
double cry = bz * cx - bx * cz;
|
||||
double crz = bx * cy - by * cx;
|
||||
double numer = ax * crx + ay * cry + az * crz;
|
||||
double denom = la * lb * lc
|
||||
+ (ax * bx + ay * by + az * bz) * lc
|
||||
+ (bx * cx + by * cy + bz * cz) * la
|
||||
+ (cx * ax + cy * ay + cz * az) * lb;
|
||||
sum += System.Math.Atan2(numer, denom) * 2.0;
|
||||
}
|
||||
return sum / (4.0 * System.Math.PI);
|
||||
}
|
||||
|
||||
/// <summary>Local-space center of grid voxel (ix, iy, iz).</summary>
|
||||
private static Vector3 VoxelCenter(Vector3 origin, int ix, int iy, int iz, float size)
|
||||
=> new Vector3(origin.x + (ix + 0.5f) * size, origin.y + (iy + 0.5f) * size, origin.z + (iz + 0.5f) * size);
|
||||
|
||||
private void OnDrawGizmosSelected()
|
||||
{
|
||||
if (!drawBakedGizmos || !HasBake) return;
|
||||
Gizmos.color = gizmoColor;
|
||||
Gizmos.matrix = transform.localToWorldMatrix;
|
||||
Vector3 cube = Vector3.one * (voxelSize * 0.9f);
|
||||
for (int iz = 0; iz < nz; iz++)
|
||||
for (int iy = 0; iy < ny; iy++)
|
||||
for (int ix = 0; ix < nx; ix++)
|
||||
{
|
||||
int idx = ix + nx * (iy + ny * iz);
|
||||
if ((bits[idx >> 5] & (1u << (idx & 31))) == 0) continue;
|
||||
Gizmos.DrawCube(VoxelCenter(origin, ix, iy, iz, voxelSize), cube);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0a59a347a3128354ebd4f84afc97a0c5
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c34d766647ee8994d83c19705febc269
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,184 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
/// <summary>
|
||||
/// First-person camera. Drives its OWN dedicated <see cref="CinemachineCamera"/>: each frame it snaps
|
||||
/// to the player's dedicated <c>FirstPersonTarget</c> (the eye anchor) for position and
|
||||
/// applies free mouse-look (yaw/pitch) for rotation. While active the player's mesh is hidden so the
|
||||
/// body isn't seen from the inside. Movement stays with the player controller, which reads the
|
||||
/// brain-driven Main Camera forward, so WASD is automatically relative to this view.
|
||||
///
|
||||
/// Activation is driven by <see cref="FirstPersonMode"/> via <see cref="SetActive"/>: it raises the
|
||||
/// vcam priority so the CinemachineBrain blends to it, snaps look to the current view (no jump), hides
|
||||
/// the mesh and locks the cursor. While inactive the vcam rests at a low priority and this component
|
||||
/// does nothing.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CinemachineCamera))]
|
||||
[DisallowMultipleComponent]
|
||||
public class FirstPersonController : MonoBehaviour
|
||||
{
|
||||
[Header("Target")]
|
||||
[Tooltip("Eye position the camera sits at (the player's FirstPersonTarget). Empty = auto-resolved from the player's \"FirstPersonTarget\" child.")]
|
||||
public Transform cameraTarget;
|
||||
|
||||
[Tooltip("Renderers hidden while first-person is active (so you don't see your own body). Empty = the player's SkinnedMeshRenderers, auto-resolved.")]
|
||||
public Renderer[] hiddenRenderers;
|
||||
|
||||
[Header("Look")]
|
||||
[Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")]
|
||||
public float horizontalSensitivity = 0.2f;
|
||||
|
||||
[Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")]
|
||||
public float verticalSensitivity = 0.15f;
|
||||
|
||||
[Tooltip("Invert vertical look (mouse up tilts the camera down).")]
|
||||
public bool invertY = false;
|
||||
|
||||
[Tooltip("Pitch is clamped to +/- this angle (degrees) so the camera never flips over.")]
|
||||
[Range(1f, 89.9f)]
|
||||
public float maxPitch = 89f;
|
||||
|
||||
[Header("Smoothing")]
|
||||
[Tooltip("Look smoothing, in seconds. 0 = raw mouse (instant); small values take the edge off jitter without feeling laggy.")]
|
||||
[Range(0f, 0.3f)]
|
||||
public float lookSmoothTime = 0.04f;
|
||||
|
||||
[Header("Camera")]
|
||||
[Tooltip("Priority the dedicated CinemachineCamera takes while active (must beat the gameplay camera).")]
|
||||
public int activePriority = 100;
|
||||
|
||||
[Tooltip("Priority the dedicated CinemachineCamera rests at while inactive (must lose to the gameplay camera).")]
|
||||
public int inactivePriority = -100;
|
||||
|
||||
CinemachineCamera _vcam;
|
||||
PlayerCharacterController _player; // body whose yaw we pin to the camera while active
|
||||
PlayerPoseController _poseController; // supplies the active pose's per-pose first-person eye offset
|
||||
SmoothedLook _look; // yaw/pitch accumulator + smoothing shared with the freecam
|
||||
bool _resolved;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_vcam = GetComponent<CinemachineCamera>();
|
||||
if (!_vcam)
|
||||
Debug.LogError("[FirstPersonController] No CinemachineCamera on this GameObject; first-person cannot go live.", this);
|
||||
else
|
||||
_vcam.Priority = inactivePriority;
|
||||
|
||||
ResolveReferences();
|
||||
|
||||
// Dormant until FirstPersonMode enters; nothing reads input or moves the rig meanwhile.
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
/// <summary>Enter/leave first-person. Raises/lowers the vcam priority, hides the player mesh, snaps
|
||||
/// look to the live view and manages the cursor. Called by <see cref="FirstPersonMode"/>.</summary>
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (_vcam)
|
||||
_vcam.Priority = active ? activePriority : inactivePriority;
|
||||
|
||||
SetMeshHidden(active);
|
||||
|
||||
if (active)
|
||||
{
|
||||
SyncToCurrentView();
|
||||
InputUtil.SetCursorLocked(true);
|
||||
ApplyTransform(); // place at the eye on the same frame so the blend starts from there
|
||||
if (_player) _player.FaceYaw = _look.DisplayYaw; // body faces the camera from frame one
|
||||
}
|
||||
else
|
||||
{
|
||||
InputUtil.SetCursorLocked(false);
|
||||
if (_player) _player.FaceYaw = null; // restore movement-facing
|
||||
}
|
||||
|
||||
enabled = active;
|
||||
}
|
||||
|
||||
void LateUpdate()
|
||||
{
|
||||
// LateUpdate so the player has already moved the CameraTarget this frame.
|
||||
LookTick();
|
||||
ApplyTransform();
|
||||
if (_player) _player.FaceYaw = _look.DisplayYaw; // keep the body's yaw locked to the camera
|
||||
}
|
||||
|
||||
void LookTick()
|
||||
{
|
||||
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
|
||||
_look.Tick(delta, horizontalSensitivity, verticalSensitivity, invertY, maxPitch, lookSmoothTime);
|
||||
}
|
||||
|
||||
void ApplyTransform()
|
||||
{
|
||||
Quaternion rot = _look.Rotation;
|
||||
if (cameraTarget)
|
||||
{
|
||||
// Sit at the first-person eye anchor, then raise/lower the eye by the active pose's first-person
|
||||
// offset. TransformVector keeps it in the target's local up and scales with the player (so it
|
||||
// tracks PlayerScaler).
|
||||
Vector3 pos = cameraTarget.position;
|
||||
float offset = _poseController ? _poseController.FirstPersonCameraYOffset : 0f;
|
||||
if (offset != 0f)
|
||||
pos += cameraTarget.TransformVector(Vector3.up * offset);
|
||||
transform.SetPositionAndRotation(pos, rot);
|
||||
}
|
||||
else
|
||||
transform.rotation = rot;
|
||||
}
|
||||
|
||||
/// <summary>Seed look from the currently rendered view so going live produces no visible jump.</summary>
|
||||
void SyncToCurrentView()
|
||||
{
|
||||
var cam = Camera.main;
|
||||
if (cam)
|
||||
_look.SyncTo(cam.transform.rotation, maxPitch);
|
||||
else
|
||||
_look.Snap(); // no camera: keep the current angles but still snap the smoothed state
|
||||
}
|
||||
|
||||
void SetMeshHidden(bool hidden)
|
||||
{
|
||||
if (hiddenRenderers == null) return;
|
||||
foreach (var r in hiddenRenderers)
|
||||
if (r) r.enabled = !hidden;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Explicitly binds this rig to the locally-owned networked player (overriding the scene-wide
|
||||
/// auto-resolve, which could otherwise latch onto a remote avatar). Called by the GameModeManager
|
||||
/// when the local player spawns.
|
||||
/// </summary>
|
||||
public void SetPlayer(PlayerCharacterController p)
|
||||
{
|
||||
if (!p) return;
|
||||
_player = p;
|
||||
_poseController = p.GetComponent<PlayerPoseController>();
|
||||
Transform t = p.transform.Find("FirstPersonTarget");
|
||||
if (t) cameraTarget = t;
|
||||
hiddenRenderers = p.GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||
_resolved = true;
|
||||
}
|
||||
|
||||
void ResolveReferences()
|
||||
{
|
||||
if (_resolved) return;
|
||||
_resolved = true;
|
||||
|
||||
var player = FindFirstObjectByType<PlayerCharacterController>(FindObjectsInactive.Include);
|
||||
if (!player) return;
|
||||
_player = player;
|
||||
|
||||
if (!_poseController)
|
||||
_poseController = player.GetComponent<PlayerPoseController>();
|
||||
|
||||
if (!cameraTarget)
|
||||
{
|
||||
Transform t = player.transform.Find("FirstPersonTarget");
|
||||
if (t) cameraTarget = t;
|
||||
}
|
||||
|
||||
if (hiddenRenderers == null || hiddenRenderers.Length == 0)
|
||||
hiddenRenderers = player.GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 43b8f1dc43642294f99fab0aaabf6197
|
||||
@@ -0,0 +1,193 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Free-flying spectator camera. Drives its OWN dedicated <see cref="CinemachineCamera"/> by moving
|
||||
/// this transform with WASD (relative to where it looks), Q/E (or Space/Left Ctrl) for down/up
|
||||
/// and the mouse for look. Collision is a <see cref="CharacterController"/> capsule sized like the
|
||||
/// player's, so the freecam slides along world geometry instead of clipping through walls/floors.
|
||||
///
|
||||
/// Activation is driven by <see cref="FreeCamMode"/> via <see cref="SetActive"/>: it raises the vcam
|
||||
/// priority so the CinemachineBrain blends to it, snaps the rig to the current view (no jump) and
|
||||
/// locks the cursor. While inactive the vcam rests at a low priority and this component does nothing.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CharacterController))]
|
||||
[DisallowMultipleComponent]
|
||||
public class FreeCamController : MonoBehaviour
|
||||
{
|
||||
[Header("Flight")]
|
||||
[Tooltip("Base flight speed (units/second).")]
|
||||
public float moveSpeed = 6f;
|
||||
|
||||
[Tooltip("Speed multiplier while the sprint key (Left Shift) is held.")]
|
||||
public float sprintMultiplier = 3f;
|
||||
|
||||
[Header("Look")]
|
||||
[Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")]
|
||||
public float horizontalSensitivity = 0.2f;
|
||||
|
||||
[Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")]
|
||||
public float verticalSensitivity = 0.15f;
|
||||
|
||||
[Tooltip("Invert vertical look (mouse up tilts the camera down).")]
|
||||
public bool invertY = false;
|
||||
|
||||
[Tooltip("Pitch is clamped to +/- this angle (degrees) so the camera never flips over.")]
|
||||
[Range(1f, 89.9f)]
|
||||
public float maxPitch = 89f;
|
||||
|
||||
[Header("Smoothing")]
|
||||
[Tooltip("Acceleration/deceleration smoothing for flight, in seconds. 0 = instant (snappy starts/stops); higher = floatier ramp-up and coast-down.")]
|
||||
[Range(0f, 1f)]
|
||||
public float moveSmoothTime = 0.12f;
|
||||
|
||||
[Tooltip("Look smoothing, in seconds. 0 = raw mouse (instant); small values take the edge off jitter without feeling laggy.")]
|
||||
[Range(0f, 0.3f)]
|
||||
public float lookSmoothTime = 0.04f;
|
||||
|
||||
[Header("Camera")]
|
||||
[Tooltip("Priority the dedicated CinemachineCamera takes while active (must beat the gameplay camera).")]
|
||||
public int activePriority = 100;
|
||||
|
||||
[Tooltip("Priority the dedicated CinemachineCamera rests at while inactive (must lose to the gameplay camera).")]
|
||||
public int inactivePriority = -100;
|
||||
|
||||
CharacterController _controller;
|
||||
CinemachineCamera _vcam;
|
||||
SmoothedLook _look; // yaw/pitch accumulator + smoothing shared with first person
|
||||
Vector3 _moveVelocity; // smoothed world-space flight velocity
|
||||
Vector3 _moveVelocityRef;
|
||||
bool _ignoredPlayer;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_controller = GetComponent<CharacterController>();
|
||||
_vcam = GetComponent<CinemachineCamera>();
|
||||
if (!_vcam)
|
||||
Debug.LogError("[FreeCamController] No CinemachineCamera on this GameObject; freecam cannot go live.", this);
|
||||
else
|
||||
_vcam.Priority = inactivePriority;
|
||||
|
||||
// Dormant until FreeCamMode enters; nothing reads input or moves the rig meanwhile.
|
||||
enabled = false;
|
||||
}
|
||||
|
||||
/// <summary>Enter/leave freecam. Raises/lowers the vcam priority, snaps to the live view and
|
||||
/// manages the cursor. Called by <see cref="FreeCamMode"/>.</summary>
|
||||
public void SetActive(bool active)
|
||||
{
|
||||
if (_vcam)
|
||||
_vcam.Priority = active ? activePriority : inactivePriority;
|
||||
|
||||
if (active)
|
||||
{
|
||||
IgnorePlayerCollision();
|
||||
SyncToCurrentView();
|
||||
InputUtil.SetCursorLocked(true);
|
||||
}
|
||||
else
|
||||
{
|
||||
InputUtil.SetCursorLocked(false);
|
||||
}
|
||||
|
||||
enabled = active;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
LookTick();
|
||||
MoveTick();
|
||||
}
|
||||
|
||||
void LookTick()
|
||||
{
|
||||
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
|
||||
_look.Tick(delta, horizontalSensitivity, verticalSensitivity, invertY, maxPitch, lookSmoothTime);
|
||||
|
||||
// Rotation is unconstrained by the CharacterController; only position is.
|
||||
transform.rotation = _look.Rotation;
|
||||
}
|
||||
|
||||
void MoveTick()
|
||||
{
|
||||
Vector3 dir = ReadMoveDirection();
|
||||
if (dir.sqrMagnitude > 1f)
|
||||
dir.Normalize();
|
||||
|
||||
float speed = moveSpeed * (SprintHeld() ? sprintMultiplier : 1f);
|
||||
Vector3 targetVelocity = dir * speed;
|
||||
|
||||
// SmoothDamp eases accel/decel and is already dt-correct; 0 collapses to instant.
|
||||
_moveVelocity = moveSmoothTime > 0f
|
||||
? Vector3.SmoothDamp(_moveVelocity, targetVelocity, ref _moveVelocityRef, moveSmoothTime)
|
||||
: targetVelocity;
|
||||
|
||||
_controller.Move(_moveVelocity * Time.deltaTime);
|
||||
}
|
||||
|
||||
/// <summary>Place the rig at the currently rendered view so going live produces no visible jump.</summary>
|
||||
void SyncToCurrentView()
|
||||
{
|
||||
var cam = Camera.main;
|
||||
if (!cam) return;
|
||||
|
||||
CharacterTeleport.Move(_controller, transform, cam.transform.position, cam.transform.rotation);
|
||||
|
||||
_look.SyncTo(transform.rotation, maxPitch);
|
||||
_moveVelocity = Vector3.zero;
|
||||
_moveVelocityRef = Vector3.zero;
|
||||
}
|
||||
|
||||
/// <summary>Stop the freecam capsule from being blocked by the (frozen) player's capsule.</summary>
|
||||
void IgnorePlayerCollision()
|
||||
{
|
||||
if (_ignoredPlayer) return;
|
||||
_ignoredPlayer = true;
|
||||
|
||||
var player = FindFirstObjectByType<PlayerCharacterController>();
|
||||
if (player)
|
||||
{
|
||||
var playerCC = player.GetComponent<CharacterController>();
|
||||
// Only an enabled capsule can take part in a collision-ignore (a spectator's body has its CC
|
||||
// disabled, and there is nothing to collide with there anyway).
|
||||
if (playerCC && playerCC.enabled)
|
||||
Physics.IgnoreCollision(_controller, playerCC, true);
|
||||
}
|
||||
}
|
||||
|
||||
Vector3 ReadMoveDirection()
|
||||
{
|
||||
Vector3 dir = Vector3.zero;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
var kb = Keyboard.current;
|
||||
if (kb != null)
|
||||
{
|
||||
if (kb.wKey.isPressed) dir += transform.forward;
|
||||
if (kb.sKey.isPressed) dir -= transform.forward;
|
||||
if (kb.dKey.isPressed) dir += transform.right;
|
||||
if (kb.aKey.isPressed) dir -= transform.right;
|
||||
if (kb.eKey.isPressed || kb.spaceKey.isPressed) dir += Vector3.up;
|
||||
if (kb.qKey.isPressed || kb.leftCtrlKey.isPressed) dir -= Vector3.up;
|
||||
}
|
||||
#else
|
||||
dir += transform.forward * Input.GetAxisRaw("Vertical");
|
||||
dir += transform.right * Input.GetAxisRaw("Horizontal");
|
||||
if (Input.GetKey(KeyCode.E) || Input.GetKey(KeyCode.Space)) dir += Vector3.up;
|
||||
if (Input.GetKey(KeyCode.Q) || Input.GetKey(KeyCode.LeftControl)) dir -= Vector3.up;
|
||||
#endif
|
||||
return dir;
|
||||
}
|
||||
|
||||
bool SprintHeld()
|
||||
{
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
return Keyboard.current != null && Keyboard.current.leftShiftKey.isPressed;
|
||||
#else
|
||||
return Input.GetKey(KeyCode.LeftShift);
|
||||
#endif
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ca7cff7542b53b498f0a9834786b9e0
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 463b6be548abf464287096e1e92fcd01
|
||||
@@ -0,0 +1,525 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
using UnityEngine.Animations;
|
||||
using UnityEngine.Playables;
|
||||
using UnityEngine.Serialization;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// Holds a list of <see cref="PlayerPose"/>s plus the active index. The pose system owns ALL character
|
||||
/// animation (the Animator is left empty): standing / airborne / wall / ceiling holds the active pose's
|
||||
/// idle clip, walking/sprinting on the ground plays its optional walk/run clip (unset = idle held).
|
||||
///
|
||||
/// One key cycles poses (wrapping); sprinting in any state snaps back to the default pose, held
|
||||
/// continuously so the reset shows up immediately. Clips play through a tiny <see cref="PlayableGraph"/>
|
||||
/// (two-input mixer on an <see cref="AnimationPlayableOutput"/>) that crossfades every clip switch over
|
||||
/// <see cref="poseBlendDuration"/>. Each pose also carries its own CharacterController capsule (eased,
|
||||
/// so a low pose can crawl under furniture) and per-pose camera y-offsets (third-person framing and
|
||||
/// first-person eye).
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(PlayerCharacterController))]
|
||||
[DisallowMultipleComponent]
|
||||
// Run after PlayerCharacterController (default order 0) so the sprint / locomotion state polled below is
|
||||
// this frame's, and the per-pose capsule / camera offset resolve without a one-frame seam.
|
||||
[DefaultExecutionOrder(100)]
|
||||
public class PlayerPoseController : MonoBehaviour
|
||||
{
|
||||
[Header("Poses")]
|
||||
[Tooltip("Selectable poses. Index 0 is the default pose that sprinting resets to.")]
|
||||
[SerializeField] private List<PlayerPose> poses = new();
|
||||
|
||||
[Tooltip("Index the selection resets to while sprinting in any state (the default/standard pose).")]
|
||||
[Min(0)]
|
||||
[SerializeField] private int defaultPoseIndex = 0;
|
||||
|
||||
[Header("Input")]
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
[Tooltip("Key that cycles to the next pose (wraps at the end of the list).")]
|
||||
[SerializeField] private Key cycleKey = Key.C;
|
||||
#else
|
||||
[Tooltip("Key that cycles to the next pose (wraps at the end of the list).")]
|
||||
[SerializeField] private KeyCode cycleKey = KeyCode.C;
|
||||
#endif
|
||||
|
||||
[Header("Transitions")]
|
||||
[Tooltip("Seconds to crossfade from the previous pose to a newly selected one. 0 = instant snap.")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float poseBlendDuration = 0.2f;
|
||||
|
||||
[Tooltip("Approach speed (local units/sec) for easing the per-pose camera-target y-offset toward its " +
|
||||
"target while cycling poses. 0 = instant.")]
|
||||
[Min(0f)]
|
||||
[FormerlySerializedAs("ceilingOffsetSpeed")]
|
||||
[SerializeField] private float cameraOffsetSpeed = 4f;
|
||||
|
||||
[Tooltip("Blend speed (0..1 per second) for resizing the CharacterController capsule toward the active " +
|
||||
"pose's dimensions when the pose changes. 0 = instant snap; higher = snappier resize.")]
|
||||
[Min(0f)]
|
||||
[SerializeField] private float capsuleBlendSpeed = 8f;
|
||||
|
||||
[Tooltip("Apply Mecanim foot IK to the played humanoid clips. Off (default) plays each pose exactly as " +
|
||||
"authored. On bends the knees to plant the feet on the IK goal, which makes a straight-knee idle " +
|
||||
"render visibly bent even when the clip's Lower Leg Stretch muscle is fully straight.")]
|
||||
[SerializeField] private bool applyFootIK = false;
|
||||
|
||||
private PlayerCharacterController _player;
|
||||
private Animator _animator;
|
||||
private string _graphName; // cached so building the graph allocates no string from the Update path
|
||||
|
||||
private int _currentIndex;
|
||||
|
||||
/// <summary>Locomotion category that selects which clip the active pose plays.</summary>
|
||||
public enum Locomotion { Idle, Walk, Run, Fall }
|
||||
|
||||
// Network drive: a remote avatar's controllers are disabled (NetworkTransform owns the transform),
|
||||
// so its movement flags are dead - pose index + locomotion come from the owner via SetNetworkState.
|
||||
// _currentLocomotion is the category resolved this frame, published by the owner for replication.
|
||||
private bool _networkControlled;
|
||||
private Locomotion _networkLoco;
|
||||
private Locomotion _currentLocomotion;
|
||||
|
||||
// Playables: a 2-input AnimationMixerPlayable that overrides the Animator Controller. Input 1 holds
|
||||
// the current ("to") pose; input 0 holds the outgoing ("from") pose during a crossfade and is empty
|
||||
// once the blend completes. Blending both inputs over a per-frame rest baseline (see below) makes
|
||||
// poses interpolate instead of snapping; an empty/partial "to" clip still resolves to rest.
|
||||
private PlayableGraph _graph;
|
||||
private AnimationPlayableOutput _output;
|
||||
private AnimationMixerPlayable _mixer;
|
||||
private AnimationClipPlayable _fromPlayable;
|
||||
private AnimationClipPlayable _toPlayable;
|
||||
private AnimationClip _toClip; // the clip currently targeted (held on input 1)
|
||||
private bool _posePlaying;
|
||||
private float _blend; // 0..1 weight of the "to" input; 1 = fully on the target pose
|
||||
private bool _blending;
|
||||
|
||||
private float _cameraPoseOffset; // eased per-pose camera-target offset routed to the controller's camera target
|
||||
private float _firstPersonCameraOffset; // eased per-pose first-person eye offset, read by the FirstPersonController (never written to the shared camera target)
|
||||
|
||||
// Capsule blend: the CharacterController dimensions are eased toward the active pose's via a single
|
||||
// normalized blend (so the capsule stays a valid shape throughout) and written through the controller.
|
||||
// Snapped on the first frame so the player spawns at its pose size with no resize blend.
|
||||
private bool _capsuleStarted;
|
||||
private float _capFromCenterY, _capFromRadius, _capFromHeight;
|
||||
private float _capToCenterY, _capToRadius, _capToHeight;
|
||||
private float _capCenterY, _capRadius, _capHeight;
|
||||
private float _capBlend;
|
||||
|
||||
// Rest-pose snapshot of the Animator subtree, taken before the Animator first evaluates. A clip only
|
||||
// writes the bones its curves cover, so un-keyed bones would stay frozen on the previous animation;
|
||||
// restoring rest first makes every pose define the full body. The controller's meshOffsetTarget is
|
||||
// dropped from the set (see CaptureRestPose) so the wall-lean / ground raise is never wiped.
|
||||
private Transform[] _restBones;
|
||||
private Vector3[] _restPos;
|
||||
private Quaternion[] _restRot;
|
||||
private Vector3[] _restScale;
|
||||
|
||||
/// <summary>Index of the currently selected pose.</summary>
|
||||
public int CurrentIndex => _currentIndex;
|
||||
|
||||
/// <summary>Locomotion category resolved this frame (which clip the active pose is playing). The owner publishes it for network sync.</summary>
|
||||
public Locomotion CurrentLocomotion => _currentLocomotion;
|
||||
|
||||
/// <summary>
|
||||
/// Switches this avatar to network-driven animation: local input, the sprint-reset, the per-pose
|
||||
/// collision capsule and camera framing are all skipped; pose index and locomotion are supplied by
|
||||
/// <see cref="SetNetworkState"/>. Called on a remote avatar by <see cref="PlayerAnimationSync"/>.
|
||||
/// </summary>
|
||||
public void EnableNetworkDrive() => _networkControlled = true;
|
||||
|
||||
/// <summary>Pushes the replicated pose index and locomotion category onto a network-driven avatar.</summary>
|
||||
public void SetNetworkState(int poseIndex, Locomotion loco)
|
||||
{
|
||||
_currentIndex = WrapIndex(poseIndex);
|
||||
_networkLoco = loco;
|
||||
}
|
||||
|
||||
/// <summary>Eased per-pose y-offset for the first-person eye, applied on top of the camera target by the FirstPersonController.</summary>
|
||||
public float FirstPersonCameraYOffset => _firstPersonCameraOffset;
|
||||
|
||||
/// <summary>Holds the avatar on its current pose frame: the Playable graph stops advancing (its bones stay
|
||||
/// exactly where they are) and all per-frame locomotion / rest-reset / capsule / camera work is skipped.
|
||||
/// Set per-client for every still-in-play hider during the resolution reveal so nobody can wiggle in place.
|
||||
/// Clearing it resumes normal playback. Independent of network drive — works on the owner and on remotes.</summary>
|
||||
public bool Frozen { get; set; }
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_player = GetComponent<PlayerCharacterController>();
|
||||
_animator = _player ? _player.GetComponentInChildren<Animator>() : null;
|
||||
_graphName = $"{name}-Pose";
|
||||
if (_animator)
|
||||
CaptureRestPose();
|
||||
else
|
||||
Debug.LogWarning("[PlayerPoseController] No Animator on the PlayerCharacterController; poses cannot play.", this);
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
// Resolution freeze: keep the graph playing (it re-asserts the frozen frame every render, even
|
||||
// under Animator culling) but zero the MIXER's speed so clip time stops advancing; skip everything
|
||||
// below so no locomotion/rest-reset moves a bone. The per-input clip speeds are untouched; the
|
||||
// mixer multiplier is restored on the first un-frozen frame just below.
|
||||
if (Frozen)
|
||||
{
|
||||
if (_graph.IsValid())
|
||||
{
|
||||
if (!_graph.IsPlaying()) _graph.Play();
|
||||
if (_mixer.IsValid()) PlayableExtensions.SetSpeed(_mixer, 0d);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (_mixer.IsValid() && PlayableExtensions.GetSpeed(_mixer) == 0d)
|
||||
PlayableExtensions.SetSpeed(_mixer, 1d);
|
||||
|
||||
// Local control only - a remote's pose index is pushed in via SetNetworkState.
|
||||
if (!_networkControlled)
|
||||
{
|
||||
if (InputUtil.GameplayKeyPressed(cycleKey))
|
||||
NextPose();
|
||||
|
||||
// Sprinting in any state (ground, air, wall, ceiling) snaps the selection back to the default
|
||||
// pose. Held continuously, so the reset shows up immediately, not only once back on the floor.
|
||||
if (_player.IsSprinting)
|
||||
_currentIndex = WrapIndex(defaultPoseIndex);
|
||||
}
|
||||
|
||||
PlayerPose pose = ActivePose();
|
||||
|
||||
// Resolve the locomotion category and its clip: locally from the controller's movement state, on
|
||||
// a remote the replicated value. ClipFor falls back to idle for unset optional clips, so a remote
|
||||
// (same pose asset) resolves to exactly the clip the owner shows.
|
||||
_currentLocomotion = _networkControlled ? _networkLoco : ResolveLocalLocomotion();
|
||||
AnimationClip clip = ClipFor(pose, _currentLocomotion, out float speed);
|
||||
PlayPose(clip, speed);
|
||||
|
||||
// Collision capsule and camera framing are local-owner concerns: a remote avatar's controller and
|
||||
// CharacterController are disabled and no camera tracks it, so a network-driven avatar stops here.
|
||||
if (_networkControlled)
|
||||
return;
|
||||
|
||||
// Per-pose collision capsule so a low pose can crawl where the standing capsule is too tall;
|
||||
// unset dimensions (<= 0) keep the default capsule.
|
||||
bool hasCapsule = pose && pose.ControllerHeight > 0f && pose.ControllerRadius > 0f;
|
||||
float capCenterY = hasCapsule ? pose.ControllerCenterY : _player.BaseCapsuleCenterY;
|
||||
float capRadius = hasCapsule ? pose.ControllerRadius : _player.BaseCapsuleRadius;
|
||||
float capHeight = hasCapsule ? pose.ControllerHeight : _player.BaseCapsuleHeight;
|
||||
ApplyCapsule(capCenterY, capRadius, capHeight);
|
||||
|
||||
// Camera target: a per-pose framing offset (applied in any state), eased so cycling poses glides
|
||||
// the camera, routed to the ThirdPersonCamera's follow target only (never the body).
|
||||
float cameraPoseTarget = pose ? pose.CameraTargetYOffset : 0f;
|
||||
_cameraPoseOffset = EaseCameraOffset(_cameraPoseOffset, cameraPoseTarget);
|
||||
_player.SetCameraTargetLocalYOffset(_cameraPoseOffset);
|
||||
|
||||
// Separate per-pose first-person eye offset - NOT routed to the shared camera target (that would
|
||||
// also move the ThirdPersonCamera); the FirstPersonController reads it directly.
|
||||
float firstPersonTarget = pose ? pose.FirstPersonCameraTargetYOffset : 0f;
|
||||
_firstPersonCameraOffset = EaseCameraOffset(_firstPersonCameraOffset, firstPersonTarget);
|
||||
}
|
||||
|
||||
/// <summary>Eases a camera y-offset toward its target at <see cref="cameraOffsetSpeed"/>; 0 snaps.</summary>
|
||||
private float EaseCameraOffset(float current, float target)
|
||||
{
|
||||
return cameraOffsetSpeed > 0f
|
||||
? Mathf.MoveTowards(current, target, cameraOffsetSpeed * Time.deltaTime)
|
||||
: target;
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
// Hand the rig back to the Animator Controller and never leak a graph; rebuilt lazily on the
|
||||
// next idle frame. Also clear the camera offset and restore the default capsule we were holding.
|
||||
ReleasePose();
|
||||
_cameraPoseOffset = 0f;
|
||||
_firstPersonCameraOffset = 0f;
|
||||
if (_player)
|
||||
{
|
||||
_player.SetCameraTargetLocalYOffset(0f);
|
||||
_player.SetCapsule(_player.BaseCapsuleCenterY, _player.BaseCapsuleRadius, _player.BaseCapsuleHeight);
|
||||
}
|
||||
_capsuleStarted = false; // re-snap to the active pose's capsule if this is re-enabled
|
||||
}
|
||||
|
||||
/// <summary>Advances to the next pose, wrapping at the end of the list.</summary>
|
||||
public void NextPose() => SetPoseIndex(_currentIndex + 1);
|
||||
|
||||
/// <summary>Selects a pose by index, wrapping into range so cycling never goes out of bounds.</summary>
|
||||
public void SetPoseIndex(int index) => _currentIndex = WrapIndex(index);
|
||||
|
||||
private int WrapIndex(int index)
|
||||
{
|
||||
int count = poses.Count;
|
||||
if (count == 0) return 0;
|
||||
return ((index % count) + count) % count;
|
||||
}
|
||||
|
||||
private PlayerPose ActivePose()
|
||||
{
|
||||
if (poses.Count == 0) return null;
|
||||
return poses[Mathf.Clamp(_currentIndex, 0, poses.Count - 1)];
|
||||
}
|
||||
|
||||
/// <summary>Maps the controller's current movement state to a locomotion category for clip selection.</summary>
|
||||
private Locomotion ResolveLocalLocomotion()
|
||||
{
|
||||
if (_player.IsMovingOnGround)
|
||||
return _player.IsSprinting ? Locomotion.Run : Locomotion.Walk;
|
||||
if (_player.IsFalling)
|
||||
return Locomotion.Fall;
|
||||
return Locomotion.Idle;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Returns the clip (and its <paramref name="speed"/>) for <paramref name="pose"/> in the given
|
||||
/// locomotion category. The walk/run/fall clips are optional: an unset one falls back to the pose's
|
||||
/// idle clip, so an unauthored category keeps the held-pose behaviour identically on the owner and on
|
||||
/// a network-driven remote avatar. A null pose hands the rig back (no clip).
|
||||
/// </summary>
|
||||
private AnimationClip ClipFor(PlayerPose pose, Locomotion loco, out float speed)
|
||||
{
|
||||
if (pose)
|
||||
{
|
||||
switch (loco)
|
||||
{
|
||||
case Locomotion.Walk when pose.WalkClip:
|
||||
speed = pose.WalkClipSpeed;
|
||||
return pose.WalkClip;
|
||||
case Locomotion.Run when pose.RunClip:
|
||||
speed = pose.RunClipSpeed;
|
||||
return pose.RunClip;
|
||||
case Locomotion.Fall when pose.FallClip:
|
||||
speed = pose.FallClipSpeed;
|
||||
return pose.FallClip;
|
||||
}
|
||||
speed = pose.ClipSpeed;
|
||||
return pose.Clip;
|
||||
}
|
||||
speed = 1f;
|
||||
return null;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Eases the CharacterController capsule toward the given target dimensions and writes them through
|
||||
/// the controller. A single normalized blend drives all three values together, so the capsule stays a
|
||||
/// valid shape (and its floor contact moves linearly) throughout the resize. The first call snaps, so
|
||||
/// the player spawns at its pose size without a resize blend; <see cref="capsuleBlendSpeed"/> 0 also snaps.
|
||||
/// </summary>
|
||||
private void ApplyCapsule(float centerY, float radius, float height)
|
||||
{
|
||||
if (!_capsuleStarted)
|
||||
{
|
||||
_capsuleStarted = true;
|
||||
_capFromCenterY = _capToCenterY = _capCenterY = centerY;
|
||||
_capFromRadius = _capToRadius = _capRadius = radius;
|
||||
_capFromHeight = _capToHeight = _capHeight = height;
|
||||
_capBlend = 1f;
|
||||
}
|
||||
else if (centerY != _capToCenterY || radius != _capToRadius || height != _capToHeight)
|
||||
{
|
||||
// New target (pose changed): ease from wherever the capsule currently is.
|
||||
_capFromCenterY = _capCenterY;
|
||||
_capFromRadius = _capRadius;
|
||||
_capFromHeight = _capHeight;
|
||||
_capToCenterY = centerY;
|
||||
_capToRadius = radius;
|
||||
_capToHeight = height;
|
||||
_capBlend = 0f;
|
||||
}
|
||||
|
||||
_capBlend = capsuleBlendSpeed > 0f
|
||||
? Mathf.MoveTowards(_capBlend, 1f, capsuleBlendSpeed * Time.deltaTime)
|
||||
: 1f;
|
||||
_capCenterY = Mathf.Lerp(_capFromCenterY, _capToCenterY, _capBlend);
|
||||
_capRadius = Mathf.Lerp(_capFromRadius, _capToRadius, _capBlend);
|
||||
_capHeight = Mathf.Lerp(_capFromHeight, _capToHeight, _capBlend);
|
||||
_player.SetCapsule(_capCenterY, _capRadius, _capHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Plays and holds the given clip, overriding the Animator Controller. Switching to a different
|
||||
/// clip crossfades from the current pose over <see cref="poseBlendDuration"/> (the first pose, or a
|
||||
/// zero duration, appears instantly). Called every frame: it starts a transition on a clip change,
|
||||
/// advances any in-flight blend, applies <paramref name="speed"/> to the target playback, and
|
||||
/// re-snaps un-keyed bones to rest before the graph evaluates. A null clip (or no animator) releases
|
||||
/// back to the controller.
|
||||
/// </summary>
|
||||
private void PlayPose(AnimationClip clip, float speed)
|
||||
{
|
||||
if (!clip || !_animator)
|
||||
{
|
||||
ReleasePose();
|
||||
return;
|
||||
}
|
||||
|
||||
EnsureGraph();
|
||||
|
||||
if (!(_posePlaying && _toClip == clip))
|
||||
BeginTransition(clip);
|
||||
|
||||
AdvanceBlend(Time.deltaTime);
|
||||
|
||||
// Set every frame so live speed-slider tuning takes effect immediately; the outgoing pose on
|
||||
// input 0 keeps the speed it carried, fading out at its own pace.
|
||||
if (_toPlayable.IsValid())
|
||||
PlayableExtensions.SetSpeed(_toPlayable, speed);
|
||||
|
||||
// Snap un-keyed bones to rest every frame so each mixer input blends relative to the same
|
||||
// baseline; the graph then overrides the keyed bones when it evaluates later this frame.
|
||||
ResetToRestPose();
|
||||
|
||||
if (!_graph.IsPlaying())
|
||||
_graph.Play();
|
||||
|
||||
_posePlaying = true;
|
||||
}
|
||||
|
||||
/// <summary>Builds the graph (a 2-input mixer feeding the Animator output) lazily if it isn't alive yet.</summary>
|
||||
private void EnsureGraph()
|
||||
{
|
||||
if (_graph.IsValid())
|
||||
return;
|
||||
_graph = PlayableGraph.Create(_graphName);
|
||||
_graph.SetTimeUpdateMode(DirectorUpdateMode.GameTime);
|
||||
_mixer = AnimationMixerPlayable.Create(_graph, 2);
|
||||
_output = AnimationPlayableOutput.Create(_graph, "Pose", _animator);
|
||||
_output.SetSourcePlayable(_mixer);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Starts a crossfade to <paramref name="clip"/>: the in-flight target becomes the outgoing pose on
|
||||
/// input 0 (carrying its playback time), the new clip goes on input 1, and the blend ramps up from
|
||||
/// 0. The first pose (or a zero duration) snaps in at full weight with no fade-from-nothing.
|
||||
/// </summary>
|
||||
private void BeginTransition(AnimationClip clip)
|
||||
{
|
||||
bool hadPose = _posePlaying && _toPlayable.IsValid();
|
||||
|
||||
// Drop the previous outgoing pose (already faded out, or none) before reusing input 0.
|
||||
if (_fromPlayable.IsValid())
|
||||
{
|
||||
_mixer.DisconnectInput(0);
|
||||
_graph.DestroyPlayable(_fromPlayable);
|
||||
_fromPlayable = default;
|
||||
}
|
||||
|
||||
if (hadPose)
|
||||
{
|
||||
// Move the current target from input 1 to input 0 so it becomes the outgoing pose without a
|
||||
// recreate (its animation time carries over, no jump).
|
||||
_mixer.DisconnectInput(1);
|
||||
_fromPlayable = _toPlayable;
|
||||
_mixer.ConnectInput(0, _fromPlayable, 0);
|
||||
}
|
||||
|
||||
_toPlayable = AnimationClipPlayable.Create(_graph, clip);
|
||||
// Off by default: foot IK plants the foot on its goal and bends the knee to reach it, so a straight-knee
|
||||
// pose renders bent (SampleAnimation, which the authoring tools check against, never applies it). The
|
||||
// outgoing pose on input 0 is a former _toPlayable, so it already carries this setting.
|
||||
_toPlayable.SetApplyFootIK(applyFootIK);
|
||||
_mixer.ConnectInput(1, _toPlayable, 0);
|
||||
_toClip = clip;
|
||||
|
||||
_blend = (hadPose && poseBlendDuration > 0f) ? 0f : 1f;
|
||||
_blending = _blend < 1f;
|
||||
ApplyBlendWeights();
|
||||
}
|
||||
|
||||
/// <summary>Advances an in-flight crossfade and drops the outgoing pose once it has fully faded.</summary>
|
||||
private void AdvanceBlend(float dt)
|
||||
{
|
||||
if (!_blending)
|
||||
return;
|
||||
_blend = poseBlendDuration > 0f ? Mathf.MoveTowards(_blend, 1f, dt / poseBlendDuration) : 1f;
|
||||
if (_blend >= 1f)
|
||||
{
|
||||
_blend = 1f;
|
||||
_blending = false;
|
||||
if (_fromPlayable.IsValid())
|
||||
{
|
||||
_mixer.DisconnectInput(0);
|
||||
_graph.DestroyPlayable(_fromPlayable);
|
||||
_fromPlayable = default;
|
||||
}
|
||||
}
|
||||
ApplyBlendWeights();
|
||||
}
|
||||
|
||||
/// <summary>Pushes the current blend onto the mixer inputs (smoothstepped while a "from" pose exists).</summary>
|
||||
private void ApplyBlendWeights()
|
||||
{
|
||||
if (!_mixer.IsValid())
|
||||
return;
|
||||
bool hasFrom = _fromPlayable.IsValid();
|
||||
float w = hasFrom ? Mathf.SmoothStep(0f, 1f, _blend) : 1f;
|
||||
_mixer.SetInputWeight(0, hasFrom ? 1f - w : 0f);
|
||||
_mixer.SetInputWeight(1, _toPlayable.IsValid() ? w : 0f);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots every transform a pose clip can address (the Animator's whole subtree) in its
|
||||
/// authored rest pose. Taken in Awake before the Animator first evaluates, so it captures the rig
|
||||
/// at rest (the paintman rig's rest pose is its T-pose).
|
||||
/// </summary>
|
||||
private void CaptureRestPose()
|
||||
{
|
||||
_restBones = _animator.transform.GetComponentsInChildren<Transform>(true);
|
||||
int n = _restBones.Length;
|
||||
_restPos = new Vector3[n];
|
||||
_restRot = new Quaternion[n];
|
||||
_restScale = new Vector3[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
Transform t = _restBones[i];
|
||||
_restPos[i] = t.localPosition;
|
||||
_restRot[i] = t.localRotation;
|
||||
_restScale[i] = t.localScale;
|
||||
}
|
||||
|
||||
// Drop the model-offset transform from the reset set: the wall-lean / ground raise (SetMeshLean)
|
||||
// is its sole writer, and with the humanoid rig the Animator shares this node - resetting it each
|
||||
// frame was exactly what wiped the offsets. Nulling the slot makes ResetToRestPose skip it.
|
||||
Transform offsetTarget = _player ? _player.MeshOffsetTarget : null;
|
||||
if (offsetTarget)
|
||||
{
|
||||
int idx = System.Array.IndexOf(_restBones, offsetTarget);
|
||||
if (idx >= 0)
|
||||
_restBones[idx] = null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restores the captured rest pose so an empty or partial pose clip fully defines the body.</summary>
|
||||
private void ResetToRestPose()
|
||||
{
|
||||
if (_restBones == null)
|
||||
return;
|
||||
for (int i = 0; i < _restBones.Length; i++)
|
||||
{
|
||||
Transform t = _restBones[i];
|
||||
if (!t)
|
||||
continue;
|
||||
t.localPosition = _restPos[i];
|
||||
t.localRotation = _restRot[i];
|
||||
t.localScale = _restScale[i];
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Destroys the graph so the Animator Controller (run/idle) drives the rig again.</summary>
|
||||
private void ReleasePose()
|
||||
{
|
||||
if (!_posePlaying)
|
||||
return;
|
||||
if (_graph.IsValid())
|
||||
_graph.Destroy();
|
||||
_graph = default;
|
||||
_output = default;
|
||||
_mixer = default;
|
||||
_fromPlayable = default;
|
||||
_toPlayable = default;
|
||||
_toClip = null;
|
||||
_blend = 0f;
|
||||
_blending = false;
|
||||
_posePlaying = false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 324ce9a23e6a5144096a195460dd567f
|
||||
@@ -0,0 +1,346 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
/// <summary>
|
||||
/// Feeds mouse input into a <see cref="CinemachineOrbitalFollow"/> so the camera
|
||||
/// orbits the follow target. Mouse movement drives yaw/pitch, the wheel zooms.
|
||||
/// The orbit behaviour depends on <see cref="LookMode"/> (usually set by the
|
||||
/// GameModeManager). Attach to the camera rig (the CinemachineOrbitalFollow).
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(CinemachineOrbitalFollow))]
|
||||
[DisallowMultipleComponent]
|
||||
public class ThirdPersonOrbitInput : MonoBehaviour
|
||||
{
|
||||
public enum OrbitLookMode
|
||||
{
|
||||
/// <summary>Rotate with every mouse move; cursor locked/hidden.</summary>
|
||||
AlwaysActive,
|
||||
/// <summary>Rotate only while the right mouse button is held; cursor free otherwise.</summary>
|
||||
HoldRightMouseButton,
|
||||
/// <summary>No rotation; cursor free.</summary>
|
||||
Disabled
|
||||
}
|
||||
|
||||
[Header("Look Mode")]
|
||||
[Tooltip("How the camera orbit is driven (usually set by GameModeManager).")]
|
||||
[SerializeField] private OrbitLookMode lookMode = OrbitLookMode.AlwaysActive;
|
||||
|
||||
[Header("Rotation")]
|
||||
[Tooltip("Horizontal look sensitivity (degrees of yaw per pixel).")]
|
||||
public float horizontalSensitivity = 0.2f;
|
||||
|
||||
[Tooltip("Vertical look sensitivity (degrees of pitch per pixel).")]
|
||||
public float verticalSensitivity = 0.15f;
|
||||
|
||||
[Tooltip("Invert vertical look (mouse up tilts the camera down).")]
|
||||
public bool invertY = false;
|
||||
|
||||
[Tooltip("Eases orbit rotation so quick mouse moves ramp in/out (higher = snappier, 0 = raw/instant).")]
|
||||
public float rotationSmoothing = 20f;
|
||||
|
||||
[Header("Zoom")]
|
||||
[Tooltip("Mouse wheel zooms the camera in and out (changes orbit radius).")]
|
||||
public bool enableZoom = true;
|
||||
|
||||
[Tooltip("Scale the zoom range, step and default radius by the follow target's size (PlayerScaler), " +
|
||||
"so a small (Hider) or large (Hunter) player is framed at the same body-relative distance. " +
|
||||
"The radius fields below are then size-1 design values, multiplied by the target's scale at " +
|
||||
"runtime. Off = absolute world units regardless of player size.")]
|
||||
public bool scaleWithTarget = true;
|
||||
|
||||
[Tooltip("World units the orbit radius changes per mouse wheel notch (size-1 design value; scaled by " +
|
||||
"the target when Scale With Target is on).")]
|
||||
public float zoomSpeed = 0.5f;
|
||||
|
||||
[Tooltip("Eases the orbit radius toward the zoom target (higher = snappier, 0 = instant).")]
|
||||
public float zoomSmoothing = 10f;
|
||||
|
||||
[Tooltip("Closest orbit radius / zoom in (size-1 design value; scaled by the target).")]
|
||||
public float minRadius = 1.5f;
|
||||
|
||||
[Tooltip("Farthest orbit radius / zoom out (size-1 design value; scaled by the target).")]
|
||||
public float maxRadius = 8f;
|
||||
|
||||
[Tooltip("Orbit radius applied when the camera (re)binds to a player - the starting zoom level " +
|
||||
"(size-1 design value; scaled by the target). Set so the bound player is comfortably framed.")]
|
||||
public float defaultRadius = 4.5f;
|
||||
|
||||
[Header("Pan (middle mouse)")]
|
||||
[Tooltip("Hold the middle mouse button and drag to pan the camera framing - a temporary offset to bring an " +
|
||||
"awkward spot of the model in front of the lens for painting. The pan stays put on release and only " +
|
||||
"glides back to center when paint mode is left. Enabled only while PaintMode is active.")]
|
||||
public bool enablePan = true;
|
||||
|
||||
[Tooltip("World units the pan offset moves per pixel of mouse drag, measured at the default zoom. It then " +
|
||||
"scales with the live zoom distance so the model slides the same amount on screen per pixel at any " +
|
||||
"zoom (closer in = finer). Body scale is folded in automatically via the orbit radius.")]
|
||||
public float panSensitivity = 0.004f;
|
||||
|
||||
[Tooltip("Farthest the framing can pan from center, in world units (body scale folded in). This is NOT scaled " +
|
||||
"by zoom, so the maximum pan distance stays the same at every zoom level - only the sensitivity changes.")]
|
||||
public float panMaxDistance = 1f;
|
||||
|
||||
[Tooltip("How snappily the pan glides back to center when paint mode is left (1/second; 0 = snaps home " +
|
||||
"instantly). The pan stays put on middle-button release - it only returns on leaving paint mode.")]
|
||||
public float panReturnSpeed = 14f;
|
||||
|
||||
[Header("Cursor")]
|
||||
[Tooltip("Lock and hide the cursor while the camera is actively rotating.")]
|
||||
public bool manageCursor = true;
|
||||
|
||||
CinemachineOrbitalFollow _orbit;
|
||||
Vector2 _smoothedLook; // low-pass-filtered look delta (deg/frame) for eased rotation
|
||||
float _targetRadius; // zoom radius the actual radius eases toward
|
||||
bool _zoomInit; // _targetRadius seeded from the live radius yet?
|
||||
Vector2 _pan; // accumulated pan in camera-space units (x=right, y=up); stays put on release, eases to 0 on leaving paint mode
|
||||
CinemachineCameraOffset _camOffset; // post-aim extension that slides the whole framing (true pan, not an orbit arc)
|
||||
|
||||
/// <summary>Current orbit mode. Setting it also refreshes the cursor state.</summary>
|
||||
public OrbitLookMode LookMode
|
||||
{
|
||||
get => lookMode;
|
||||
set { lookMode = value; ApplyCursorState(); }
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// While true, mouse-look is paused (orbit frozen) without changing the cursor
|
||||
/// state. The player controller sets this while dragging the wall-climb offset so
|
||||
/// the same mouse motion is not also spent orbiting the camera.
|
||||
/// </summary>
|
||||
public bool LookSuppressed { get; set; }
|
||||
|
||||
/// <summary>While true, holding the middle mouse button and dragging pans the camera framing - a temporary
|
||||
/// look-point offset that stays put on release. Set by PaintMode so the pan is only available while painting;
|
||||
/// clearing it (on leaving paint mode) is what glides the framing back home.</summary>
|
||||
public bool PanEnabled { get; set; }
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_orbit = GetComponent<CinemachineOrbitalFollow>();
|
||||
if (!_orbit)
|
||||
Debug.LogError("[ThirdPersonOrbitInput] No CinemachineOrbitalFollow found on this GameObject.", this);
|
||||
|
||||
// The pan rides a CinemachineCameraOffset extension applied AFTER aim with PreserveComposition off: it
|
||||
// shifts the camera position AND the look-at point by the same world vector, so the framing translates
|
||||
// (the model slides across the screen) instead of re-aiming back to center like a body TargetOffset
|
||||
// would. Offset is camera-local (x=right, y=up). Added at runtime so no scene/prefab authoring is needed.
|
||||
if (enablePan)
|
||||
{
|
||||
_camOffset = GetComponent<CinemachineCameraOffset>();
|
||||
if (!_camOffset) _camOffset = gameObject.AddComponent<CinemachineCameraOffset>();
|
||||
_camOffset.ApplyAfter = CinemachineCore.Stage.Aim;
|
||||
_camOffset.PreserveComposition = false;
|
||||
_camOffset.Offset = Vector3.zero;
|
||||
}
|
||||
}
|
||||
|
||||
void OnEnable() => ApplyCursorState();
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
InputUtil.SetCursorLocked(false);
|
||||
// Deliberately DON'T zero the pan: only FreeCamMode disables this component, and the freecam
|
||||
// snaps to the live (still panned) view - resetting now would jump the blend-FROM camera to
|
||||
// center while the freecam sits at the panned pose (the visible snap on the paint->freecam
|
||||
// switch). Frozen offset = blend-from == blend-to; the pan glides home once the orbit runs again.
|
||||
}
|
||||
|
||||
/// <summary>Uniform scale of the current follow target (1 when none, or when Scale With Target is off).
|
||||
/// The zoom range, step and default radius are multiplied by this so the framing is body-relative across
|
||||
/// player sizes. PlayerScaler keeps the player uniformly scaled, so a single lossyScale component is exact.</summary>
|
||||
float CurrentScale()
|
||||
{
|
||||
if (!scaleWithTarget || !_orbit) return 1f;
|
||||
Transform t = _orbit.FollowTarget;
|
||||
return t ? Mathf.Max(0.0001f, t.lossyScale.x) : 1f;
|
||||
}
|
||||
|
||||
/// <summary>Pan-SENSITIVITY scale with the live zoom: radius / defaultRadius = 1 at the default zoom,
|
||||
/// finer zoomed in, coarser zoomed out - the model slides the same amount ON SCREEN per pixel at any
|
||||
/// zoom. Body scale cancels out (the radius already embeds it). The clamp deliberately does NOT use
|
||||
/// this: panMaxDistance stays body-relative only, constant across zoom levels.</summary>
|
||||
float PanZoomScale() => _orbit ? _orbit.Radius / Mathf.Max(0.0001f, defaultRadius) : 1f;
|
||||
|
||||
/// <summary>Snaps the zoom to the scaled default radius. Called when a new avatar binds (see
|
||||
/// GameModeManager.BindLocalPlayer) so each player starts at its body-relative default zoom instead of
|
||||
/// inheriting the previous avatar's radius.</summary>
|
||||
public void ResetZoom()
|
||||
{
|
||||
if (!_orbit) return;
|
||||
_targetRadius = defaultRadius * CurrentScale();
|
||||
_orbit.Radius = _targetRadius;
|
||||
_zoomInit = true;
|
||||
}
|
||||
|
||||
/// <summary>Snaps the orbit axes to Camera.main's current view direction so the next orbit mode keeps
|
||||
/// that facing instead of the orbit's last-used angle. Called when switching out of a non-orbit rig:
|
||||
/// Camera.main still shows that rig's view this frame (the brain hasn't blended yet), so the read is
|
||||
/// exact. With WorldSpace binding + Sphere orbit + a centered composer the look rotation is exactly
|
||||
/// Euler(VerticalAxis, HorizontalAxis, 0).</summary>
|
||||
public void SyncToView()
|
||||
{
|
||||
if (!_orbit) return;
|
||||
var cam = Camera.main;
|
||||
if (!cam) return;
|
||||
|
||||
Vector3 e = cam.transform.rotation.eulerAngles;
|
||||
SetAxisValue(ref _orbit.HorizontalAxis, e.y > 180f ? e.y - 360f : e.y);
|
||||
SetAxisValue(ref _orbit.VerticalAxis, e.x > 180f ? e.x - 360f : e.x);
|
||||
|
||||
// Drop any eased look carry-over so the snapped angle isn't immediately dragged back.
|
||||
_smoothedLook = Vector2.zero;
|
||||
}
|
||||
|
||||
void Update()
|
||||
{
|
||||
if (!_orbit)
|
||||
return;
|
||||
|
||||
bool modeWantsLook = ShouldRotate();
|
||||
|
||||
if (manageCursor)
|
||||
InputUtil.SetCursorLocked(modeWantsLook);
|
||||
|
||||
if (modeWantsLook && !LookSuppressed)
|
||||
{
|
||||
Vector2 delta = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
|
||||
Vector2 raw = new Vector2(
|
||||
delta.x * horizontalSensitivity,
|
||||
delta.y * verticalSensitivity * (invertY ? 1f : -1f));
|
||||
|
||||
// Ease the per-frame look toward the raw delta so fast flicks ramp in/out
|
||||
// instead of snapping; steady motion still converges to full sensitivity.
|
||||
Vector2 applied = rotationSmoothing > 0f
|
||||
? (_smoothedLook = Vector2.Lerp(_smoothedLook, raw, 1f - Mathf.Exp(-rotationSmoothing * Time.deltaTime)))
|
||||
: raw;
|
||||
|
||||
if (applied.sqrMagnitude > 0f)
|
||||
{
|
||||
ApplyAxisDelta(ref _orbit.HorizontalAxis, applied.x);
|
||||
ApplyAxisDelta(ref _orbit.VerticalAxis, applied.y);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
_smoothedLook = Vector2.zero;
|
||||
}
|
||||
|
||||
if (enableZoom)
|
||||
{
|
||||
if (!_zoomInit)
|
||||
{
|
||||
// Seed the starting zoom at the scaled default (not the live radius) so a fresh bind frames
|
||||
// the player body-relative; ResetZoom() does the same on an explicit (re)bind.
|
||||
_targetRadius = defaultRadius * CurrentScale();
|
||||
_orbit.Radius = _targetRadius;
|
||||
_zoomInit = true;
|
||||
}
|
||||
|
||||
float scale = CurrentScale();
|
||||
|
||||
// Shift/Ctrl+wheel is reserved for the paint brush resize (MeshPainter), so the orbit camera
|
||||
// ignores the wheel while either is held - otherwise it would zoom and resize at once.
|
||||
float scroll = (InputUtil.ShiftHeld() || InputUtil.CtrlHeld()) ? 0f : InputUtil.ReadScroll();
|
||||
if (!Mathf.Approximately(scroll, 0f))
|
||||
{
|
||||
// One wheel notch = one zoom step (sign only, platform-independent). The step scales with
|
||||
// the player so the notch count to cross the range is the same at every size.
|
||||
_targetRadius -= Mathf.Sign(scroll) * zoomSpeed * scale;
|
||||
}
|
||||
|
||||
// Clamp every frame against the scaled range so a runtime size change (PlayerScaler grow/shrink)
|
||||
// keeps the target in-range instead of sticking at a stale absolute bound.
|
||||
_targetRadius = Mathf.Clamp(_targetRadius, minRadius * scale, maxRadius * scale);
|
||||
|
||||
// Ease the actual radius toward the target (frame-rate independent).
|
||||
_orbit.Radius = zoomSmoothing > 0f
|
||||
? Mathf.Lerp(_orbit.Radius, _targetRadius, 1f - Mathf.Exp(-zoomSmoothing * Time.deltaTime))
|
||||
: _targetRadius;
|
||||
}
|
||||
|
||||
UpdatePan(Time.deltaTime);
|
||||
}
|
||||
|
||||
bool ShouldRotate()
|
||||
{
|
||||
switch (lookMode)
|
||||
{
|
||||
case OrbitLookMode.AlwaysActive: return true;
|
||||
case OrbitLookMode.HoldRightMouseButton: return InputUtil.RightMouseHeld();
|
||||
default: return false;
|
||||
}
|
||||
}
|
||||
|
||||
void ApplyCursorState()
|
||||
{
|
||||
if (manageCursor)
|
||||
InputUtil.SetCursorLocked(ShouldRotate());
|
||||
}
|
||||
|
||||
/// <summary>Adds to an input axis; wrapping axes wrap, others clamp to their range.</summary>
|
||||
static void ApplyAxisDelta(ref InputAxis axis, float amount)
|
||||
=> SetAxisValue(ref axis, axis.Value + amount);
|
||||
|
||||
/// <summary>Sets an input axis to an absolute value; wrapping axes wrap, others clamp to their range.</summary>
|
||||
static void SetAxisValue(ref InputAxis axis, float value)
|
||||
{
|
||||
float min = axis.Range.x;
|
||||
float max = axis.Range.y;
|
||||
|
||||
if (axis.Wrap && max > min)
|
||||
{
|
||||
float span = max - min;
|
||||
value = ((value - min) % span + span) % span + min;
|
||||
}
|
||||
else
|
||||
{
|
||||
value = Mathf.Clamp(value, min, max);
|
||||
}
|
||||
|
||||
axis.Value = value;
|
||||
}
|
||||
|
||||
/// <summary>Middle-mouse pan (while PaintMode enables it): slides the whole framing along the screen
|
||||
/// right/up axes via the camera-offset extension - a temporary offset to line up an awkward spot of
|
||||
/// the model for painting. Eases home outside paint mode / while the orbit is Disabled.</summary>
|
||||
void UpdatePan(float dt)
|
||||
{
|
||||
if (!enablePan || !_camOffset)
|
||||
return;
|
||||
|
||||
bool active = PanEnabled && lookMode != OrbitLookMode.Disabled;
|
||||
float zoom = PanZoomScale();
|
||||
|
||||
if (active && InputUtil.MiddleMouseHeld())
|
||||
{
|
||||
Vector2 d = InputUtil.ReadMouseDelta() * MouseSensitivity.Multiplier;
|
||||
// Subtracting gives a hand-tool feel (the model follows the cursor). The step scales with the
|
||||
// zoom (same on-screen feel, see PanZoomScale); the clamp stays body-relative only.
|
||||
_pan -= d * (panSensitivity * zoom);
|
||||
_pan = Vector2.ClampMagnitude(_pan, panMaxDistance * CurrentScale());
|
||||
}
|
||||
else if (active)
|
||||
{
|
||||
return; // still painting, middle button released - hold the pan where it was left
|
||||
}
|
||||
else if (_pan != Vector2.zero)
|
||||
{
|
||||
// Left paint mode (or look disabled) - glide the framing back to center.
|
||||
if (panReturnSpeed > 0f)
|
||||
_pan = Vector2.Lerp(_pan, Vector2.zero, 1f - Mathf.Exp(-panReturnSpeed * dt));
|
||||
else
|
||||
_pan = Vector2.zero;
|
||||
// Snap the last sliver so the offset settles exactly on center and this branch stops running.
|
||||
if (_pan.sqrMagnitude < 1e-8f)
|
||||
_pan = Vector2.zero;
|
||||
}
|
||||
else
|
||||
{
|
||||
return; // at rest and not panning - nothing to write
|
||||
}
|
||||
|
||||
// Offset is camera-local (x=right, y=up); the extension translates eye + look-at together -> true pan.
|
||||
_camOffset.Offset = new Vector3(_pan.x, _pan.y, 0f);
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: df129be8fadab79498bd429a59bb98e4
|
||||
@@ -0,0 +1,66 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Caps the frame rate via <see cref="Application.targetFrameRate"/>. VSync is
|
||||
/// disabled automatically while limiting, because it would otherwise override
|
||||
/// targetFrameRate. Changes apply live (also in Play mode) via OnValidate.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class FrameRateLimiter : MonoBehaviour
|
||||
{
|
||||
[Header("Limit")]
|
||||
[Tooltip("Cap the frame rate to 'Target FPS' (off = unlimited).")]
|
||||
public bool limitFrameRate = true;
|
||||
|
||||
[Tooltip("Target frame rate when the limit is active.")]
|
||||
[Range(1, 360)]
|
||||
public int targetFps = 60;
|
||||
|
||||
[Header("VSync")]
|
||||
[Tooltip("Disable VSync while limiting (VSync would otherwise override the FPS limit). Normally ON.")]
|
||||
public bool disableVSyncWhenLimiting = true;
|
||||
|
||||
[Tooltip("VSync count when no limit is active (0 = off, 1 = every refresh, 2 = every second refresh).")]
|
||||
[Range(0, 2)]
|
||||
public int vSyncCountWhenUnlimited = 0;
|
||||
|
||||
void OnEnable() => Apply();
|
||||
|
||||
void OnValidate()
|
||||
{
|
||||
if (Application.isPlaying)
|
||||
Apply();
|
||||
}
|
||||
|
||||
/// <summary>Applies the current FPS + VSync settings.</summary>
|
||||
public void Apply()
|
||||
{
|
||||
if (limitFrameRate)
|
||||
{
|
||||
if (disableVSyncWhenLimiting)
|
||||
QualitySettings.vSyncCount = 0;
|
||||
|
||||
Application.targetFrameRate = Mathf.Max(1, targetFps);
|
||||
}
|
||||
else
|
||||
{
|
||||
QualitySettings.vSyncCount = vSyncCountWhenUnlimited;
|
||||
Application.targetFrameRate = -1;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Enable/disable the limit (e.g. for UI buttons).</summary>
|
||||
public void SetLimitEnabled(bool enabled)
|
||||
{
|
||||
limitFrameRate = enabled;
|
||||
Apply();
|
||||
}
|
||||
|
||||
/// <summary>Set the target FPS and enable the limit (e.g. for UI sliders).</summary>
|
||||
public void SetTargetFps(int fps)
|
||||
{
|
||||
targetFps = Mathf.Max(1, fps);
|
||||
limitFrameRate = true;
|
||||
Apply();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dd08e354312b3b14d8f2573ae830c513
|
||||
@@ -0,0 +1,241 @@
|
||||
using UnityEngine;
|
||||
using Unity.Cinemachine;
|
||||
|
||||
/// <summary>
|
||||
/// Drives the world-space "hero cutout": occluder geometry inside a cone from the third-person camera
|
||||
/// (apex) to the player is clipped away, punching a see-through funnel toward the character. Each late
|
||||
/// frame this feeds two global shader properties (<c>_HeroCutoutTarget</c>, <c>_HeroCutoutParams</c>)
|
||||
/// that <c>Custom/HeroCutoutLit</c> reads.
|
||||
///
|
||||
/// Arming is deliberately simple: ONE ray from the camera toward the player - blocked = armed, clear
|
||||
/// view = occluders stay solid. The cut volume lives entirely in the shader: everything between camera
|
||||
/// and player (within the cone) is cut, geometry behind the player stays solid, and the far end fades
|
||||
/// across a soft depth band (<see cref="depthFeather"/>) instead of a hard plane. The cone radius grows
|
||||
/// with camera distance, so the hole keeps a constant on-screen size at every occluder depth.
|
||||
///
|
||||
/// Active only while the third-person orbit vcam is the live Cinemachine camera; disarming clears the
|
||||
/// global flag (also the zero-initialised default), so scenes without this driver render cutout
|
||||
/// materials as plain URP/Lit. Replaces the Cinemachine Deoccluder pull-in: the camera holds its orbit
|
||||
/// distance and the obstacle goes see-through. Attach to a persistent scene object.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public class HeroCutout : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Orbit camera input whose CinemachineCamera must be live for the cutout to arm. Empty = found automatically.")]
|
||||
public ThirdPersonOrbitInput orbitInput;
|
||||
|
||||
[Tooltip("Game mode manager, used as a fallback source for the live player. Empty = found automatically.")]
|
||||
public GameModeManager modes;
|
||||
|
||||
[Tooltip("Switch off the orbit camera's Cinemachine Deoccluder obstacle avoidance at runtime so the " +
|
||||
"camera holds its orbit distance and this cutout handles occlusion - instead of the camera " +
|
||||
"pulling in toward the player. The scene's Deoccluder asset is left untouched; only its live " +
|
||||
"pull-in is disabled (and restored if this driver is disabled).")]
|
||||
public bool replaceDeoccluderPullIn = true;
|
||||
|
||||
[Header("Cutout cone")]
|
||||
[Tooltip("Radius of the see-through cone AT THE PLAYER, in metres, AT PLAYER SCALE 1. The live radius is " +
|
||||
"multiplied by the hero target's uniform world scale (PlayerScaler) so the hole keeps the same " +
|
||||
"on-screen size for a small hider or a large hunter. The cone also tapers to nothing at the " +
|
||||
"camera, so the hole keeps a constant on-screen size at every occluder depth.")]
|
||||
[Range(0.05f, 3f)] public float radius = 0.5f;
|
||||
|
||||
[Tooltip("Soft-edge width of the cone wall at the player, in metres, AT PLAYER SCALE 1 (also tapers " +
|
||||
"toward the camera). Like the radius, the live value is multiplied by the hero target's uniform " +
|
||||
"world scale (PlayerScaler) so the soft edge keeps the same on-screen proportion at any size.")]
|
||||
[Range(0f, 1f)] public float feather = 0.15f;
|
||||
|
||||
[Tooltip("Soft depth band (m) straddling the player: instead of stopping at a hard plane at the " +
|
||||
"player's depth, the cut fades out across +/- this distance around the player, so an occluder " +
|
||||
"running past the player (a shelf board) fades just behind them too instead of showing a hard " +
|
||||
"straight cut line. Geometry beyond this band stays fully solid.")]
|
||||
[Range(0.01f, 2f)] public float depthFeather = 0.5f;
|
||||
|
||||
[Tooltip("Vertical offset (m) from the follow target the cone aims at (chest/head height).")]
|
||||
public float heightOffset = 0f;
|
||||
|
||||
[Header("Occlusion ray (camera -> player)")]
|
||||
[Tooltip("Layers that count as occluders. Leave empty (Nothing) to fall back to all default raycast layers.")]
|
||||
public LayerMask occluderMask = ~0;
|
||||
|
||||
[Tooltip("Collider tag ignored by the ray (the player's own colliders). Must be an existing tag; " +
|
||||
"empty = ignore nothing.")]
|
||||
public string ignoreTag = "Player";
|
||||
|
||||
[Tooltip("Metres the ray stops short of the player. Keep at 0 so a wall the player stands right " +
|
||||
"behind still arms the cut (everything between camera and player counts). The ray already " +
|
||||
"ends AT the player, so geometry behind them is never hit regardless. Raise this only if a " +
|
||||
"low camera grazes the floor at the player's feet (better fixed via Height Offset).")]
|
||||
[Range(0f, 1.5f)] public float minDistanceFromTarget = 0f;
|
||||
|
||||
[Tooltip("Seconds the cutout stays armed after the view clears again, to stop flicker at occluder edges.")]
|
||||
public float occlusionHold = 0.1f;
|
||||
|
||||
static readonly int TargetId = Shader.PropertyToID("_HeroCutoutTarget");
|
||||
static readonly int ParamsId = Shader.PropertyToID("_HeroCutoutParams");
|
||||
|
||||
private CinemachineCamera _orbitCam;
|
||||
private CinemachineBrain _brain;
|
||||
private CinemachineDeoccluder _deoccluder;
|
||||
private bool _deoccluderResolved;
|
||||
private bool _deoccluderOriginalAvoid;
|
||||
private bool _pullInSuppressed;
|
||||
private float _lastOccludedTime = -999f;
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (!orbitInput) orbitInput = FindFirstObjectByType<ThirdPersonOrbitInput>(FindObjectsInactive.Include);
|
||||
if (!modes) modes = FindFirstObjectByType<GameModeManager>();
|
||||
EnsureRefs();
|
||||
|
||||
// CompareTag throws on an undefined tag - validate once so the per-frame ray never does.
|
||||
if (!string.IsNullOrEmpty(ignoreTag))
|
||||
{
|
||||
try { _ = gameObject.CompareTag(ignoreTag); }
|
||||
catch
|
||||
{
|
||||
Debug.LogWarning($"[HeroCutout] Ignore Tag '{ignoreTag}' is not defined; the ray will ignore nothing.", this);
|
||||
ignoreTag = string.Empty;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
Disarm();
|
||||
RestoreDeoccluderPullIn();
|
||||
}
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
EnsureRefs();
|
||||
ApplyPullInSuppression();
|
||||
|
||||
Camera cam = Camera.main;
|
||||
if (!cam) { Disarm(); return; }
|
||||
|
||||
if (!_brain || _brain.gameObject != cam.gameObject)
|
||||
_brain = cam.GetComponent<CinemachineBrain>();
|
||||
|
||||
if (!IsThirdPersonLive()) { Disarm(); return; }
|
||||
|
||||
Transform target = ResolveHeroTarget();
|
||||
if (!target) { Disarm(); return; }
|
||||
|
||||
Vector3 heroPos = target.position + Vector3.up * heightOffset;
|
||||
Vector3 camPos = cam.transform.position;
|
||||
|
||||
// Body-relative cone: the authored radius is the scale-1 size, scaled by the hero's uniform world
|
||||
// size so the hole stays the same on screen for a small hider or a large hunter. lossyScale captures
|
||||
// the full hierarchy (PlayerScaler drives localScale); reading it live tracks a mid-match conversion.
|
||||
float bodyScale = Mathf.Max(1e-4f, target.lossyScale.x);
|
||||
|
||||
// Only punch the tube when something actually stands between the player and the camera; with a
|
||||
// clear view the occluders stay solid. A short hold after the last blocked frame avoids flicker
|
||||
// when the single ray grazes an occluder edge.
|
||||
if (IsViewBlocked(camPos, heroPos)) _lastOccludedTime = Time.time;
|
||||
if (Time.time - _lastOccludedTime > Mathf.Max(0f, occlusionHold)) { Disarm(); return; }
|
||||
|
||||
// Same hero point feeds the ray and the cone axis, so the gate and the cut volume agree.
|
||||
_armed = true;
|
||||
Shader.SetGlobalVector(TargetId, new Vector4(heroPos.x, heroPos.y, heroPos.z, 1f));
|
||||
Shader.SetGlobalVector(ParamsId, new Vector4(Mathf.Max(0f, radius * bodyScale), Mathf.Max(1e-4f, feather * bodyScale), Mathf.Max(1e-4f, depthFeather), 0f));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Single ray, camera -> player. Returns true when a non-ignored collider blocks the view before
|
||||
/// the ray reaches the player (stopped short by <see cref="minDistanceFromTarget"/> so the wall the
|
||||
/// player stands against / the floor at their feet never counts). Anything "between" the camera and
|
||||
/// the player is necessarily the first thing the ray hits, so checking the first hit is enough.
|
||||
/// </summary>
|
||||
private bool IsViewBlocked(Vector3 camPos, Vector3 heroPos)
|
||||
{
|
||||
Vector3 delta = heroPos - camPos;
|
||||
float dist = delta.magnitude;
|
||||
if (dist <= 1e-3f) return false;
|
||||
Vector3 dir = delta / dist;
|
||||
|
||||
float maxDist = dist - Mathf.Clamp(minDistanceFromTarget, 0f, dist);
|
||||
if (maxDist <= 1e-3f) return false;
|
||||
|
||||
int mask = occluderMask.value != 0 ? occluderMask.value : Physics.DefaultRaycastLayers;
|
||||
|
||||
if (!Physics.Raycast(camPos, dir, out RaycastHit hit, maxDist, mask, QueryTriggerInteraction.Ignore))
|
||||
return false;
|
||||
|
||||
// First (closest) hit decides: if it's the ignored player, nothing real is in between.
|
||||
return string.IsNullOrEmpty(ignoreTag) || !hit.collider.CompareTag(ignoreTag);
|
||||
}
|
||||
|
||||
/// <summary>True only when the orbit vcam is the live Cinemachine camera (third-person modes).</summary>
|
||||
private bool IsThirdPersonLive()
|
||||
{
|
||||
if (!_orbitCam || !_brain) return false;
|
||||
var live = _brain.ActiveVirtualCamera as CinemachineCamera;
|
||||
return live && live == _orbitCam;
|
||||
}
|
||||
|
||||
private Transform ResolveHeroTarget()
|
||||
{
|
||||
if (_orbitCam && _orbitCam.Follow) return _orbitCam.Follow;
|
||||
if (modes && modes.Player) return modes.Player.transform;
|
||||
return null;
|
||||
}
|
||||
|
||||
private bool _armed; // change-guard: Disarm runs every non-armed frame, so it only writes the transition
|
||||
|
||||
/// <summary>Clears the "enabled" flag so cutout materials render as plain URP/Lit.</summary>
|
||||
private void Disarm()
|
||||
{
|
||||
if (!_armed) return;
|
||||
_armed = false;
|
||||
Shader.SetGlobalVector(TargetId, Vector4.zero);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Lazily resolves the orbit vcam and its Cinemachine Deoccluder (they may live in a scene that
|
||||
/// loads after this driver). Captures the deoccluder's authored obstacle-avoidance state once so it
|
||||
/// can be restored later.
|
||||
/// </summary>
|
||||
private void EnsureRefs()
|
||||
{
|
||||
if (!_orbitCam && orbitInput) _orbitCam = orbitInput.GetComponent<CinemachineCamera>();
|
||||
if (!_deoccluderResolved && _orbitCam)
|
||||
{
|
||||
_deoccluder = _orbitCam.GetComponent<CinemachineDeoccluder>();
|
||||
if (_deoccluder) _deoccluderOriginalAvoid = _deoccluder.AvoidObstacles.Enabled;
|
||||
_deoccluderResolved = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Switches the deoccluder's obstacle avoidance (the camera pull-in) off while this driver replaces
|
||||
/// it with the cutout, or restores the authored state when the replacement is turned off. Only
|
||||
/// toggles on transitions, so it never fights a value changed elsewhere mid-frame.
|
||||
/// </summary>
|
||||
private void ApplyPullInSuppression()
|
||||
{
|
||||
if (!_deoccluder) return;
|
||||
|
||||
if (replaceDeoccluderPullIn && !_pullInSuppressed)
|
||||
{
|
||||
_deoccluder.AvoidObstacles.Enabled = false;
|
||||
_pullInSuppressed = true;
|
||||
}
|
||||
else if (!replaceDeoccluderPullIn && _pullInSuppressed)
|
||||
{
|
||||
_deoccluder.AvoidObstacles.Enabled = _deoccluderOriginalAvoid;
|
||||
_pullInSuppressed = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Restores the deoccluder's authored pull-in if this driver had suppressed it.</summary>
|
||||
private void RestoreDeoccluderPullIn()
|
||||
{
|
||||
if (_pullInSuppressed && _deoccluder)
|
||||
{
|
||||
_deoccluder.AvoidObstacles.Enabled = _deoccluderOriginalAvoid;
|
||||
_pullInSuppressed = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c7d67030b8dcdec489471b88c35dd78b
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3fe9179f5a6af7349909c74b7b867309
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,109 @@
|
||||
using UnityEngine;
|
||||
|
||||
public partial class MeshPainter
|
||||
{
|
||||
// --- Backend selection / switching ---
|
||||
|
||||
private void SetupBackend(bool gpu, Color32[][] canonical)
|
||||
{
|
||||
// SetupGpu returns false if the shaders are missing and it fell back to CPU; honor
|
||||
// the real outcome so _gpuActive never claims GPU while _gpu is null (would NRE on
|
||||
// the next SwitchBackend / paint).
|
||||
if (gpu)
|
||||
{
|
||||
_gpuActive = SetupGpu(canonical);
|
||||
}
|
||||
else
|
||||
{
|
||||
SetupCpu(canonical);
|
||||
_gpuActive = false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
||||
RebuildBackend(toGpu, canonical);
|
||||
|
||||
Debug.Log($"[MeshPainter] Backend switched -> {(toGpu ? "GPU" : "CPU")} painting.", this);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Tears down the active backend and rebuilds it from externally supplied canonical maps (load /
|
||||
/// late-join snapshot / undo). Runs the island/dilation seeding path so edge padding is rebuilt for
|
||||
/// the new content. The input arrays are never mutated (SetupBackend clones its seed).
|
||||
/// </summary>
|
||||
private void ReseedCanonical(Color32[][] canonical) => RebuildBackend(useGpu, canonical);
|
||||
|
||||
/// <summary>Tears down the active backend and sets up the requested one on the given maps.</summary>
|
||||
private void RebuildBackend(bool gpu, Color32[][] canonical)
|
||||
{
|
||||
if (_gpuActive) TeardownGpu();
|
||||
else TeardownCpu();
|
||||
SetupBackend(gpu, canonical);
|
||||
_hasLastScreen = false;
|
||||
}
|
||||
|
||||
/// <summary>Seed source for a map: the supplied canonical data when valid, else the material seed.</summary>
|
||||
private Color32[] ResolveSeed(int mi, Color32[][] canonical)
|
||||
{
|
||||
return (canonical != null && canonical[mi] != null && canonical[mi].Length == textureSize * textureSize)
|
||||
? canonical[mi] : _seedCanonical[mi];
|
||||
}
|
||||
|
||||
// Backend-agnostic stroke dispatch (GPU vs CPU), shared by local painting and replayed net stamps.
|
||||
|
||||
private void BeginBackendStroke()
|
||||
{
|
||||
if (_gpuActive) BeginGpuStroke();
|
||||
else ResetCpuDirty();
|
||||
}
|
||||
|
||||
private void DrawBackendStamp(Vector3 pLocal, float radiusLocal, Vector3 u, Vector3 v, Vector3 n)
|
||||
{
|
||||
if (_gpuActive) DrawBrushGpu(pLocal, radiusLocal, u, v, n);
|
||||
else PaintStamp3D(pLocal, radiusLocal, u, v, n);
|
||||
}
|
||||
|
||||
private void EndBackendStroke()
|
||||
{
|
||||
if (_gpuActive) EndGpuStroke();
|
||||
else EndCpuStroke();
|
||||
}
|
||||
|
||||
/// <summary>Reads a map from the active backend into canonical (material) layout.</summary>
|
||||
private Color32[] ReadCanonical(int mi)
|
||||
{
|
||||
if (_gpuActive)
|
||||
{
|
||||
var gm = _gpu[mi];
|
||||
// MSO: read the packed final mask RT (R=metallic, G=occlusion, A=smoothness).
|
||||
// Others: the display RT. The mask map is linear data; color maps read sRGB.
|
||||
if (mi == MAP_MSO) return ReadRtPixels(gm.final, linear: true);
|
||||
return ReadRtPixels(gm.display, linear: MapIsLinear(mi));
|
||||
}
|
||||
return (Color32[])_cpu[mi].pixels.Clone();
|
||||
}
|
||||
|
||||
private static bool MapIsLinear(int mi) => mi == MAP_MSO;
|
||||
|
||||
// Material slots per map, as pre-hashed IDs (this runs per stroke end - no per-call allocations).
|
||||
// MSO: one mask map drives three URP slots: _MetallicGlossMap (R/A) + _OcclusionMap (G) +
|
||||
// _ParallaxMap (G = occlusion reused as a 0.5-centred height for parallax/relief).
|
||||
private static readonly int[][] MapPropIds =
|
||||
{
|
||||
new[] { BaseMapId, MainTexId }, // MAP_ALBEDO
|
||||
new[] { MetallicGlossMapId, OcclusionMapId, ParallaxMapId }, // MAP_MSO
|
||||
new[] { EmissionMapId }, // MAP_EM
|
||||
};
|
||||
|
||||
private void AssignMapTexture(int mi, Texture tex)
|
||||
{
|
||||
if (!_materialInstance) return;
|
||||
foreach (int id in MapPropIds[mi])
|
||||
if (_materialInstance.HasProperty(id)) _materialInstance.SetTexture(id, tex);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 43a45b2bb2bb2b8499debf83ef5a7371
|
||||
@@ -0,0 +1,341 @@
|
||||
using UnityEngine;
|
||||
|
||||
public partial class MeshPainter
|
||||
{
|
||||
// --- 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),
|
||||
// UV-island seed protects loaded/switched-in content from edge padding right away
|
||||
// (the gutter stays paddable). Clone: painted[] is mutated per stroke.
|
||||
painted = (bool[])GetIslandMask().Clone(),
|
||||
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 = ResolveSeed(mi, canonical);
|
||||
var px = (Color32[])src.Clone();
|
||||
if (mi != MAP_MSO) // albedo / emission: opaque. The mask map keeps alpha = smoothness.
|
||||
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;
|
||||
}
|
||||
|
||||
// --- 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);
|
||||
}
|
||||
}
|
||||
|
||||
// sRGB<->linear LUT so the CPU blends the sRGB color maps (albedo/emission) in LINEAR
|
||||
// space, matching the GPU's sRGB-framebuffer blend. Without it, partial-opacity texels
|
||||
// (soft mask edges, brushColor.a<1) would differ between the GPU and CPU backends.
|
||||
private static readonly float[] _srgbToLinear = BuildSrgbToLinearLut();
|
||||
|
||||
private static float[] BuildSrgbToLinearLut()
|
||||
{
|
||||
var lut = new float[256];
|
||||
for (int i = 0; i < 256; i++) lut[i] = Mathf.GammaToLinearSpace(i / 255f);
|
||||
return lut;
|
||||
}
|
||||
|
||||
/// <summary>Blends a linear source over an sRGB-encoded destination byte in linear space, returns the sRGB byte.</summary>
|
||||
private static byte BlendSrgb(byte dstByte, float srcLinear, float invOp, float op)
|
||||
{
|
||||
float outLinear = _srgbToLinear[dstByte] * invOp + srcLinear * op;
|
||||
return (byte)(Mathf.Clamp01(Mathf.LinearToGammaSpace(outLinear)) * 255f + 0.5f);
|
||||
}
|
||||
|
||||
/// <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, aAO = paintOcclusion;
|
||||
if (!aAlb && !aMS && !aEM && !aAO) return;
|
||||
|
||||
// Albedo target (brush color in linear space; blended linear to match the GPU).
|
||||
float brLin = Mathf.GammaToLinearSpace(brushColor.r);
|
||||
float bgLin = Mathf.GammaToLinearSpace(brushColor.g);
|
||||
float bbLin = Mathf.GammaToLinearSpace(brushColor.b);
|
||||
float albOpMul = Mathf.Clamp01(brushColor.a);
|
||||
CpuMap alb = _cpu[MAP_ALBEDO];
|
||||
|
||||
// Mask map target: R = metallic, G = occlusion, B = 0, A = smoothness (one texture
|
||||
// bound to both _MetallicGlossMap and _OcclusionMap).
|
||||
float msoR = Mathf.Clamp01(metallic) * 255f;
|
||||
float msoG = Mathf.Clamp01(occlusion) * 255f;
|
||||
float msoA = Mathf.Clamp01(smoothness) * 255f;
|
||||
float msOpMul = Mathf.Clamp01(metallicSmoothnessOpacity);
|
||||
float aoOpMul = Mathf.Clamp01(occlusionOpacity);
|
||||
CpuMap mso = _cpu[MAP_MSO];
|
||||
bool aMSO = aMS || aAO;
|
||||
|
||||
// Emission target (linear, like albedo).
|
||||
float erLin = Mathf.GammaToLinearSpace(emissionColor.r);
|
||||
float egLin = Mathf.GammaToLinearSpace(emissionColor.g);
|
||||
float ebLin = Mathf.GammaToLinearSpace(emissionColor.b);
|
||||
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];
|
||||
|
||||
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;
|
||||
|
||||
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(
|
||||
BlendSrgb(dst.r, brLin, it, op),
|
||||
BlendSrgb(dst.g, bgLin, it, op),
|
||||
BlendSrgb(dst.b, bbLin, it, op),
|
||||
255);
|
||||
alb.painted[idx] = true;
|
||||
ExpandDirty(alb, x, y);
|
||||
}
|
||||
}
|
||||
|
||||
if (aMSO)
|
||||
{
|
||||
// Mask map blend. Metallic(R) + smoothness(A) blend with the MS opacity,
|
||||
// occlusion(G) with its own opacity - each = mask * the channel's opacity
|
||||
// multiplier (the albedo brushColor.a equivalent). Untouched channels keep
|
||||
// their previous value; B stays 0.
|
||||
Color32 dst = mso.pixels[idx];
|
||||
float msOp = maskOp * msOpMul;
|
||||
float aoOp = maskOp * aoOpMul;
|
||||
byte mr = aMS ? (byte)(dst.r * (1f - msOp) + msoR * msOp) : dst.r;
|
||||
byte mg = aAO ? (byte)(dst.g * (1f - aoOp) + msoG * aoOp) : dst.g;
|
||||
byte ma = aMS ? (byte)(dst.a * (1f - msOp) + msoA * msOp) : dst.a;
|
||||
mso.pixels[idx] = new Color32(mr, mg, 0, ma);
|
||||
mso.painted[idx] = true;
|
||||
ExpandDirty(mso, 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(
|
||||
BlendSrgb(dst.r, erLin, it, op),
|
||||
BlendSrgb(dst.g, egLin, it, op),
|
||||
BlendSrgb(dst.b, ebLin, it, 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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: c651e96b15e679740a526502ded8cdc2
|
||||
@@ -0,0 +1,308 @@
|
||||
using UnityEngine;
|
||||
|
||||
public partial class MeshPainter
|
||||
{
|
||||
// --- GPU backend ---
|
||||
|
||||
/// <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 mask map)
|
||||
public RenderTexture temp; // dilation ping-pong scratch
|
||||
public RenderTexture final; // MSO only: packed mask map (R=metallic, G=occlusion, A=smoothness); null otherwise
|
||||
public bool drew; // anything drawn this stroke?
|
||||
public bool sRGB; // sRGB color map (albedo/emission) vs linear data map (MSO)
|
||||
}
|
||||
|
||||
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 GpuMap[] _gpu;
|
||||
private Material _brushMat;
|
||||
private Material _dilateMat;
|
||||
private Material _packMat;
|
||||
private bool _prevSRGB;
|
||||
|
||||
/// <summary>
|
||||
/// Sets up the GPU backend (RenderTexture set + materials per map). Returns true on
|
||||
/// success; on missing shaders it falls back to the CPU backend and returns false so
|
||||
/// the caller leaves _gpuActive=false (and useGpu in sync) instead of crashing later.
|
||||
/// </summary>
|
||||
private bool 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);
|
||||
return false;
|
||||
}
|
||||
|
||||
_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_MSO) ? 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);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Seeds a GPU map's display RT from canonical data (coverage alpha = UV islands), then packs MS to final.</summary>
|
||||
private void SeedGpuMap(int mi, Color32[][] canonical)
|
||||
{
|
||||
var src = ResolveSeed(mi, canonical);
|
||||
var gm = _gpu[mi];
|
||||
|
||||
if (mi == MAP_MSO)
|
||||
{
|
||||
// Canonical (R=metallic, G=occlusion, A=smoothness) -> work layout
|
||||
// (R=metallic, G=smoothness, B=occlusion). Alpha = coverage, stamped below.
|
||||
var work = new Color32[src.Length];
|
||||
for (int i = 0; i < src.Length; i++) work[i] = new Color32(src[i].r, src[i].a, src[i].g, 0);
|
||||
UploadColorsToRT(work, gm.display, linear: true);
|
||||
StampCoverage(gm.display);
|
||||
PackMs(gm); // build the final mask RT once so the material shows the seeded values
|
||||
}
|
||||
else
|
||||
{
|
||||
UploadColorsToRT((Color32[])src.Clone(), gm.display, linear: gm.sRGB == false);
|
||||
StampCoverage(gm.display);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Stamps the dilation coverage guard into a map's alpha: rasterizes the mesh with the brush
|
||||
/// shader's coverage pass (pass 1), writing alpha = 1 on exactly the texels the GPU brush can
|
||||
/// paint. This replaces the CPU island-mask approximation, whose generous edge tolerance marked
|
||||
/// sub-pixel island borders as protected even though the brush never reaches them - so they
|
||||
/// could never be overpainted (stayed frozen at their seeded value) while the dilation guarded
|
||||
/// them. With a GPU-accurate guard those border texels fall in the gutter (alpha 0) and dilation
|
||||
/// overpaints them normally. Texels stay alpha 0 until reached, identical to the brush.
|
||||
/// </summary>
|
||||
private void StampCoverage(RenderTexture rt)
|
||||
{
|
||||
if (!_brushMat || !_bakedMesh) return;
|
||||
var prev = RenderTexture.active;
|
||||
Graphics.SetRenderTarget(rt);
|
||||
if (_brushMat.SetPass(1))
|
||||
{
|
||||
DrawBakedMeshNow();
|
||||
}
|
||||
else
|
||||
{
|
||||
Debug.LogError("[MeshPainter] Brush coverage pass (pass 1) missing - dilation guard not seeded.", this);
|
||||
}
|
||||
RenderTexture.active = prev;
|
||||
}
|
||||
|
||||
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 RGB into a RenderTexture via a temporary texture. Alpha (the
|
||||
/// dilation coverage marker) is cleared to 0 here and stamped afterwards by StampCoverage,
|
||||
/// so the guard matches the GPU rasterizer exactly (see SeedGpuMap).</summary>
|
||||
private void UploadColorsToRT(Color32[] colors, RenderTexture rt, bool linear)
|
||||
{
|
||||
var tmp = new Texture2D(textureSize, textureSize, TextureFormat.RGBA32, false, linear);
|
||||
for (int i = 0; i < colors.Length; i++) colors[i].a = 0;
|
||||
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;
|
||||
}
|
||||
|
||||
// --- 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)
|
||||
|
||||
// Stamp placement is shared by every draw on this map.
|
||||
_brushMat.SetTexture(MaskTexId, _stampMask);
|
||||
_brushMat.SetVector(BrushCenterId, pLocal);
|
||||
_brushMat.SetFloat(BrushRadiusId, radiusLocal);
|
||||
_brushMat.SetVector(BrushUId, u);
|
||||
_brushMat.SetVector(BrushVId, v);
|
||||
_brushMat.SetVector(BrushNormalId, n);
|
||||
|
||||
if (mi == MAP_MSO)
|
||||
{
|
||||
// Metallic/smoothness and occlusion share one texture but paint with their OWN
|
||||
// opacity, so each is a separate stamp (own _BrushColor.a + per-channel write
|
||||
// mask). Untouched channels keep their value while coverage (A) accumulates.
|
||||
if (paintMetallicSmoothness)
|
||||
StampMso(Mathf.Clamp01(metallicSmoothnessOpacity), 13f); // R|G|A (skip B = occlusion)
|
||||
if (paintOcclusion)
|
||||
StampMso(Mathf.Clamp01(occlusionOpacity), 3f); // B|A (occlusion only)
|
||||
}
|
||||
else
|
||||
{
|
||||
// Albedo / emission: full RGBA, opacity carried by the colour's own alpha.
|
||||
_brushMat.SetColor(BrushColorId, mi == MAP_ALBEDO ? brushColor : emissionColor);
|
||||
_brushMat.SetFloat(ColorWriteMaskId, 15f);
|
||||
DrawStamp();
|
||||
}
|
||||
|
||||
gm.drew = true;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Issues one mask-map stamp: raw (metallic, smoothness, occlusion) data with the given paint
|
||||
/// opacity in alpha and a per-channel colour write mask, so metallic/smoothness and occlusion
|
||||
/// stay independently paintable with their own opacity. EncodeData pre-compensates the Color
|
||||
/// property's sRGB->linear upload so the values round-trip exactly (CPU parity); alpha is not converted.
|
||||
/// </summary>
|
||||
private void StampMso(float opacity, float colorMask)
|
||||
{
|
||||
_brushMat.SetVector(BrushColorId, new Vector4(
|
||||
EncodeData(metallic), EncodeData(smoothness), EncodeData(occlusion), opacity));
|
||||
_brushMat.SetFloat(ColorWriteMaskId, colorMask);
|
||||
DrawStamp();
|
||||
}
|
||||
|
||||
/// <summary>Draws the baked mesh once per sub-mesh with the current brush pass (UV-space rasterization).</summary>
|
||||
private void DrawStamp()
|
||||
{
|
||||
if (!_brushMat.SetPass(0)) return;
|
||||
DrawBakedMeshNow();
|
||||
}
|
||||
|
||||
/// <summary>Draws the baked mesh once per sub-mesh with the pass the caller just set.</summary>
|
||||
private void DrawBakedMeshNow()
|
||||
{
|
||||
int sub = _bakedMesh.subMeshCount;
|
||||
for (int i = 0; i < sub; i++)
|
||||
Graphics.DrawMeshNow(_bakedMesh, Matrix4x4.identity, i);
|
||||
}
|
||||
|
||||
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>
|
||||
/// Pre-compensates a raw data value (0..1) for the brush's Color property, which Unity
|
||||
/// converts sRGB->linear on upload in a linear project. Applying the inverse here makes
|
||||
/// the value round-trip exactly (so metallic/smoothness/occlusion are stored faithfully
|
||||
/// and match the CPU byte writes). In gamma color space no conversion happens, so pass through.
|
||||
/// </summary>
|
||||
private static float EncodeData(float v)
|
||||
{
|
||||
v = Mathf.Clamp01(v);
|
||||
return QualitySettings.activeColorSpace == ColorSpace.Linear ? Mathf.LinearToGammaSpace(v) : v;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0e2c632532b3a794f8a2dd6ac26a5fd7
|
||||
@@ -0,0 +1,271 @@
|
||||
using System.IO;
|
||||
using System.IO.Compression;
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// One committed brush stamp in replicable form. Painting is synced as commands, not pixels: the owner
|
||||
/// already resolves every stamp into local object space (hit point, radius, tangent frame), so a remote
|
||||
/// peer replays the SAME stamp through its own backend onto the identical mesh/UVs.
|
||||
/// Self-contained on purpose: each stamp carries its full brush state, so the stream is stateless.
|
||||
/// Channel data quantizes to bytes with no real loss (the paint maps are 8-bit anyway).
|
||||
/// </summary>
|
||||
public struct PaintStampNet : INetworkSerializable
|
||||
{
|
||||
// Channel/mode bit flags (Channels field).
|
||||
public const byte FlagAlbedo = 1 << 0;
|
||||
public const byte FlagMetallicSmoothness = 1 << 1;
|
||||
public const byte FlagOcclusion = 1 << 2;
|
||||
public const byte FlagEmission = 1 << 3;
|
||||
public const byte FlagSpray = 1 << 4;
|
||||
|
||||
// Placement (object-local space).
|
||||
public Vector3 P; // hit point
|
||||
public float R; // 3D brush radius
|
||||
public Vector3 U, V, N; // tangent frame (right / up / normal)
|
||||
|
||||
public byte Channels; // active channels + spray flag
|
||||
public int BrushIndex; // active brush mask index (resolved-mask painting)
|
||||
public int SpraySeed; // RNG seed so a spray stamp scatters identically on every peer
|
||||
|
||||
public Color32 BrushColor; // albedo color (alpha = opacity)
|
||||
public Color32 EmissionColor; // emission color (alpha = opacity)
|
||||
public byte Metallic, Smoothness, Occlusion; // 0..255 = 0..1
|
||||
public byte MSOpacity, OccOpacity; // 0..255 = 0..1
|
||||
|
||||
public void NetworkSerialize<T>(BufferSerializer<T> s) where T : IReaderWriter
|
||||
{
|
||||
s.SerializeValue(ref P);
|
||||
s.SerializeValue(ref R);
|
||||
s.SerializeValue(ref U);
|
||||
s.SerializeValue(ref V);
|
||||
s.SerializeValue(ref N);
|
||||
s.SerializeValue(ref Channels);
|
||||
s.SerializeValue(ref BrushIndex);
|
||||
s.SerializeValue(ref SpraySeed);
|
||||
s.SerializeValue(ref BrushColor);
|
||||
s.SerializeValue(ref EmissionColor);
|
||||
s.SerializeValue(ref Metallic);
|
||||
s.SerializeValue(ref Smoothness);
|
||||
s.SerializeValue(ref Occlusion);
|
||||
s.SerializeValue(ref MSOpacity);
|
||||
s.SerializeValue(ref OccOpacity);
|
||||
}
|
||||
}
|
||||
|
||||
public partial class MeshPainter
|
||||
{
|
||||
/// <summary>
|
||||
/// Fires once per committed brush stamp on the owner (after the stamp has been drawn locally),
|
||||
/// carrying everything a remote peer needs to replay it. Only the owner's PlayerPaintSync
|
||||
/// subscribes; with no subscriber this is a no-op, so single-player keeps zero overhead.
|
||||
/// </summary>
|
||||
public event System.Action<PaintStampNet> OnStampCommitted;
|
||||
|
||||
// Last spray seed used by the owner this stamp (captured into the replicated stamp).
|
||||
private int _lastSpraySeed;
|
||||
|
||||
private static byte ToByte01(float v) => (byte)(Mathf.Clamp01(v) * 255f + 0.5f);
|
||||
|
||||
/// <summary>True if anyone is listening for replicated stamps (the owner is networked + painting).</summary>
|
||||
private bool WantsStampCapture => OnStampCommitted != null;
|
||||
|
||||
/// <summary>Builds the replicated stamp from the current brush state + resolved placement and raises the event.</summary>
|
||||
private void EmitStamp(Vector3 p, float r, Vector3 u, Vector3 v, Vector3 n)
|
||||
{
|
||||
var handler = OnStampCommitted;
|
||||
if (handler == null) return;
|
||||
|
||||
byte ch = 0;
|
||||
if (paintAlbedo) ch |= PaintStampNet.FlagAlbedo;
|
||||
if (paintMetallicSmoothness) ch |= PaintStampNet.FlagMetallicSmoothness;
|
||||
if (paintOcclusion) ch |= PaintStampNet.FlagOcclusion;
|
||||
if (paintEmission) ch |= PaintStampNet.FlagEmission;
|
||||
if (spray) ch |= PaintStampNet.FlagSpray;
|
||||
|
||||
handler(new PaintStampNet
|
||||
{
|
||||
P = p, R = r, U = u, V = v, N = n,
|
||||
Channels = ch,
|
||||
BrushIndex = activeBrushIndex,
|
||||
SpraySeed = _lastSpraySeed,
|
||||
BrushColor = brushColor,
|
||||
EmissionColor = emissionColor,
|
||||
Metallic = ToByte01(metallic),
|
||||
Smoothness = ToByte01(smoothness),
|
||||
Occlusion = ToByte01(occlusion),
|
||||
MSOpacity = ToByte01(metallicSmoothnessOpacity),
|
||||
OccOpacity = ToByte01(occlusionOpacity),
|
||||
});
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bakes the spray mask from a fixed RNG seed so every peer scatters the identical pattern. The
|
||||
/// global Unity RNG state is saved and restored so nothing else in the frame is perturbed.
|
||||
/// </summary>
|
||||
private void BuildSprayStampMaskSeeded(int seed)
|
||||
{
|
||||
var prev = Random.state;
|
||||
Random.InitState(seed);
|
||||
BuildSprayStampMask();
|
||||
Random.state = prev;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Replays a replicated brush stamp on a remote avatar: adopts the stamp's brush state, rebuilds
|
||||
/// the matching mask (resolved or seeded spray) and runs it through the active backend exactly like
|
||||
/// a local stamp. Mutating the public brush fields is safe here because the remote painter never
|
||||
/// paints from local input (PaintingEnabled is false on remotes).
|
||||
/// </summary>
|
||||
public void ApplyNetworkStamp(in PaintStampNet s)
|
||||
{
|
||||
if (!_setupValid) return;
|
||||
|
||||
paintAlbedo = (s.Channels & PaintStampNet.FlagAlbedo) != 0;
|
||||
paintMetallicSmoothness = (s.Channels & PaintStampNet.FlagMetallicSmoothness) != 0;
|
||||
paintOcclusion = (s.Channels & PaintStampNet.FlagOcclusion) != 0;
|
||||
paintEmission = (s.Channels & PaintStampNet.FlagEmission) != 0;
|
||||
spray = (s.Channels & PaintStampNet.FlagSpray) != 0;
|
||||
|
||||
brushColor = s.BrushColor;
|
||||
emissionColor = s.EmissionColor;
|
||||
metallic = s.Metallic / 255f;
|
||||
smoothness = s.Smoothness / 255f;
|
||||
occlusion = s.Occlusion / 255f;
|
||||
metallicSmoothnessOpacity = s.MSOpacity / 255f;
|
||||
occlusionOpacity = s.OccOpacity / 255f;
|
||||
activeBrushIndex = s.BrushIndex;
|
||||
|
||||
if (spray) BuildSprayStampMaskSeeded(s.SpraySeed);
|
||||
else { ResolveActiveMask(); UseResolvedStampMask(); }
|
||||
|
||||
BeginBackendStroke();
|
||||
DrawBackendStamp(s.P, s.R, s.U, s.V, s.N);
|
||||
EndBackendStroke();
|
||||
|
||||
// Accumulate this peer's stroke dirty mask so a network checkpoint marker can commit the same
|
||||
// history entry as the owner (same stamps -> same touched maps -> COW-aligned with the owner).
|
||||
MarkStrokePainted();
|
||||
}
|
||||
|
||||
// --- Late-join snapshot: the whole painted state as a compressed blob ---
|
||||
//
|
||||
// Live stamps only reach peers connected at paint time; a late joiner needs the accumulated
|
||||
// result as a baseline. Instead of streaming 12 MB of raw maps, we dump the canonical maps once
|
||||
// and Deflate them (paint is largely flat -> compresses well), then PlayerPaintSync chunks the
|
||||
// blob over the network to the single joining client and re-seeds their painter with it.
|
||||
|
||||
private const uint SnapshotMagic = 0x50414e53; // 'SNAP' little-endian
|
||||
private const int SnapshotVersion = 1;
|
||||
|
||||
/// <summary>True once Start() has built the backend, so capture/apply are safe.</summary>
|
||||
public bool IsPaintReady => _setupValid;
|
||||
|
||||
/// <summary>
|
||||
/// Serializes every paint map (canonical layout) and Deflate-compresses the result. Returns false
|
||||
/// if the painter is not set up yet. Run on the authority that holds the up-to-date maps.
|
||||
/// </summary>
|
||||
public bool TryCaptureSnapshot(out byte[] compressed)
|
||||
{
|
||||
compressed = null;
|
||||
if (!_setupValid) return false;
|
||||
|
||||
int n = textureSize * textureSize;
|
||||
using (var raw = new MemoryStream(16 + n * 4 * MAP_COUNT))
|
||||
{
|
||||
using (var w = new BinaryWriter(raw, System.Text.Encoding.UTF8, leaveOpen: true))
|
||||
{
|
||||
w.Write(SnapshotMagic);
|
||||
w.Write(SnapshotVersion);
|
||||
w.Write(textureSize);
|
||||
w.Write(MAP_COUNT);
|
||||
|
||||
WriteCanonicalMaps(w);
|
||||
}
|
||||
compressed = Deflate(raw.ToArray());
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Decompresses a snapshot, re-seeds the active backend with it, and makes it this peer's sole history
|
||||
/// baseline under the authority's <paramref name="baselineId"/> (so subsequent checkpoint/restore
|
||||
/// commands line up). Caller must ensure IsPaintReady.
|
||||
/// </summary>
|
||||
public void ApplyPaintSnapshot(byte[] compressed, int baselineId)
|
||||
{
|
||||
if (!TryDecodeSnapshot(compressed, "Paint", out Color32[][] canonical)) return;
|
||||
|
||||
ReseedCanonical(canonical);
|
||||
SetHistoryBaseline(canonical, baselineId);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owner-side: re-seeds the painter from an in-memory snapshot blob and resets history to it as a
|
||||
/// fresh local baseline (like <see cref="LoadPaintByName"/>, just from bytes) - used to carry a
|
||||
/// painting across a scene-switch respawn, then rebroadcast via BroadcastLoadedPaint. Returns false
|
||||
/// if the painter is not ready or the blob is incompatible (the current painting stays untouched).
|
||||
/// </summary>
|
||||
public bool LoadPaintFromSnapshot(byte[] compressed)
|
||||
{
|
||||
if (!TryDecodeSnapshot(compressed, "Carry", out Color32[][] canonical)) return false;
|
||||
|
||||
ReseedCanonical(canonical);
|
||||
ResetHistoryToCurrent(canonical);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Guard + decode for an incoming snapshot blob; false = not ready / empty / undecodable.</summary>
|
||||
private bool TryDecodeSnapshot(byte[] compressed, string context, out Color32[][] canonical)
|
||||
{
|
||||
canonical = null;
|
||||
if (!_setupValid || compressed == null || compressed.Length == 0) return false;
|
||||
|
||||
try { canonical = DecodeSnapshot(compressed); }
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[MeshPainter] {context} snapshot decode failed: {e.Message}", this);
|
||||
return false;
|
||||
}
|
||||
return canonical != null;
|
||||
}
|
||||
|
||||
private Color32[][] DecodeSnapshot(byte[] compressed)
|
||||
{
|
||||
byte[] raw = Inflate(compressed);
|
||||
using (var ms = new MemoryStream(raw))
|
||||
using (var r = new BinaryReader(ms))
|
||||
{
|
||||
if (r.ReadUInt32() != SnapshotMagic) return null;
|
||||
int ver = r.ReadInt32();
|
||||
int size = r.ReadInt32();
|
||||
int maps = r.ReadInt32();
|
||||
|
||||
// Resolution / version mismatch: ignore rather than smear; the painter keeps its state.
|
||||
if (ver != SnapshotVersion || size != textureSize) return null;
|
||||
|
||||
return ReadCanonicalMaps(r, maps);
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Deflate(byte[] data)
|
||||
{
|
||||
using (var outp = new MemoryStream())
|
||||
{
|
||||
using (var ds = new DeflateStream(outp, System.IO.Compression.CompressionLevel.Fastest, leaveOpen: true))
|
||||
ds.Write(data, 0, data.Length);
|
||||
return outp.ToArray();
|
||||
}
|
||||
}
|
||||
|
||||
private static byte[] Inflate(byte[] data)
|
||||
{
|
||||
using (var inp = new MemoryStream(data))
|
||||
using (var ds = new DeflateStream(inp, CompressionMode.Decompress))
|
||||
using (var outp = new MemoryStream())
|
||||
{
|
||||
ds.CopyTo(outp);
|
||||
return outp.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: ff9c722ff799831408a3475fe7fd57a6
|
||||
@@ -0,0 +1,309 @@
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using UnityEngine;
|
||||
|
||||
public partial class MeshPainter
|
||||
{
|
||||
// --- Persistence: named paint slots, saved/loaded explicitly from the HUD ---
|
||||
//
|
||||
// The painter no longer auto-loads on start or auto-saves on quit; painting is volatile until the
|
||||
// player explicitly saves it to a named slot, and a fresh session always starts from the material
|
||||
// seed. Saved slots live in a single global library folder (role/object independent), so any avatar
|
||||
// can load any saved painting as a camouflage preset.
|
||||
|
||||
// Raw, bit-exact map dump: 'CHMP' magic + version + name + width + height + map count, then each
|
||||
// map's canonical Color32 bytes. Raw (not PNG) avoids any sRGB/alpha-premultiply ambiguity and
|
||||
// round-trips the exact canonical bytes ReadCanonical produces.
|
||||
private const uint PaintFileMagic = 0x504D4843; // 'CHMP' little-endian
|
||||
private const int PaintFileVersion = 4; // v3: emission + camo scalars; v4: + painted-height knobs
|
||||
private const string PaintFileExt = ".paint";
|
||||
|
||||
/// <summary>
|
||||
/// Per-player material scalars stored alongside the maps from v3 on. These are NOT baked into the
|
||||
/// painted pixels (a global emission multiplier + the PlayerCamoLit knobs), so they ride the slot
|
||||
/// header and are re-applied onto the painter when the slot loads.
|
||||
/// </summary>
|
||||
private struct PaintScalars
|
||||
{
|
||||
public bool has; // false = legacy v2 slot with no scalars -> leave the painter's values untouched
|
||||
public bool hasHeight; // v4+ painted-height knobs present (occlusion threshold + normal/parallax intensity)
|
||||
public float emissionIntensity;
|
||||
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen;
|
||||
public float occlusionThreshold, heightNormalIntensity, parallaxIntensity;
|
||||
}
|
||||
|
||||
private static string PaintLibraryDir =>
|
||||
Path.Combine(Application.persistentDataPath, "MeshPaint", "Saved");
|
||||
|
||||
/// <summary>Filesystem-safe key: invalid filename characters and spaces become underscores.</summary>
|
||||
internal static string SanitizeFileKey(string s)
|
||||
{
|
||||
foreach (char c in Path.GetInvalidFileNameChars()) s = s.Replace(c, '_');
|
||||
return s.Replace(' ', '_');
|
||||
}
|
||||
|
||||
private static string SanitizeKey(string s) =>
|
||||
string.IsNullOrEmpty(s) ? "unnamed" : SanitizeFileKey(s);
|
||||
|
||||
/// <summary>Disk path for a slot. The display name lives inside the file; the file name is a sanitized key.</summary>
|
||||
private static string PathForName(string name) =>
|
||||
Path.Combine(PaintLibraryDir, SanitizeKey(name) + PaintFileExt);
|
||||
|
||||
/// <summary>True if a slot with this name already exists (used to block overwriting on save).</summary>
|
||||
public static bool PaintExists(string name) =>
|
||||
!string.IsNullOrWhiteSpace(name) && File.Exists(PathForName(name));
|
||||
|
||||
/// <summary>Display names of every saved slot, sorted alphabetically. Each name is read from the file header.</summary>
|
||||
public static List<string> ListSavedPaintings()
|
||||
{
|
||||
var names = new List<string>();
|
||||
try
|
||||
{
|
||||
if (!Directory.Exists(PaintLibraryDir)) return names;
|
||||
foreach (string path in Directory.GetFiles(PaintLibraryDir, "*" + PaintFileExt))
|
||||
{
|
||||
string n = ReadHeaderName(path);
|
||||
if (!string.IsNullOrEmpty(n)) names.Add(n);
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[MeshPainter] Listing saved paintings failed: {e.Message}");
|
||||
}
|
||||
names.Sort(System.StringComparer.CurrentCultureIgnoreCase);
|
||||
return names;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Writes the current painted maps to a named slot. Returns false without writing if the painter is
|
||||
/// not ready. An existing slot of the same name is overwritten only when <paramref name="overwrite"/>
|
||||
/// is true; otherwise the call fails rather than clobbering it.
|
||||
/// </summary>
|
||||
public bool SavePaintAs(string name, bool overwrite = false)
|
||||
{
|
||||
if (!_setupValid || string.IsNullOrWhiteSpace(name)) return false;
|
||||
name = name.Trim();
|
||||
if (!overwrite && PaintExists(name)) return false;
|
||||
|
||||
try
|
||||
{
|
||||
Directory.CreateDirectory(PaintLibraryDir);
|
||||
|
||||
using (var fs = new FileStream(PathForName(name), FileMode.Create, FileAccess.Write))
|
||||
using (var w = new BinaryWriter(fs))
|
||||
{
|
||||
w.Write(PaintFileMagic);
|
||||
w.Write(PaintFileVersion);
|
||||
w.Write(name); // length-prefixed UTF8 (BinaryWriter.Write(string))
|
||||
w.Write(textureSize);
|
||||
w.Write(textureSize);
|
||||
w.Write(MAP_COUNT);
|
||||
|
||||
// v3: per-player material scalars that are NOT baked into the maps (a global emission
|
||||
// multiplier + the PlayerCamoLit knobs). The painter is ready here (_setupValid), so the
|
||||
// material-backed camo getters return the live values rather than the not-ready 0.
|
||||
w.Write(emissionIntensity);
|
||||
w.Write(CamoHalfLambert);
|
||||
w.Write(CamoHalfLambertBrightness);
|
||||
w.Write(CamoSpecularDampen);
|
||||
|
||||
// v4: the painted-height knobs (same material-instance source of truth as the camo knobs).
|
||||
w.Write(OcclusionThreshold);
|
||||
w.Write(HeightNormalIntensity);
|
||||
w.Write(ParallaxIntensity);
|
||||
|
||||
WriteCanonicalMaps(w);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[MeshPainter] Saving paint '{name}' failed: {e.Message}", this);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Loads a named slot and re-seeds the active backend with it, replacing the current painting.
|
||||
/// Returns false if the slot is missing or incompatible (the current painting is left untouched).
|
||||
/// </summary>
|
||||
public bool LoadPaintByName(string name)
|
||||
{
|
||||
if (!_setupValid) return false;
|
||||
|
||||
Color32[][] canonical = ReadMaps(PathForName(name), out PaintScalars scalars);
|
||||
if (canonical == null) return false;
|
||||
|
||||
ReseedCanonical(canonical);
|
||||
|
||||
// A load replaces the whole painting and is NOT undoable: the loaded maps become the new history
|
||||
// baseline, clearing any prior undo/redo. Peers reset to the same baseline via the load broadcast.
|
||||
ResetHistoryToCurrent(canonical);
|
||||
|
||||
// v3 slots also carry the per-player material scalars; apply them so the loaded look is complete.
|
||||
// Legacy v2 slots have none -> leave the current emission/camo untouched. The painter is ready here
|
||||
// (_setupValid), so the material-backed camo setters take effect; PlayerPaintSync replicates the
|
||||
// changed values to peers via their owner-authoritative NetworkVariables on its next LateUpdate.
|
||||
if (scalars.has)
|
||||
{
|
||||
emissionIntensity = scalars.emissionIntensity;
|
||||
RefreshEmissionColor();
|
||||
CamoHalfLambert = scalars.camoHalfLambert;
|
||||
CamoHalfLambertBrightness = scalars.camoHalfLambertBrightness;
|
||||
CamoSpecularDampen = scalars.camoSpecularDampen;
|
||||
}
|
||||
// v4 slots also carry the painted-height knobs; older slots (v2/v3) leave them at the current value.
|
||||
if (scalars.hasHeight)
|
||||
{
|
||||
OcclusionThreshold = scalars.occlusionThreshold;
|
||||
HeightNormalIntensity = scalars.heightNormalIntensity;
|
||||
ParallaxIntensity = scalars.parallaxIntensity;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Deletes a named slot. Returns true if a file was removed.</summary>
|
||||
public static bool DeletePaint(string name)
|
||||
{
|
||||
try
|
||||
{
|
||||
string path = PathForName(name);
|
||||
if (!File.Exists(path)) return false;
|
||||
File.Delete(path);
|
||||
return true;
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[MeshPainter] Deleting paint '{name}' failed: {e.Message}");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads just the display-name header of a slot file (or the file name as a fallback).</summary>
|
||||
private static string ReadHeaderName(string path)
|
||||
{
|
||||
try
|
||||
{
|
||||
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
|
||||
using (var r = new BinaryReader(fs, Encoding.UTF8))
|
||||
{
|
||||
if (!TryReadPaintVersion(r, out _)) return null;
|
||||
return r.ReadString();
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
return Path.GetFileNameWithoutExtension(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads all maps from a slot file in canonical layout, or null if missing / incompatible. Per-map
|
||||
/// null entries are tolerated by both backends (they fall back to the material seed).
|
||||
/// </summary>
|
||||
private Color32[][] ReadMaps(string path, out PaintScalars scalars)
|
||||
{
|
||||
scalars = default;
|
||||
if (!File.Exists(path)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
using (var fs = new FileStream(path, FileMode.Open, FileAccess.Read))
|
||||
using (var r = new BinaryReader(fs, Encoding.UTF8))
|
||||
{
|
||||
if (!TryReadPaintVersion(r, out int ver)) return null;
|
||||
r.ReadString(); // display name (header only; not needed for the pixel load)
|
||||
int w = r.ReadInt32();
|
||||
int h = r.ReadInt32();
|
||||
int maps = r.ReadInt32();
|
||||
|
||||
// v3+: the per-player material scalars sit between the header and the pixel maps.
|
||||
if (ver >= 3)
|
||||
{
|
||||
scalars.has = true;
|
||||
scalars.emissionIntensity = r.ReadSingle();
|
||||
scalars.camoHalfLambert = r.ReadSingle();
|
||||
scalars.camoHalfLambertBrightness = r.ReadSingle();
|
||||
scalars.camoSpecularDampen = r.ReadSingle();
|
||||
}
|
||||
|
||||
// v4+: the painted-height knobs follow the camo scalars.
|
||||
if (ver >= 4)
|
||||
{
|
||||
scalars.hasHeight = true;
|
||||
scalars.occlusionThreshold = r.ReadSingle();
|
||||
scalars.heightNormalIntensity = r.ReadSingle();
|
||||
scalars.parallaxIntensity = r.ReadSingle();
|
||||
}
|
||||
|
||||
// Resolution change -> ignore rather than smear mismatched data; the brush re-seeds
|
||||
// from the material instead.
|
||||
if (w != textureSize || h != textureSize)
|
||||
{
|
||||
Debug.LogWarning($"[MeshPainter] Saved paint ignored (got {w}x{h}, expected " +
|
||||
$"{textureSize}x{textureSize}).", this);
|
||||
return null;
|
||||
}
|
||||
|
||||
return ReadCanonicalMaps(r, maps);
|
||||
}
|
||||
}
|
||||
catch (System.Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[MeshPainter] Loading paint failed: {e.Message}", this);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Validates the paint-file magic and version range (legacy v2 .. current).</summary>
|
||||
private static bool TryReadPaintVersion(BinaryReader r, out int ver)
|
||||
{
|
||||
if (r.ReadUInt32() != PaintFileMagic) { ver = 0; return false; }
|
||||
ver = r.ReadInt32();
|
||||
return ver >= 2 && ver <= PaintFileVersion;
|
||||
}
|
||||
|
||||
/// <summary>Writes every map's canonical Color32 bytes to the stream (shared by slot save + net snapshot).</summary>
|
||||
private void WriteCanonicalMaps(BinaryWriter w)
|
||||
{
|
||||
int n = textureSize * textureSize;
|
||||
var bytes = new byte[n * 4];
|
||||
for (int mi = 0; mi < MAP_COUNT; mi++)
|
||||
{
|
||||
Color32[] px = ReadCanonical(mi);
|
||||
int count = Mathf.Min(n, px.Length);
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
int o = i * 4;
|
||||
bytes[o] = px[i].r;
|
||||
bytes[o + 1] = px[i].g;
|
||||
bytes[o + 2] = px[i].b;
|
||||
bytes[o + 3] = px[i].a;
|
||||
}
|
||||
w.Write(bytes, 0, count * 4);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Reads up to MAP_COUNT canonical map blocks; a truncated stream leaves the remaining maps null (seeded).</summary>
|
||||
private Color32[][] ReadCanonicalMaps(BinaryReader r, int mapsInFile)
|
||||
{
|
||||
int n = textureSize * textureSize;
|
||||
var result = new Color32[MAP_COUNT][];
|
||||
int load = Mathf.Min(mapsInFile, MAP_COUNT);
|
||||
for (int mi = 0; mi < load; mi++)
|
||||
{
|
||||
byte[] raw = r.ReadBytes(n * 4);
|
||||
if (raw.Length != n * 4) break; // truncated -> remaining maps stay at the material seed
|
||||
|
||||
var px = new Color32[n];
|
||||
for (int i = 0; i < n; i++)
|
||||
{
|
||||
int o = i * 4;
|
||||
px[i] = new Color32(raw[o], raw[o + 1], raw[o + 2], raw[o + 3]);
|
||||
}
|
||||
result[mi] = px;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: cbe2b3b1d4eb9eb489c40b1ee7f31ac3
|
||||
@@ -0,0 +1,558 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
public partial class MeshPainter
|
||||
{
|
||||
// --- Pipette (eyedropper) --- (semantics: see the pipette tooltip; per-surface routing: PickAtScreen)
|
||||
|
||||
[Header("Brush - pipette (eyedropper)")]
|
||||
[Tooltip("Pipette mode: a click samples the channel values at the hit point on ANY surface " +
|
||||
"(albedo, metallic, smoothness, emission) and adopts them into the brush (opacities/alphas " +
|
||||
"left untouched) instead of painting. Height (occlusion) is adopted and its toggle enabled " +
|
||||
"only when our own paint mesh is picked; any other surface disables the height toggle. On a " +
|
||||
"foreign object the data channels are read texel-exact from the material; albedo follows " +
|
||||
"'Pipette Albedo From Screen'. Held button picks once.")]
|
||||
public bool pipette = false;
|
||||
|
||||
[Tooltip("On a foreign-object pick, where albedo comes from. OFF (default): albedo is read " +
|
||||
"texel-exact from the material like the other channels (raw, unlit). ON: albedo is taken " +
|
||||
"from the lit on-screen pixel ('what you see' - honours baked lightmaps/ambient/SSAO). " +
|
||||
"The data channels (metallic/smoothness/emission) always come from the material.")]
|
||||
[SerializeField] private bool pipetteAlbedoFromScreen = false;
|
||||
|
||||
/// <summary>
|
||||
/// Raised after a successful pick, once the brush has adopted the sampled values and all four
|
||||
/// paint-channel flags have been enabled. The in-game HUD listens to re-sync its widgets.
|
||||
/// </summary>
|
||||
public event Action Picked;
|
||||
|
||||
private bool _pickHeld; // edge tracking so a held button picks once, not every frame
|
||||
|
||||
// A terrain hit higher than this above the heightfield is a tree (trees share the terrain
|
||||
// collider but stand proud of the ground); on the surface it is bare ground.
|
||||
private const float TerrainFoliageHeightEpsilon = 0.3f;
|
||||
|
||||
/// <summary>
|
||||
/// Raycasts at a screen position against ANY collider (regardless of which channels are toggled),
|
||||
/// samples the channel values at the hit and adopts them into the brush (toggle rules: see
|
||||
/// <see cref="FinalizePick"/>). Pick paths, by what was hit:
|
||||
/// - our paint mesh -> texel-exact from the painted maps (full PBR);
|
||||
/// - terrain ground -> blend of the terrain layers by their splat weights (full PBR);
|
||||
/// - terrain tree / detail -> the rendered on-screen colour (no per-instance renderer/UV);
|
||||
/// - any foreign object -> data channels texel-exact from the material; albedo from the
|
||||
/// material or the lit on-screen pixel per pipetteAlbedoFromScreen;
|
||||
/// - anything else / sky -> the rendered on-screen colour.
|
||||
/// </summary>
|
||||
private void PickAtScreen(Vector2 screenPos)
|
||||
{
|
||||
bool ok;
|
||||
bool pickedHeight = false; // height is only meaningful off our OWN painted maps
|
||||
if (TryRaycastAny(screenPos, out RaycastHit hit))
|
||||
{
|
||||
if (hit.collider == _collider)
|
||||
{
|
||||
ok = PickFromPaintTextures(hit);
|
||||
pickedHeight = ok;
|
||||
}
|
||||
else
|
||||
{
|
||||
Terrain terrain = hit.collider is TerrainCollider ? hit.collider.GetComponent<Terrain>() : null;
|
||||
if (terrain)
|
||||
ok = PickFromTerrain(terrain, hit, screenPos);
|
||||
else
|
||||
ok = PickFromForeignHybrid(hit, screenPos);
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
// Ray left the scene entirely (e.g. foliage against the sky) - only the screen pixel remains.
|
||||
ok = PickFromScreen(screenPos);
|
||||
}
|
||||
if (!ok) return;
|
||||
|
||||
FinalizePick(pickedHeight);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Common tail of every successful pick: enables the sampled paint channels (height only when our
|
||||
/// own paint mesh was picked - any other surface disables its toggle and keeps the value), leaves
|
||||
/// pipette mode (one-shot) and notifies listeners (the HUD) to re-sync their widgets.
|
||||
/// </summary>
|
||||
private void FinalizePick(bool pickedHeight)
|
||||
{
|
||||
paintAlbedo = true;
|
||||
paintMetallicSmoothness = true;
|
||||
paintOcclusion = pickedHeight;
|
||||
paintEmission = true;
|
||||
|
||||
pipette = false;
|
||||
|
||||
Picked?.Invoke();
|
||||
}
|
||||
|
||||
/// <summary>Adopts a sampled colour as the brush albedo, preserving the brush's paint opacity (alpha).</summary>
|
||||
private void AdoptAlbedo(Color c) =>
|
||||
brushColor = new Color(Mathf.Clamp01(c.r), Mathf.Clamp01(c.g), Mathf.Clamp01(c.b), brushColor.a);
|
||||
|
||||
/// <summary>Zeroes the emission colour, preserving its paint opacity (alpha).</summary>
|
||||
private void ClearEmissionRgb() =>
|
||||
emissionColor = new Color(0f, 0f, 0f, emissionColor.a);
|
||||
|
||||
/// <summary>
|
||||
/// Picks from our own painted maps at the hit texel (bit-exact, backend-agnostic).
|
||||
/// </summary>
|
||||
private bool PickFromPaintTextures(RaycastHit hit)
|
||||
{
|
||||
if (_verts == null || _uvs == null || _tris == null || hit.triangleIndex < 0) return false;
|
||||
|
||||
int t = hit.triangleIndex;
|
||||
if (t * 3 + 2 >= _tris.Length) return false;
|
||||
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
|
||||
|
||||
// UV at the hit via barycentric interpolation of the cached UVs - same UV space the
|
||||
// brush paints into, so the sampled texel is exactly the one that would be painted.
|
||||
Vector3 bc = hit.barycentricCoordinate;
|
||||
Vector2 uv = _uvs[i0] * bc.x + _uvs[i1] * bc.y + _uvs[i2] * bc.z;
|
||||
|
||||
int px = Mathf.Clamp(Mathf.FloorToInt(uv.x * textureSize), 0, textureSize - 1);
|
||||
int py = Mathf.Clamp(Mathf.FloorToInt(uv.y * textureSize), 0, textureSize - 1);
|
||||
int idx = py * textureSize + px;
|
||||
|
||||
// ReadCanonical gives material layout for either backend:
|
||||
// albedo -> sRGB RGB
|
||||
// MSO -> R = metallic, G = occlusion, A = smoothness (linear data)
|
||||
// emission -> sRGB RGB
|
||||
Color32[] alb = ReadCanonical(MAP_ALBEDO);
|
||||
Color32[] mso = ReadCanonical(MAP_MSO);
|
||||
Color32[] em = ReadCanonical(MAP_EM);
|
||||
if (idx < 0 || idx >= alb.Length || idx >= mso.Length || idx >= em.Length) return false;
|
||||
|
||||
Color32 a = alb[idx];
|
||||
Color32 m = mso[idx];
|
||||
Color32 e = em[idx];
|
||||
|
||||
// sRGB bytes map straight back into the gamma-space brush colours (round-trips exactly). The
|
||||
// alpha (paint opacity) is deliberately preserved, not overwritten - a pick changes only the colour.
|
||||
brushColor = new Color(a.r / 255f, a.g / 255f, a.b / 255f, brushColor.a);
|
||||
metallic = m.r / 255f;
|
||||
smoothness = m.a / 255f;
|
||||
occlusion = m.g / 255f; // the painted HEIGHT - adopted only off our own mesh (FinalizePick)
|
||||
emissionColor = new Color(e.r / 255f, e.g / 255f, e.b / 255f, emissionColor.a);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Foreign-object pick: data channels always texel-exact from the material (raw, unlit); albedo
|
||||
/// optionally overwritten with the fully-lit on-screen pixel (<see cref="pipetteAlbedoFromScreen"/>).
|
||||
/// Falls back to a pure screen pick when no texel-exact material read is possible.
|
||||
/// </summary>
|
||||
private bool PickFromForeignHybrid(RaycastHit hit, Vector2 screenPos)
|
||||
{
|
||||
if (PickFromForeignMaterial(hit))
|
||||
{
|
||||
// No camera (headless) -> keep the material's raw albedo rather than failing.
|
||||
if (pipetteAlbedoFromScreen && TrySampleScreenColor(screenPos, out Color lit))
|
||||
AdoptAlbedo(lit);
|
||||
return true;
|
||||
}
|
||||
|
||||
return PickFromScreen(screenPos);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks from a foreign object's material at the hit UV: texel-exact texture samples
|
||||
/// (where a UV at the hit can be resolved and the map exists) combined with the material
|
||||
/// scalars per the URP/Lit conventions, with scalar fallbacks everywhere else.
|
||||
/// </summary>
|
||||
private bool PickFromForeignMaterial(RaycastHit hit)
|
||||
{
|
||||
Collider col = hit.collider;
|
||||
if (!col) return false;
|
||||
Renderer rend = col.GetComponent<Renderer>();
|
||||
if (!rend) rend = col.GetComponentInParent<Renderer>();
|
||||
if (!rend) return false;
|
||||
|
||||
// hit.textureCoord is only populated for a NON-convex MeshCollider (convex/primitive colliders
|
||||
// return zero) - recover the UV from the renderer's own mesh then. The hit SUBMESH is resolved
|
||||
// alongside so a multi-material renderer reads the covering sub-material, not always slot 0.
|
||||
Vector2 uv;
|
||||
bool hasUv;
|
||||
int subMesh;
|
||||
if (col is MeshCollider mc && !mc.convex && hit.triangleIndex >= 0)
|
||||
{
|
||||
uv = hit.textureCoord;
|
||||
hasUv = true;
|
||||
subMesh = SubMeshFromIndex(mc.sharedMesh, hit.triangleIndex * 3);
|
||||
}
|
||||
else
|
||||
{
|
||||
hasUv = TryRecoverMeshUv(rend, hit, out uv, out subMesh);
|
||||
}
|
||||
|
||||
// One material per submesh: pick the slot the hit submesh maps to (clamped, so a renderer with
|
||||
// fewer materials than submeshes still reads). Reads the shared material - never instances it.
|
||||
Material[] mats = rend.sharedMaterials;
|
||||
Material mat = (mats != null && mats.Length > 0)
|
||||
? mats[Mathf.Clamp(subMesh, 0, mats.Length - 1)]
|
||||
: null;
|
||||
if (!mat) return false;
|
||||
|
||||
// Base map but no UV resolvable: defer to the on-screen pixel (which at least reflects the
|
||||
// texture) rather than silently picking the flat _BaseColor.
|
||||
if (!hasUv && GetTexProp(mat, "_BaseMap", "_MainTex")) return false;
|
||||
|
||||
// --- Albedo: BaseMap (sRGB) * BaseColor ---
|
||||
Color baseCol = GetColorProp(mat, "_BaseColor", "_Color", Color.white);
|
||||
Texture baseTex = GetTexProp(mat, "_BaseMap", "_MainTex");
|
||||
Color albedo = baseCol;
|
||||
if (hasUv && baseTex)
|
||||
albedo = SampleTexel(baseTex, ApplyST(mat, "_BaseMap", "_MainTex", uv), true) * baseCol;
|
||||
AdoptAlbedo(albedo);
|
||||
|
||||
// --- Metallic / Smoothness: MetallicGloss map (linear) ---
|
||||
// URP/Lit: metallic = map.r ; smoothness = map.a * _Smoothness (or _Metallic/_Glossiness scalars).
|
||||
float metallicVal = GetFloatProp(mat, "_Metallic", null, 0f);
|
||||
float smoothVal = GetFloatProp(mat, "_Smoothness", "_Glossiness", 0.5f);
|
||||
Texture mgTex = GetTexProp(mat, "_MetallicGlossMap", null);
|
||||
if (hasUv && mgTex)
|
||||
{
|
||||
Color mg = SampleTexel(mgTex, ApplyST(mat, "_MetallicGlossMap", null, uv), false);
|
||||
metallicVal = mg.r;
|
||||
smoothVal = mg.a * smoothVal;
|
||||
}
|
||||
metallic = Mathf.Clamp01(metallicVal);
|
||||
smoothness = Mathf.Clamp01(smoothVal);
|
||||
|
||||
// Occlusion is deliberately NOT sampled (that channel drives painted height now, see FinalizePick).
|
||||
|
||||
// --- Emission: EmissionMap (sRGB) * EmissionColor ---
|
||||
Color emColor = GetColorProp(mat, "_EmissionColor", null, Color.black);
|
||||
Texture emTex = GetTexProp(mat, "_EmissionMap", null);
|
||||
Color emCol = emColor;
|
||||
if (hasUv && emTex)
|
||||
emCol = SampleTexel(emTex, ApplyST(mat, "_EmissionMap", null, uv), true) * emColor;
|
||||
emissionColor = new Color(Mathf.Clamp01(emCol.r), Mathf.Clamp01(emCol.g), Mathf.Clamp01(emCol.b), emissionColor.a);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Picks at a terrain hit. The TerrainCollider also carries the painted TREES (which sit above
|
||||
/// the heightfield and expose no per-instance renderer/UV here), and grass/mushroom DETAILS have
|
||||
/// no collider at all:
|
||||
/// - hit above the surface -> a tree: adopt the rendered on-screen colour;
|
||||
/// - hit on a cell that has grass/mushroom detail -> adopt the on-screen colour;
|
||||
/// - otherwise (bare ground) -> blend the terrain layers by their splat weights (full PBR).
|
||||
/// </summary>
|
||||
private bool PickFromTerrain(Terrain terrain, RaycastHit hit, Vector2 screenPos)
|
||||
{
|
||||
TerrainData td = terrain.terrainData;
|
||||
if (!td) return false;
|
||||
Vector3 tpos = terrain.transform.position;
|
||||
Vector3 size = td.size;
|
||||
if (size.x <= 1e-4f || size.z <= 1e-4f) return false;
|
||||
|
||||
// Trees share the terrain collider but stand proud of the heightfield.
|
||||
float surfaceY = terrain.SampleHeight(hit.point) + tpos.y;
|
||||
if (hit.point.y > surfaceY + TerrainFoliageHeightEpsilon)
|
||||
return PickFromScreen(screenPos);
|
||||
|
||||
Vector3 local = hit.point - tpos;
|
||||
float nx = Mathf.Clamp01(local.x / size.x);
|
||||
float nz = Mathf.Clamp01(local.z / size.z);
|
||||
|
||||
// Grass/mushroom detail rendered on this ground cell -> what's visible there is the
|
||||
// foliage (which has no collider of its own), so trust the screen pixel.
|
||||
if (TerrainHasDetail(td, nx, nz))
|
||||
return PickFromScreen(screenPos);
|
||||
|
||||
return PickFromTerrainLayers(td, local, nx, nz);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Blends the terrain's layers at a ground point by their splat (alphamap) weights: the weighted
|
||||
/// diffuse colour becomes albedo and the weighted per-layer metallic/smoothness scalars feed
|
||||
/// those channels; emission is black (occlusion is left as the brush's height value). Terrain has no Renderer/material, so this
|
||||
/// is the only way to read its surface.
|
||||
/// </summary>
|
||||
private bool PickFromTerrainLayers(TerrainData td, Vector3 local, float nx, float nz)
|
||||
{
|
||||
int aw = td.alphamapWidth, ah = td.alphamapHeight;
|
||||
int mapX = Mathf.Clamp(Mathf.FloorToInt(nx * aw), 0, aw - 1);
|
||||
int mapZ = Mathf.Clamp(Mathf.FloorToInt(nz * ah), 0, ah - 1);
|
||||
float[,,] weights = td.GetAlphamaps(mapX, mapZ, 1, 1);
|
||||
|
||||
TerrainLayer[] layers = td.terrainLayers;
|
||||
int count = Mathf.Min(layers.Length, weights.GetLength(2));
|
||||
Color albedo = Color.black;
|
||||
float metallicVal = 0f, smoothVal = 0f, weightSum = 0f;
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
TerrainLayer L = layers[i];
|
||||
if (L == null) continue;
|
||||
float w = weights[0, 0, i];
|
||||
if (w <= 0f) continue;
|
||||
|
||||
Color col = Color.gray;
|
||||
if (L.diffuseTexture)
|
||||
{
|
||||
// Terrain tiles each layer across the world by tileSize (metres); sample that texel.
|
||||
Vector2 uv = new Vector2(
|
||||
(local.x - L.tileOffset.x) / Mathf.Max(1e-4f, L.tileSize.x),
|
||||
(local.z - L.tileOffset.y) / Mathf.Max(1e-4f, L.tileSize.y));
|
||||
col = SampleTexel(L.diffuseTexture, uv, true);
|
||||
}
|
||||
albedo += col * w;
|
||||
metallicVal += L.metallic * w;
|
||||
smoothVal += L.smoothness * w;
|
||||
weightSum += w;
|
||||
}
|
||||
if (weightSum <= 1e-4f) return false;
|
||||
float inv = 1f / weightSum; // alphamap weights normally sum to 1, but guard anyway
|
||||
|
||||
AdoptAlbedo(albedo * inv);
|
||||
metallic = Mathf.Clamp01(metallicVal * inv);
|
||||
smoothness = Mathf.Clamp01(smoothVal * inv);
|
||||
ClearEmissionRgb(); // occlusion left untouched (now painted height)
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when any grass/mushroom detail layer has coverage at the given normalized terrain
|
||||
/// position - i.e. foliage (which carries no collider) is rendered on that ground cell.
|
||||
/// </summary>
|
||||
private static bool TerrainHasDetail(TerrainData td, float nx, float nz)
|
||||
{
|
||||
int dw = td.detailWidth, dh = td.detailHeight;
|
||||
if (dw <= 0 || dh <= 0) return false;
|
||||
int dx = Mathf.Clamp(Mathf.FloorToInt(nx * dw), 0, dw - 1);
|
||||
int dy = Mathf.Clamp(Mathf.FloorToInt(nz * dh), 0, dh - 1);
|
||||
int layerCount = td.detailPrototypes.Length;
|
||||
for (int i = 0; i < layerCount; i++)
|
||||
{
|
||||
int[,] d = td.GetDetailLayer(dx, dy, 1, 1, i);
|
||||
if (d[0, 0] > 0) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Fallback colour pick: reads the rendered pixel under the cursor (the paint camera re-rendered
|
||||
/// to a temporary target, so it works on either render pipeline). Used for surfaces that can't be
|
||||
/// sampled texel-exact - terrain trees and grass/mushroom details (no per-instance renderer/UV)
|
||||
/// and anything hit without a readable material, plus rays that miss all geometry. Only albedo is
|
||||
/// meaningful from a screen pixel; the data channels default to matte/unoccluded/no-emission.
|
||||
/// </summary>
|
||||
private bool PickFromScreen(Vector2 screenPos)
|
||||
{
|
||||
if (!TrySampleScreenColor(screenPos, out Color c)) return false;
|
||||
|
||||
AdoptAlbedo(c);
|
||||
metallic = 0f;
|
||||
smoothness = 0f;
|
||||
ClearEmissionRgb(); // occlusion left untouched (now painted height)
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Reads the rendered pixel under the cursor (the paint camera re-rendered to a temporary target,
|
||||
/// so it works on either render pipeline) without touching any brush channel. Returns the fully-lit
|
||||
/// on-screen colour - what is visible, including baked lightmaps/ambient/SSAO. False when no camera
|
||||
/// is bound (headless). Shared by <see cref="PickFromScreen"/> and the hybrid albedo override.
|
||||
/// </summary>
|
||||
private bool TrySampleScreenColor(Vector2 screenPos, out Color color)
|
||||
{
|
||||
color = default;
|
||||
Camera cam = paintCamera;
|
||||
if (!cam) return false;
|
||||
int w = Mathf.Max(1, cam.pixelWidth), h = Mathf.Max(1, cam.pixelHeight);
|
||||
|
||||
RenderTexture rt = RenderTexture.GetTemporary(w, h, 24, RenderTextureFormat.ARGB32, RenderTextureReadWrite.sRGB);
|
||||
RenderTexture prevTarget = cam.targetTexture;
|
||||
RenderTexture prevActive = RenderTexture.active;
|
||||
cam.targetTexture = rt;
|
||||
cam.Render();
|
||||
RenderTexture.active = rt;
|
||||
|
||||
var tmp = new Texture2D(1, 1, TextureFormat.RGBA32, false, false);
|
||||
int px = Mathf.Clamp(Mathf.RoundToInt(screenPos.x), 0, w - 1);
|
||||
int py = Mathf.Clamp(Mathf.RoundToInt(screenPos.y), 0, h - 1);
|
||||
tmp.ReadPixels(new Rect(px, py, 1, 1), 0, 0, false);
|
||||
tmp.Apply(false);
|
||||
color = tmp.GetPixel(0, 0);
|
||||
|
||||
cam.targetTexture = prevTarget;
|
||||
RenderTexture.active = prevActive;
|
||||
RenderTexture.ReleaseTemporary(rt);
|
||||
if (Application.isPlaying) Destroy(tmp); else DestroyImmediate(tmp);
|
||||
return true;
|
||||
}
|
||||
|
||||
// --- Material read helpers (name-tolerant: URP and legacy Standard) ---
|
||||
|
||||
private static Color GetColorProp(Material m, string nameA, string nameB, Color fallback)
|
||||
{
|
||||
if (m.HasProperty(nameA)) return m.GetColor(nameA);
|
||||
if (nameB != null && m.HasProperty(nameB)) return m.GetColor(nameB);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static float GetFloatProp(Material m, string nameA, string nameB, float fallback)
|
||||
{
|
||||
if (m.HasProperty(nameA)) return m.GetFloat(nameA);
|
||||
if (nameB != null && m.HasProperty(nameB)) return m.GetFloat(nameB);
|
||||
return fallback;
|
||||
}
|
||||
|
||||
private static Texture GetTexProp(Material m, string nameA, string nameB)
|
||||
{
|
||||
if (m.HasProperty(nameA)) { Texture t = m.GetTexture(nameA); if (t) return t; }
|
||||
if (nameB != null && m.HasProperty(nameB)) { Texture t = m.GetTexture(nameB); if (t) return t; }
|
||||
return null;
|
||||
}
|
||||
|
||||
private static Vector2 ApplyST(Material m, string nameA, string nameB, Vector2 uv)
|
||||
{
|
||||
string prop = m.HasProperty(nameA) ? nameA : (nameB != null && m.HasProperty(nameB) ? nameB : null);
|
||||
if (prop == null) return uv;
|
||||
Vector2 s = m.GetTextureScale(prop);
|
||||
Vector2 o = m.GetTextureOffset(prop);
|
||||
return new Vector2(uv.x * s.x + o.x, uv.y * s.y + o.y);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Samples a single texel from a texture at a UV (texel-exact). Uses GetPixelBilinear when the
|
||||
/// texture is CPU-readable; otherwise blits it to a temporary RenderTexture and reads back the
|
||||
/// one texel, so it works regardless of the texture's "Read/Write" import flag. <paramref
|
||||
/// name="srgb"/> selects the colour space so the returned value matches the readable path
|
||||
/// (gamma for colour maps, linear for data maps).
|
||||
/// </summary>
|
||||
private static Color SampleTexel(Texture tex, Vector2 uv, bool srgb)
|
||||
{
|
||||
Texture2D t2d = tex as Texture2D;
|
||||
if (t2d != null && t2d.isReadable)
|
||||
return t2d.GetPixelBilinear(uv.x, uv.y);
|
||||
|
||||
int w = Mathf.Max(1, tex.width), h = Mathf.Max(1, tex.height);
|
||||
var rw = srgb ? RenderTextureReadWrite.sRGB : RenderTextureReadWrite.Linear;
|
||||
RenderTexture rt = RenderTexture.GetTemporary(w, h, 0, RenderTextureFormat.ARGB32, rw);
|
||||
RenderTexture prev = RenderTexture.active;
|
||||
Graphics.Blit(tex, rt);
|
||||
RenderTexture.active = rt;
|
||||
|
||||
var tmp = new Texture2D(1, 1, TextureFormat.RGBA32, false, !srgb);
|
||||
int px = Mathf.Clamp(Mathf.FloorToInt(Mathf.Repeat(uv.x, 1f) * w), 0, w - 1);
|
||||
int py = Mathf.Clamp(Mathf.FloorToInt(Mathf.Repeat(uv.y, 1f) * h), 0, h - 1);
|
||||
tmp.ReadPixels(new Rect(px, py, 1, 1), 0, 0, false);
|
||||
tmp.Apply(false);
|
||||
Color c = tmp.GetPixel(0, 0);
|
||||
|
||||
RenderTexture.active = prev;
|
||||
RenderTexture.ReleaseTemporary(rt);
|
||||
if (Application.isPlaying) Destroy(tmp); else DestroyImmediate(tmp);
|
||||
return c;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Recovers the UV at a hit point by intersecting the camera ray with the renderer's own mesh
|
||||
/// (Moller-Trumbore against each triangle, nearest hit wins). Needed when the collider that was
|
||||
/// hit cannot supply hit.textureCoord - a convex MeshCollider or any primitive collider. Requires
|
||||
/// a MeshFilter whose mesh is readable and UV-mapped; returns false otherwise so the caller can
|
||||
/// fall back. A one-shot click cost, so the O(triangles) sweep is acceptable.
|
||||
/// </summary>
|
||||
private bool TryRecoverMeshUv(Renderer rend, RaycastHit hit, out Vector2 uv, out int subMesh)
|
||||
{
|
||||
uv = default;
|
||||
subMesh = 0;
|
||||
if (!rend) return false;
|
||||
MeshFilter mf = rend.GetComponent<MeshFilter>();
|
||||
Mesh mesh = mf ? mf.sharedMesh : null;
|
||||
if (!mesh || !mesh.isReadable) return false;
|
||||
|
||||
Vector2[] uvs = mesh.uv;
|
||||
if (uvs == null || uvs.Length == 0) return false;
|
||||
Vector3[] verts = mesh.vertices;
|
||||
int[] tris = mesh.triangles;
|
||||
|
||||
// Ray from the paint camera through the surface point (its UV is what we want). Fall back to
|
||||
// shooting along the inverted hit normal when no camera is bound.
|
||||
Vector3 ro, rd;
|
||||
if (paintCamera)
|
||||
{
|
||||
ro = paintCamera.transform.position;
|
||||
rd = (hit.point - ro).normalized;
|
||||
}
|
||||
else
|
||||
{
|
||||
rd = -hit.normal;
|
||||
ro = hit.point - rd * 0.01f;
|
||||
}
|
||||
|
||||
Matrix4x4 l2w = rend.transform.localToWorldMatrix;
|
||||
float bestT = float.MaxValue;
|
||||
Vector3 bestBary = default;
|
||||
int bestTri = -1;
|
||||
for (int i = 0; i + 2 < tris.Length; i += 3)
|
||||
{
|
||||
Vector3 a = l2w.MultiplyPoint3x4(verts[tris[i]]);
|
||||
Vector3 b = l2w.MultiplyPoint3x4(verts[tris[i + 1]]);
|
||||
Vector3 c = l2w.MultiplyPoint3x4(verts[tris[i + 2]]);
|
||||
if (RayTriangle(ro, rd, a, b, c, out float t, out float u, out float v) && t < bestT)
|
||||
{
|
||||
bestT = t;
|
||||
bestBary = new Vector3(1f - u - v, u, v);
|
||||
bestTri = i;
|
||||
}
|
||||
}
|
||||
if (bestTri < 0) return false;
|
||||
|
||||
uv = uvs[tris[bestTri]] * bestBary.x + uvs[tris[bestTri + 1]] * bestBary.y + uvs[tris[bestTri + 2]] * bestBary.z;
|
||||
subMesh = SubMeshFromIndex(mesh, bestTri); // bestTri is the offset into the flattened index buffer
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolves which submesh a triangle belongs to, given its offset into the mesh's flattened index
|
||||
/// buffer (triangleNumber * 3, matching the <see cref="Mesh.triangles"/> layout). Submesh index then
|
||||
/// maps 1:1 onto the renderer's material slots. Returns 0 for a single-material mesh (the common
|
||||
/// case, no descriptor lookup) or when the mesh is missing.
|
||||
/// </summary>
|
||||
private static int SubMeshFromIndex(Mesh mesh, int indexOffset)
|
||||
{
|
||||
if (!mesh) return 0;
|
||||
int subCount = mesh.subMeshCount;
|
||||
if (subCount <= 1) return 0;
|
||||
for (int s = 0; s < subCount; s++)
|
||||
{
|
||||
UnityEngine.Rendering.SubMeshDescriptor d = mesh.GetSubMesh(s);
|
||||
if (indexOffset >= d.indexStart && indexOffset < d.indexStart + d.indexCount)
|
||||
return s;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Moller-Trumbore ray/triangle intersection. On a hit, <paramref name="t"/> is the ray distance
|
||||
/// and (<paramref name="u"/>, <paramref name="v"/>) are the barycentric weights of vertices b and
|
||||
/// c (vertex a's weight is 1 - u - v). Small epsilons admit edge hits.
|
||||
/// </summary>
|
||||
private static bool RayTriangle(Vector3 ro, Vector3 rd, Vector3 a, Vector3 b, Vector3 c,
|
||||
out float t, out float u, out float v)
|
||||
{
|
||||
t = u = v = 0f;
|
||||
const float eps = 1e-7f;
|
||||
Vector3 e1 = b - a, e2 = c - a;
|
||||
Vector3 p = Vector3.Cross(rd, e2);
|
||||
float det = Vector3.Dot(e1, p);
|
||||
if (det > -eps && det < eps) return false; // ray parallel to the triangle
|
||||
float inv = 1f / det;
|
||||
Vector3 tvec = ro - a;
|
||||
u = Vector3.Dot(tvec, p) * inv;
|
||||
if (u < -1e-4f || u > 1f + 1e-4f) return false;
|
||||
Vector3 q = Vector3.Cross(tvec, e1);
|
||||
v = Vector3.Dot(rd, q) * inv;
|
||||
if (v < -1e-4f || u + v > 1f + 1e-4f) return false;
|
||||
t = Vector3.Dot(e2, q) * inv;
|
||||
return t > eps;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 81a936ee72b5dab45b6c0f63237602cc
|
||||
@@ -0,0 +1,292 @@
|
||||
using UnityEngine;
|
||||
|
||||
public partial class MeshPainter
|
||||
{
|
||||
// --- Setup (shared) ---
|
||||
|
||||
private void SetupCollider()
|
||||
{
|
||||
_collider = GetComponent<MeshCollider>();
|
||||
if (!_collider) _collider = gameObject.AddComponent<MeshCollider>();
|
||||
_collider.convex = false; // non-convex provides triangleIndex / textureCoord
|
||||
|
||||
// An always-on SkinnedColliderBaker (skinned rigs only) owns the bake even outside paint
|
||||
// mode; we delegate to it and only borrow its mesh. Without one we keep our own legacy bake.
|
||||
_baker = _skinned ? GetComponent<SkinnedColliderBaker>() : null;
|
||||
if (!_baker) _bakedMesh = new Mesh { name = "MeshPainter_Baked" };
|
||||
BakeColliderMesh();
|
||||
}
|
||||
|
||||
private void BakeColliderMesh()
|
||||
{
|
||||
if (_baker)
|
||||
{
|
||||
// Baker already baked (negative execution order) and keeps _collider.sharedMesh fresh;
|
||||
// we only track its mesh for the GPU raster path. No bake here - that's the whole point.
|
||||
_bakedMesh = _baker.BakedMesh;
|
||||
}
|
||||
else 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;
|
||||
}
|
||||
}
|
||||
|
||||
// BuildMeshCaches scratch: refilled per bake without allocating (with updateColliderEveryFrame this
|
||||
// runs per frame while painting; mesh.vertices/.triangles/.uv return fresh arrays per call).
|
||||
private readonly System.Collections.Generic.List<Vector3> _vertScratch = new System.Collections.Generic.List<Vector3>();
|
||||
private int _meshCacheId; // mesh instance the topology/UV caches were built for (0 = none)
|
||||
|
||||
/// <summary>Reads vertices/triangles/UVs of the collider mesh and computes culling data.
|
||||
/// Only the vertices change across re-bakes of the same mesh; topology/UVs are fetched once.</summary>
|
||||
private void BuildMeshCaches()
|
||||
{
|
||||
Mesh m = _collider.sharedMesh;
|
||||
if (!m) return;
|
||||
|
||||
m.GetVertices(_vertScratch);
|
||||
int vCount = _vertScratch.Count;
|
||||
if (_verts == null || _verts.Length != vCount) _verts = new Vector3[vCount];
|
||||
_vertScratch.CopyTo(_verts);
|
||||
|
||||
if (_meshCacheId != m.GetInstanceID())
|
||||
{
|
||||
_meshCacheId = m.GetInstanceID();
|
||||
_tris = m.triangles;
|
||||
_uvs = m.uv;
|
||||
// Fallback to the original mesh UVs if the baked mesh dropped them.
|
||||
if ((_uvs == null || _uvs.Length != vCount) && _skinned && _skinned.sharedMesh)
|
||||
_uvs = _skinned.sharedMesh.uv;
|
||||
}
|
||||
|
||||
if (_uvs == null || _uvs.Length != _verts.Length) return;
|
||||
|
||||
int triCount = _tris.Length / 3;
|
||||
if (_triCenter == null || _triCenter.Length != triCount)
|
||||
{
|
||||
_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>
|
||||
/// Rasterizes the mesh UV islands into a per-texel coverage marker - the dilation SOURCE / overwrite
|
||||
/// guard (island texels are real content edge padding must never overwrite; the gutter stays
|
||||
/// paddable). Derived from the STATIC UV layout instead of transient per-stroke coverage, so every
|
||||
/// seed (fresh start, load, backend switch) marks the same content and the first stroke after a
|
||||
/// load/switch no longer bleeds over existing paint. Built once and cached.
|
||||
/// </summary>
|
||||
private bool[] GetIslandMask()
|
||||
{
|
||||
int n = textureSize * textureSize;
|
||||
if (_islandMask != null && _islandMask.Length == n) return _islandMask;
|
||||
|
||||
var mask = new bool[n];
|
||||
if (_tris == null || _uvs == null) { _islandMask = mask; return mask; }
|
||||
|
||||
float ts = textureSize;
|
||||
const float eps = -1e-4f; // matches PaintStamp3D: tolerate 1px cracks between triangles
|
||||
int triCount = _tris.Length / 3;
|
||||
|
||||
for (int t = 0; t < triCount; t++)
|
||||
{
|
||||
int i0 = _tris[t * 3], i1 = _tris[t * 3 + 1], i2 = _tris[t * 3 + 2];
|
||||
Vector2 uv0 = _uvs[i0], uv1 = _uvs[i1], uv2 = _uvs[i2];
|
||||
|
||||
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;
|
||||
|
||||
float d = (p1y - p2y) * (p0x - p2x) + (p2x - p1x) * (p0y - p2y);
|
||||
if (Mathf.Abs(d) < 1e-7f) continue;
|
||||
float invD = 1f / d;
|
||||
|
||||
for (int y = minY; y <= maxY; y++)
|
||||
{
|
||||
float py = y + 0.5f;
|
||||
int row = 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;
|
||||
if (1f - a - b < eps) continue;
|
||||
mask[row + x] = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
_islandMask = mask;
|
||||
return mask;
|
||||
}
|
||||
|
||||
/// <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();
|
||||
|
||||
// Mask map seed from the material scalars: R = metallic, G = occlusion/height, B = 0,
|
||||
// A = smoothness. G = 128 (= 0.5) is the NEUTRAL height: no parallax/relief and AO =
|
||||
// saturate(2*0.5) = 1 (no darkening), so the unpainted look is unchanged. Painting G
|
||||
// below 0.5 carves a hole (and darkens), above 0.5 raises a hill (see PlayerCamoLit).
|
||||
float metallic0 = GetMatFloat(MetallicId, 0f);
|
||||
float smoothness0 = GetMatFloat(SmoothnessId, 0.5f);
|
||||
_seedCanonical[MAP_MSO] = FillCanonical(new Color32(
|
||||
(byte)(Mathf.Clamp01(metallic0) * 255f), 128, 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()
|
||||
{
|
||||
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 */ }
|
||||
}
|
||||
|
||||
return FillCanonical(baseColor);
|
||||
}
|
||||
|
||||
/// <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");
|
||||
SetMatFloat(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;
|
||||
_lastEmissionIntensity = float.NaN; // fresh material instance -> force the first write
|
||||
RefreshEmissionColor();
|
||||
}
|
||||
|
||||
// Occlusion map: URP samples the G channel (LerpWhiteTo(map.g, _OcclusionStrength)).
|
||||
// Strength 1 so the painted value drives the occlusion as-is.
|
||||
if (_materialInstance.HasProperty(OcclusionMapId))
|
||||
{
|
||||
_materialInstance.EnableKeyword("_OCCLUSIONMAP");
|
||||
SetMatFloat(OcclusionStrengthId, 1f);
|
||||
}
|
||||
|
||||
// Height: the same mask map is also bound to _ParallaxMap, so the painted occlusion (G) doubles
|
||||
// as a 0.5-centred height. _PARALLAXMAP enables URP's parallax AND our relief-normal/AO remap in
|
||||
// PlayerCamoLit; _Parallax is lifted off its floor so the displacement is visible. _NORMALMAP is
|
||||
// REQUIRED for the relief normal to show - URP only transforms normalTS into world space when it
|
||||
// (or _DETAIL) is set, otherwise the relief is invisible. _BumpMap stays unbound (flat default
|
||||
// normal), so the keyword only enables the tangent->world path.
|
||||
if (_materialInstance.HasProperty(ParallaxMapId))
|
||||
{
|
||||
_materialInstance.EnableKeyword("_PARALLAXMAP");
|
||||
_materialInstance.EnableKeyword("_NORMALMAP");
|
||||
if (GetMatFloat(ParallaxId, 0.02f) < 0.02f)
|
||||
SetMatFloat(ParallaxId, 0.02f);
|
||||
}
|
||||
}
|
||||
|
||||
private float _lastEmissionIntensity = float.NaN;
|
||||
|
||||
/// <summary>Pushes the global emission intensity to the material's _EmissionColor (live-tunable).
|
||||
/// Runs per frame, so it only writes the material when the intensity actually changed.</summary>
|
||||
private void RefreshEmissionColor()
|
||||
{
|
||||
if (emissionIntensity == _lastEmissionIntensity) return;
|
||||
if (_materialInstance && _materialInstance.HasProperty(EmissionColorId))
|
||||
{
|
||||
_materialInstance.SetColor(EmissionColorId, Color.white * Mathf.Max(0f, emissionIntensity));
|
||||
_lastEmissionIntensity = emissionIntensity;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Eases the see-through X-ray glow's alpha toward its on/off target (<see cref="XRayRevealed"/>)
|
||||
/// at a constant rate, so the reveal fades in/out over <c>xrayFadeDuration</c> seconds instead of popping.
|
||||
/// Rgb is left untouched (the authored glow colour). Only writes the material on an actual change, and
|
||||
/// snaps instantly when the duration is 0.</summary>
|
||||
private void TickXRayFade()
|
||||
{
|
||||
if (!_materialInstance || !_materialInstance.HasProperty(XRayColorId)) return;
|
||||
Color c = _materialInstance.GetColor(XRayColorId);
|
||||
float step = xrayFadeDuration > 0f ? Time.deltaTime / xrayFadeDuration : 1f;
|
||||
float a = Mathf.MoveTowards(c.a, _xrayTarget, step);
|
||||
if (a == c.a) return; // already at target -> no material write
|
||||
c.a = a;
|
||||
_materialInstance.SetColor(XRayColorId, c);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f3dd1a2d423bb5a46931b0e689fa779a
|
||||
@@ -0,0 +1,157 @@
|
||||
using UnityEngine;
|
||||
|
||||
public partial class MeshPainter
|
||||
{
|
||||
// --- Spray can brush ---
|
||||
//
|
||||
// Instead of one solid stamp, the spray scatters many small dots (random position,
|
||||
// size and alpha) within the brush footprint. It is implemented by baking those dots
|
||||
// into a fresh procedural MASK each stamp and then painting that mask with ONE normal
|
||||
// brush stamp. This reuses the entire seam-bridging / curvature-following / per-channel
|
||||
// pipeline as-is and costs a single mesh draw per stamp (not one per dot). Because the
|
||||
// per-dot alpha lives in the mask value, it flows into every channel (albedo, mask map,
|
||||
// emission) automatically - on both the GPU and CPU backends.
|
||||
|
||||
[Header("Brush - spray can")]
|
||||
[Tooltip("Spray-can mode: scatter random dots (position, size, alpha) within the brush " +
|
||||
"radius instead of a single solid stamp. The dots are baked into the brush mask, " +
|
||||
"so this works on all channels and both backends.")]
|
||||
public bool spray = false;
|
||||
|
||||
[Tooltip("Number of dots scattered per stamp.")]
|
||||
[Range(1, 128)]
|
||||
public int sprayDots = 24;
|
||||
|
||||
[Tooltip("Dot diameter as a fraction of the brush diameter (randomized per dot between min and max).")]
|
||||
[Range(0.01f, 1f)]
|
||||
public float sprayDotSizeMin = 0.05f;
|
||||
|
||||
[Tooltip("Dot diameter as a fraction of the brush diameter (randomized per dot between min and max).")]
|
||||
[Range(0.01f, 1f)]
|
||||
public float sprayDotSizeMax = 0.18f;
|
||||
|
||||
[Tooltip("Per-dot opacity (randomized per dot between min and max). Multiplied on top of the channel color's alpha.")]
|
||||
[Range(0f, 1f)]
|
||||
public float sprayDotAlphaMin = 0.25f;
|
||||
|
||||
[Tooltip("Per-dot opacity (randomized per dot between min and max). Multiplied on top of the channel color's alpha.")]
|
||||
[Range(0f, 1f)]
|
||||
public float sprayDotAlphaMax = 1f;
|
||||
|
||||
// Resolution of the per-stamp spray mask (square). Small + reused so regenerating it
|
||||
// every stamp stays cheap.
|
||||
private const int SprayRes = 128;
|
||||
private const float SprayDotSoftEdge = 0.4f; // fraction of the dot radius that fades to 0
|
||||
|
||||
private Texture2D _sprayTex; // the baked spray mask uploaded to the brush
|
||||
private Color32[] _sprayPixels; // reused upload buffer
|
||||
private float[] _sprayOpacity; // reused per-texel opacity (CPU sampling)
|
||||
|
||||
// --- Active stamp mask (indirection shared by both backends) ---
|
||||
// The standard mask cache (_resolvedMask / _maskOpacity / _maskW / _maskH) stays the
|
||||
// canonical resolved mask used by the preview. _stamp* is what the CURRENT stamp paints
|
||||
// with - the resolved mask for normal painting, or a fresh spray mask in spray mode.
|
||||
private Texture2D _stampMask;
|
||||
private float[] _stampOpacity;
|
||||
private int _stampW, _stampH;
|
||||
|
||||
/// <summary>Points the active stamp mask at the resolved standard mask (normal painting).</summary>
|
||||
private void UseResolvedStampMask()
|
||||
{
|
||||
_stampMask = _resolvedMask;
|
||||
_stampOpacity = _maskOpacity;
|
||||
_stampW = _maskW;
|
||||
_stampH = _maskH;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Bakes a fresh random spray pattern into the spray mask and makes it the active stamp
|
||||
/// mask. Each dot gets a random center inside the brush disc, a random diameter and a
|
||||
/// random opacity; dots composite by max so overlaps never exceed full opacity.
|
||||
/// </summary>
|
||||
private void BuildSprayStampMask()
|
||||
{
|
||||
EnsureSprayBuffers();
|
||||
|
||||
System.Array.Clear(_sprayOpacity, 0, _sprayOpacity.Length);
|
||||
|
||||
int dots = Mathf.Max(1, sprayDots);
|
||||
float sizeLo = Mathf.Clamp(Mathf.Min(sprayDotSizeMin, sprayDotSizeMax), 0.01f, 1f);
|
||||
float sizeHi = Mathf.Clamp(Mathf.Max(sprayDotSizeMin, sprayDotSizeMax), 0.01f, 1f);
|
||||
float alphaLo = Mathf.Clamp01(Mathf.Min(sprayDotAlphaMin, sprayDotAlphaMax));
|
||||
float alphaHi = Mathf.Clamp01(Mathf.Max(sprayDotAlphaMin, sprayDotAlphaMax));
|
||||
|
||||
for (int k = 0; k < dots; k++)
|
||||
{
|
||||
// Diameter is a fraction of the brush diameter -> uv radius = fraction * 0.5.
|
||||
float dotR = Mathf.Max(0.5f / SprayRes, Random.Range(sizeLo, sizeHi) * 0.5f);
|
||||
float alpha = Random.Range(alphaLo, alphaHi);
|
||||
// Keep the WHOLE dot inside the brush disc (uv radius 0.5 = the brush radius) so the
|
||||
// spray footprint matches the normal brush instead of bleeding past it by dotR.
|
||||
float centerR = Mathf.Max(0f, 0.5f - dotR);
|
||||
Vector2 c = Random.insideUnitCircle * centerR + new Vector2(0.5f, 0.5f);
|
||||
|
||||
SplatDot(c.x, c.y, dotR, alpha);
|
||||
}
|
||||
|
||||
for (int i = 0; i < _sprayOpacity.Length; i++)
|
||||
{
|
||||
byte g = (byte)(Mathf.Clamp01(_sprayOpacity[i]) * 255f + 0.5f);
|
||||
_sprayPixels[i] = new Color32(g, g, g, 255);
|
||||
}
|
||||
_sprayTex.SetPixels32(_sprayPixels);
|
||||
_sprayTex.Apply(false);
|
||||
|
||||
_stampMask = _sprayTex;
|
||||
_stampOpacity = _sprayOpacity;
|
||||
_stampW = SprayRes;
|
||||
_stampH = SprayRes;
|
||||
}
|
||||
|
||||
/// <summary>Rasterizes one soft dot (center/radius in uv, value = alpha) into the opacity buffer, compositing by max.</summary>
|
||||
private void SplatDot(float cx, float cy, float r, float alpha)
|
||||
{
|
||||
if (alpha <= 0f) return;
|
||||
|
||||
float inner = 1f - SprayDotSoftEdge; // solid core up to this normalized distance, then fade
|
||||
int minX = Mathf.Max(0, Mathf.FloorToInt((cx - r) * SprayRes));
|
||||
int maxX = Mathf.Min(SprayRes - 1, Mathf.CeilToInt((cx + r) * SprayRes));
|
||||
int minY = Mathf.Max(0, Mathf.FloorToInt((cy - r) * SprayRes));
|
||||
int maxY = Mathf.Min(SprayRes - 1, Mathf.CeilToInt((cy + r) * SprayRes));
|
||||
float invR = 1f / r;
|
||||
|
||||
for (int y = minY; y <= maxY; y++)
|
||||
{
|
||||
float vY = (y + 0.5f) / SprayRes;
|
||||
int row = y * SprayRes;
|
||||
for (int x = minX; x <= maxX; x++)
|
||||
{
|
||||
float vX = (x + 0.5f) / SprayRes;
|
||||
float dx = vX - cx, dy = vY - cy;
|
||||
float dist = Mathf.Sqrt(dx * dx + dy * dy) * invR; // 0 = center, 1 = edge
|
||||
if (dist >= 1f) continue;
|
||||
|
||||
float falloff = dist <= inner ? 1f : 1f - Mathf.SmoothStep(inner, 1f, dist);
|
||||
float val = alpha * falloff;
|
||||
int idx = row + x;
|
||||
if (val > _sprayOpacity[idx]) _sprayOpacity[idx] = val;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private void EnsureSprayBuffers()
|
||||
{
|
||||
if (_sprayTex) return;
|
||||
|
||||
_sprayTex = NewMaskTexture(SprayRes, "MeshPainter_SprayMask");
|
||||
_sprayPixels = new Color32[SprayRes * SprayRes];
|
||||
_sprayOpacity = new float[SprayRes * SprayRes];
|
||||
}
|
||||
|
||||
private void TeardownSpray()
|
||||
{
|
||||
if (_sprayTex) { Destroy(_sprayTex); _sprayTex = null; }
|
||||
_sprayPixels = null;
|
||||
_sprayOpacity = null;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5b607cbaf7556b3489d0ccfbd4f926dc
|
||||
@@ -0,0 +1,260 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem;
|
||||
#endif
|
||||
|
||||
public partial class MeshPainter
|
||||
{
|
||||
// --- Undo / redo: per-stroke history of the canonical maps, mirrored on every peer ---
|
||||
//
|
||||
// One stroke (mouse-down -> mouse-up) = one undo step; each entry is the full canonical map set AFTER
|
||||
// that edit, restored by re-seeding the backend. Index 0 is the baseline (material seed, or a
|
||||
// loaded/snapshotted one).
|
||||
//
|
||||
// Networked: every replica keeps the SAME history, so undo/redo replicates as a tiny command. The
|
||||
// owner tags each committed stroke with a monotonic CHECKPOINT ID; remotes capture history at the same
|
||||
// boundaries under that id. Undo/redo carries the TARGET id; a peer lacking it (a late joiner) falls
|
||||
// back to a snapshot. PlayerPaintSync drives this via OnHistoryCheckpoint / OnHistoryRestore.
|
||||
//
|
||||
// Memory: unchanged maps are shared by reference with the previous entry (copy-on-write) - safe
|
||||
// because both backends CLONE their seed input and ReadCanonical returns fresh arrays.
|
||||
|
||||
[Header("Undo")]
|
||||
[Tooltip("Maximum number of undoable edits kept in memory (strokes + loads). Older edits drop off; " +
|
||||
"the oldest kept state is the undo floor. Higher = more undo depth but more memory (only the " +
|
||||
"maps a stroke changed are stored per step).")]
|
||||
[Min(1)]
|
||||
public int maxUndoSteps = 20;
|
||||
|
||||
/// <summary>
|
||||
/// Fires on the owner when a stroke is committed as a new history entry, carrying its checkpoint id.
|
||||
/// PlayerPaintSync forwards a marker so every remote captures the same entry under the same id. No
|
||||
/// subscriber = no-op (single-player).
|
||||
/// </summary>
|
||||
public event System.Action<int> OnHistoryCheckpoint;
|
||||
|
||||
/// <summary>
|
||||
/// Fires on the owner when the painting is restored to a history entry (undo / redo), carrying that
|
||||
/// entry's checkpoint id. PlayerPaintSync forwards the id so peers reproduce the move locally (or fetch
|
||||
/// a snapshot if they lack that id). No subscriber = no-op (single-player).
|
||||
/// </summary>
|
||||
public event System.Action<int> OnHistoryRestore;
|
||||
|
||||
private List<Color32[][]> _undoStates; // index 0 = oldest kept state; null until InitUndoHistory ran
|
||||
private List<int> _undoIds; // parallel checkpoint id per state (owner-assigned, peer-shared)
|
||||
private int _undoIndex; // the state the painter currently shows
|
||||
private int _nextCheckpointId; // owner: next id to assign; remotes set ids from the network
|
||||
|
||||
private bool _strokeActive; // a stroke has painted >=1 stamp and is not yet committed to history
|
||||
private int _strokeDirtyMaps; // bitmask of map indices touched by the current stroke
|
||||
|
||||
/// <summary>The checkpoint id of the state currently shown. Used as the baseline tag when snapshotting.</summary>
|
||||
public int CurrentCheckpointId =>
|
||||
(_undoIds != null && _undoIndex >= 0 && _undoIndex < _undoIds.Count) ? _undoIds[_undoIndex] : 0;
|
||||
|
||||
/// <summary>Seeds the history with the material baseline (id 0). Called once after the backend is set up.</summary>
|
||||
private void InitUndoHistory()
|
||||
{
|
||||
if (_seedCanonical == null) return;
|
||||
SetSingleBaseline(_seedCanonical, 0);
|
||||
_nextCheckpointId = 1;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Rebuilds the whole history to a single baseline entry holding <paramref name="maps"/> (per-map null
|
||||
/// falls back to the live backend), tagged <paramref name="id"/>. Clears all undo/redo. Used by the
|
||||
/// material seed, a load (owner) and an applied snapshot (remote). Takes ownership of the passed arrays.
|
||||
/// </summary>
|
||||
private void SetSingleBaseline(Color32[][] maps, int id)
|
||||
{
|
||||
if (_undoStates == null) _undoStates = new List<Color32[][]>(Mathf.Max(2, maxUndoSteps + 1));
|
||||
if (_undoIds == null) _undoIds = new List<int>(Mathf.Max(2, maxUndoSteps + 1));
|
||||
_undoStates.Clear();
|
||||
_undoIds.Clear();
|
||||
|
||||
int n = textureSize * textureSize;
|
||||
var baseline = new Color32[MAP_COUNT][];
|
||||
for (int mi = 0; mi < MAP_COUNT; mi++)
|
||||
{
|
||||
var s = (maps != null) ? maps[mi] : null;
|
||||
baseline[mi] = (s != null && s.Length == n) ? s : ReadCanonical(mi);
|
||||
}
|
||||
_undoStates.Add(baseline);
|
||||
_undoIds.Add(id);
|
||||
_undoIndex = 0;
|
||||
_strokeActive = false;
|
||||
_strokeDirtyMaps = 0;
|
||||
}
|
||||
|
||||
/// <summary>Owner-only: replace the whole history with the current maps as the sole baseline (used by load).</summary>
|
||||
private int ResetHistoryToCurrent(Color32[][] maps)
|
||||
{
|
||||
int id = _nextCheckpointId++;
|
||||
SetSingleBaseline(maps, id);
|
||||
return id;
|
||||
}
|
||||
|
||||
/// <summary>Remote-only: make the just-applied snapshot maps the sole history baseline under the authority's id.</summary>
|
||||
public void SetHistoryBaseline(Color32[][] maps, int baselineId)
|
||||
{
|
||||
if (!_setupValid) return;
|
||||
SetSingleBaseline(maps, baselineId);
|
||||
}
|
||||
|
||||
/// <summary>Marks that the current stroke painted a stamp (called per committed stamp, local or replicated).</summary>
|
||||
private void MarkStrokePainted()
|
||||
{
|
||||
_strokeActive = true;
|
||||
if (paintAlbedo) _strokeDirtyMaps |= 1 << MAP_ALBEDO;
|
||||
if (paintMetallicSmoothness || paintOcclusion) _strokeDirtyMaps |= 1 << MAP_MSO;
|
||||
if (paintEmission) _strokeDirtyMaps |= 1 << MAP_EM;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Owner: commits an in-progress stroke as one undo step and announces its checkpoint id (no-op if
|
||||
/// nothing was painted). Remotes use RemoteCommitCheckpoint instead.
|
||||
/// </summary>
|
||||
private void EndStrokeIfActive()
|
||||
{
|
||||
if (!_strokeActive) return;
|
||||
|
||||
// Stroke held but nothing actually painted (no active channel / no stamp landed): drop it without
|
||||
// consuming a checkpoint id, so the owner only ever announces ids it really holds an entry for.
|
||||
if (_strokeDirtyMaps == 0)
|
||||
{
|
||||
_strokeActive = false;
|
||||
return;
|
||||
}
|
||||
|
||||
int id = _nextCheckpointId++;
|
||||
CaptureUndoState(_strokeDirtyMaps, id);
|
||||
_strokeActive = false;
|
||||
_strokeDirtyMaps = 0;
|
||||
OnHistoryCheckpoint?.Invoke(id);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remote: commits the stamps replayed since the last checkpoint as one undo step under the owner's id,
|
||||
/// keeping this peer's history in lockstep. Always records the id - even if (rarely) no stamp landed
|
||||
/// here for the stroke - so every replica's history stays 1:1 aligned with the owner's and a later
|
||||
/// undo/redo to this id reproduces locally instead of forcing a snapshot fallback.
|
||||
/// </summary>
|
||||
public void RemoteCommitCheckpoint(int id)
|
||||
{
|
||||
if (!_setupValid) return;
|
||||
CaptureUndoState(_strokeDirtyMaps, id, forceEntry: true);
|
||||
_strokeActive = false;
|
||||
_strokeDirtyMaps = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots the current maps as a new history entry under <paramref name="id"/>, dropping any redo
|
||||
/// branch. With <paramref name="forceEntry"/> an entry is added even when nothing changed (it then
|
||||
/// shares every map with the previous entry via COW) - used by remote checkpoints to keep ids aligned.
|
||||
/// </summary>
|
||||
private void CaptureUndoState(int dirtyMaps, int id, bool forceEntry = false)
|
||||
{
|
||||
if (_undoStates == null) return;
|
||||
if (dirtyMaps == 0 && !forceEntry) return;
|
||||
|
||||
// A fresh edit invalidates everything ahead of the cursor (the redo branch).
|
||||
if (_undoIndex < _undoStates.Count - 1)
|
||||
{
|
||||
int from = _undoIndex + 1;
|
||||
int n = _undoStates.Count - from;
|
||||
_undoStates.RemoveRange(from, n);
|
||||
_undoIds.RemoveRange(from, n);
|
||||
}
|
||||
|
||||
Color32[][] prev = _undoStates[_undoIndex];
|
||||
var state = new Color32[MAP_COUNT][];
|
||||
for (int mi = 0; mi < MAP_COUNT; mi++)
|
||||
{
|
||||
bool changed = (dirtyMaps & (1 << mi)) != 0;
|
||||
state[mi] = changed ? ReadCanonical(mi) : prev[mi]; // COW: share maps the stroke didn't touch
|
||||
}
|
||||
|
||||
_undoStates.Add(state);
|
||||
_undoIds.Add(id);
|
||||
_undoIndex = _undoStates.Count - 1;
|
||||
|
||||
// Bound memory: keep at most maxUndoSteps edits beyond the oldest kept state.
|
||||
int maxEntries = Mathf.Max(1, maxUndoSteps) + 1;
|
||||
while (_undoStates.Count > maxEntries)
|
||||
{
|
||||
_undoStates.RemoveAt(0);
|
||||
_undoIds.RemoveAt(0);
|
||||
_undoIndex--;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Owner: steps back one edit and announces the restore. Returns false if already at the floor.</summary>
|
||||
public bool Undo()
|
||||
{
|
||||
if (_undoStates == null || _undoIndex <= 0) return false;
|
||||
_undoIndex--;
|
||||
RestoreCurrentState();
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Owner: steps forward one edit (after an undo). Returns false if already at the newest state.</summary>
|
||||
public bool Redo()
|
||||
{
|
||||
if (_undoStates == null || _undoIndex >= _undoStates.Count - 1) return false;
|
||||
_undoIndex++;
|
||||
RestoreCurrentState();
|
||||
return true;
|
||||
}
|
||||
|
||||
private void RestoreCurrentState()
|
||||
{
|
||||
RestoreEntry(_undoIndex);
|
||||
OnHistoryRestore?.Invoke(_undoIds[_undoIndex]); // owner -> peers reproduce the move by id
|
||||
}
|
||||
|
||||
/// <summary>Shows history entry <paramref name="idx"/>: re-seeds the backend and clears any in-progress stroke.</summary>
|
||||
private void RestoreEntry(int idx)
|
||||
{
|
||||
_undoIndex = idx;
|
||||
ReseedCanonical(_undoStates[idx]);
|
||||
_strokeActive = false;
|
||||
_strokeDirtyMaps = 0;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remote: restores to the history entry tagged <paramref name="id"/>. Returns false (and does nothing)
|
||||
/// if this peer doesn't hold that id - the caller then falls back to fetching a snapshot.
|
||||
/// </summary>
|
||||
public bool RemoteRestoreTo(int id)
|
||||
{
|
||||
if (_undoStates == null || _undoIds == null) return false;
|
||||
int idx = _undoIds.IndexOf(id);
|
||||
if (idx < 0) return false;
|
||||
|
||||
RestoreEntry(idx);
|
||||
return true;
|
||||
}
|
||||
|
||||
/// <summary>Reads Ctrl+Z / Ctrl+Y this frame. Modifier-gated so plain Z/Y never trigger it.</summary>
|
||||
private void GetUndoRedoInput(out bool undo, out bool redo)
|
||||
{
|
||||
undo = false;
|
||||
redo = false;
|
||||
if (TypingGuard.IsTyping) return; // let Ctrl+Z/Y edit text instead of the painting
|
||||
if (!InputUtil.CtrlHeld()) return;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
var kb = Keyboard.current;
|
||||
if (kb != null)
|
||||
{
|
||||
undo = kb.zKey.wasPressedThisFrame;
|
||||
redo = kb.yKey.wasPressedThisFrame;
|
||||
return;
|
||||
}
|
||||
#endif
|
||||
#if ENABLE_LEGACY_INPUT_MANAGER
|
||||
undo = Input.GetKeyDown(KeyCode.Z);
|
||||
redo = Input.GetKeyDown(KeyCode.Y);
|
||||
#endif
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 9d7335962dd88b844bdaac92a7cb8d85
|
||||
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5fc799adb27d5ea45990b5522b75ddda
|
||||
@@ -0,0 +1,116 @@
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// One knob that resizes the whole player. Setting <see cref="Scale"/> drives <c>transform.localScale</c>
|
||||
/// — which Unity already propagates to the visible mesh, the CharacterController capsule, the per-pose
|
||||
/// capsules (written as local values) and the camera target — and additionally rescales the movement,
|
||||
/// jump, slide and wall tunables on <see cref="PlayerCharacterController"/> proportionally, so a smaller
|
||||
/// or larger player still moves at the same body-relative pace instead of zipping or crawling. Without
|
||||
/// this the speeds (units/second, not geometry) stay put and a half-size player feels twice as fast.
|
||||
///
|
||||
/// The authored values on the controller ARE the scale-1 design values: tune them normally there. They are
|
||||
/// captured as the base in Awake and multiplied by Scale at runtime, so the serialized design values are
|
||||
/// never overwritten on disk. In the editor only <c>localScale</c> is previewed live (geometry is the only
|
||||
/// visual part); the speed rescale happens on play. The skinned mesh collider scales correctly because
|
||||
/// <see cref="SkinnedColliderBaker"/> divides the player scale back out of its bake.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
[RequireComponent(typeof(PlayerCharacterController))]
|
||||
public class PlayerScaler : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Uniform size multiplier for the whole player. 1 = authored size. Drives transform.localScale " +
|
||||
"(mesh, CharacterController capsule, per-pose capsules and camera target follow automatically) " +
|
||||
"and rescales the controller's movement/jump/slide/wall speeds proportionally.")]
|
||||
[Min(0.01f)]
|
||||
[SerializeField] private float scale = 1f;
|
||||
|
||||
[Tooltip("Also scale jump height, gravity and the wall top-out hop so the motion is geometrically " +
|
||||
"self-similar: identical trajectory shape and air-time, just smaller/larger. Off keeps the " +
|
||||
"authored gravity, giving a small player a floatier, relatively higher jump.")]
|
||||
[SerializeField] private bool scaleJumpAndGravity = true;
|
||||
|
||||
/// <summary>Current uniform scale. Use <see cref="SetScale"/> to change it at runtime.</summary>
|
||||
public float Scale => scale;
|
||||
|
||||
private PlayerCharacterController _pcc;
|
||||
private bool _captured;
|
||||
|
||||
// Authored (scale-1) base values, snapshotted once from the controller in Awake. All are world-unit
|
||||
// scalars (units, units/s, units/s^2) that do NOT follow transform.localScale; dimensionless ratios
|
||||
// (sprint/climb multipliers), angular rates (rotation/turn speed) and times (exit grace) are left
|
||||
// untouched — they are already scale-invariant.
|
||||
private float _bMove, _bAccel, _bDecel, _bStick, _bSlideAcc, _bMaxSlide;
|
||||
private float _bWallOffset, _bWallInset, _bWallReturn, _bWallSens, _bCeilGrab;
|
||||
private float _bJump, _bGravity, _bTopOut;
|
||||
|
||||
void Awake()
|
||||
{
|
||||
_pcc = GetComponent<PlayerCharacterController>();
|
||||
CaptureBase();
|
||||
ApplyScale(scale);
|
||||
}
|
||||
|
||||
/// <summary>Sets the scale at runtime (e.g. a grow/shrink mechanic) and re-applies from the base values.</summary>
|
||||
public void SetScale(float newScale)
|
||||
{
|
||||
scale = Mathf.Max(0.01f, newScale);
|
||||
if (!_pcc) _pcc = GetComponent<PlayerCharacterController>();
|
||||
if (!_captured) CaptureBase();
|
||||
ApplyScale(scale);
|
||||
}
|
||||
|
||||
private void CaptureBase()
|
||||
{
|
||||
if (!_pcc) return;
|
||||
_bMove = _pcc.moveSpeed;
|
||||
_bAccel = _pcc.acceleration;
|
||||
_bDecel = _pcc.deceleration;
|
||||
_bStick = _pcc.groundedStickVelocity;
|
||||
_bSlideAcc = _pcc.slideAcceleration;
|
||||
_bMaxSlide = _pcc.maxSlideSpeed;
|
||||
_bWallOffset = _pcc.wallMaxOffset;
|
||||
_bWallInset = _pcc.wallMaxInset;
|
||||
_bWallReturn = _pcc.wallOffsetReturnSpeed;
|
||||
_bWallSens = _pcc.wallOffsetSensitivity;
|
||||
_bCeilGrab = _pcc.ceilingGrabDistance;
|
||||
_bJump = _pcc.jumpHeight;
|
||||
_bGravity = _pcc.gravity;
|
||||
_bTopOut = _pcc.wallTopOutBoost;
|
||||
_captured = true;
|
||||
}
|
||||
|
||||
private void ApplyScale(float s)
|
||||
{
|
||||
transform.localScale = Vector3.one * s;
|
||||
if (!_pcc) return;
|
||||
|
||||
_pcc.moveSpeed = _bMove * s;
|
||||
_pcc.acceleration = _bAccel * s;
|
||||
_pcc.deceleration = _bDecel * s;
|
||||
_pcc.groundedStickVelocity = _bStick * s;
|
||||
_pcc.slideAcceleration = _bSlideAcc * s;
|
||||
_pcc.maxSlideSpeed = _bMaxSlide * s;
|
||||
_pcc.wallMaxOffset = _bWallOffset * s;
|
||||
_pcc.wallMaxInset = _bWallInset * s;
|
||||
_pcc.wallOffsetReturnSpeed = _bWallReturn * s;
|
||||
_pcc.wallOffsetSensitivity = _bWallSens * s;
|
||||
_pcc.ceilingGrabDistance = _bCeilGrab * s;
|
||||
|
||||
if (scaleJumpAndGravity)
|
||||
{
|
||||
_pcc.jumpHeight = _bJump * s;
|
||||
_pcc.gravity = _bGravity * s;
|
||||
_pcc.wallTopOutBoost = _bTopOut * s;
|
||||
}
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
void OnValidate()
|
||||
{
|
||||
// Live geometry preview in edit mode only; the speed rescale runs at play time from the authored
|
||||
// base so the design values on the controller are never serialized in their scaled form.
|
||||
if (!Application.isPlaying)
|
||||
transform.localScale = Vector3.one * Mathf.Max(0.01f, scale);
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 26105206120a119419bee8bb5f810902
|
||||
@@ -0,0 +1,86 @@
|
||||
using System.Collections.Generic;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Sole owner of the animated character's collider bake: keeps a non-convex MeshCollider in sync with
|
||||
/// the SkinnedMeshRenderer's current pose every frame, independent of any paint/game mode. MeshPainter's
|
||||
/// own per-frame bake sits behind its PaintingEnabled gate, so the collider would otherwise freeze in
|
||||
/// player-control / insect mode. When this component is present MeshPainter delegates to it (it stops
|
||||
/// baking and only borrows <see cref="BakedMesh"/> for its GPU raster path), so the deformed-pose
|
||||
/// collider stays fresh for everyone - paint raycasts, physics, future seeker / line-of-sight queries.
|
||||
///
|
||||
/// Bakes in the renderer's LOCAL space (BakeMesh, scale = 1) - the same convention MeshPainter and
|
||||
/// ConcealmentMeter use (world = transform.localToWorldMatrix). Runs at a negative execution order so
|
||||
/// the collider is fresh before MeshPainter (and other consumers) read it in the same frame. Owns the
|
||||
/// baked Mesh (creates + destroys it); MeshPainter never destroys a borrowed mesh.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
[DefaultExecutionOrder(-50)]
|
||||
[RequireComponent(typeof(SkinnedMeshRenderer))]
|
||||
public class SkinnedColliderBaker : MonoBehaviour
|
||||
{
|
||||
[Min(0f)]
|
||||
[Tooltip("Seconds between collider re-bakes. 0 = every frame (matches MeshPainter's per-frame bake). " +
|
||||
"Raise it to trade collider freshness for CPU on a high-poly rig.")]
|
||||
[SerializeField] private float bakeInterval = 0f;
|
||||
|
||||
/// <summary>The mesh baked from the current pose, in local space. Valid after the first bake.</summary>
|
||||
public Mesh BakedMesh => _baked;
|
||||
|
||||
private SkinnedMeshRenderer _skinned;
|
||||
private MeshCollider _collider;
|
||||
private Mesh _baked;
|
||||
private float _timer;
|
||||
private readonly List<Vector3> _vbuf = new List<Vector3>(); // reused vertex scratch so the scale fixup allocates nothing per bake
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
_skinned = GetComponent<SkinnedMeshRenderer>();
|
||||
if (!_skinned)
|
||||
{
|
||||
Debug.LogError("[SkinnedColliderBaker] No SkinnedMeshRenderer on this GameObject.", this);
|
||||
enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
_collider = GetComponent<MeshCollider>();
|
||||
if (!_collider) _collider = gameObject.AddComponent<MeshCollider>();
|
||||
_collider.convex = false; // non-convex provides triangleIndex / textureCoord for paint raycasts
|
||||
|
||||
_baked = new Mesh { name = "SkinnedColliderBaker_Baked" };
|
||||
Bake(); // valid collider on frame 0, before any consumer reads it
|
||||
}
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (bakeInterval <= 0f) { Bake(); return; }
|
||||
_timer += Time.deltaTime;
|
||||
if (_timer < bakeInterval) return;
|
||||
_timer = 0f;
|
||||
Bake();
|
||||
}
|
||||
|
||||
private void Bake()
|
||||
{
|
||||
if (!_skinned) return;
|
||||
_skinned.BakeMesh(_baked);
|
||||
|
||||
// BakeMesh freezes the bone deformation — which already carries this transform's world scale —
|
||||
// into the mesh. The MeshCollider below then applies the same lossyScale a SECOND time, so a
|
||||
// scaled player (see PlayerScaler) would get a collider half/double the size of the visible
|
||||
// mesh. Divide the scale back out so the baked mesh is true scale-1 local geometry (the contract
|
||||
// this class already documents): the MeshCollider re-applies it exactly once (matching the
|
||||
// renderer), and MeshPainter / ConcealmentMeter — which raise the baked mesh to world via
|
||||
// localToWorldMatrix — stay correct at any player scale. No-op (and untouched) at scale 1; the
|
||||
// uniform divide is rotation-independent, which the player's uniform scale guarantees.
|
||||
GeometryUtil.RescaleToUnitScale(_baked, transform.lossyScale, _vbuf);
|
||||
|
||||
_collider.sharedMesh = null; // force a physics re-cook of the new geometry
|
||||
_collider.sharedMesh = _baked;
|
||||
}
|
||||
|
||||
private void OnDestroy()
|
||||
{
|
||||
if (_baked) Destroy(_baked);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 161b7cf82dd54e74d92b5ca72d3be3af
|
||||
@@ -0,0 +1,82 @@
|
||||
using Unity.Netcode;
|
||||
using UnityEngine;
|
||||
|
||||
/// <summary>
|
||||
/// Sits on the Lobby's "Start Match" trigger volume (a BoxCollider with isTrigger on). When the match
|
||||
/// AUTHORITY's avatar stands inside it, it asks the <see cref="MatchManager"/> to start the match.
|
||||
///
|
||||
/// Detection POLLS a server-side physics overlap every frame instead of relying on OnTriggerEnter.
|
||||
/// Why: the authority can be a REMOTE client (a dedicated server with no host player hands authority to
|
||||
/// the first client). On the server that client's avatar is a NetworkTransform-driven replica - its
|
||||
/// CharacterController is disabled (<see cref="NetworkPlayerSetup"/>) and it carries no Rigidbody, so it
|
||||
/// is moved by direct transform writes. Unity only sends OnTriggerEnter when one of the two colliders has
|
||||
/// a Rigidbody (or is a CharacterController moving via Move()); a transform-moved non-Rigidbody collider
|
||||
/// against a static trigger fires nothing. That is exactly why a host's own (Move()-driven) avatar tripped
|
||||
/// the trigger but a remote authority never did. An explicit overlap query finds the avatar's (baked)
|
||||
/// collider regardless of HOW it is moved, so start works for a host and a dedicated server alike.
|
||||
///
|
||||
/// Server-gated: pure clients query nothing. Only the authority avatar starts the match; a passing
|
||||
/// non-authority avatar is ignored. Runs only while in the lobby phase and stops once it has started a
|
||||
/// match. The trigger lives in the additively-loaded Lobby and is destroyed when the lobby unloads, so a
|
||||
/// re-match (back in the lobby) gets a fresh instance with its guard reset.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Collider))]
|
||||
[DisallowMultipleComponent]
|
||||
public class StartMatchTrigger : MonoBehaviour
|
||||
{
|
||||
private Collider _collider;
|
||||
private readonly Collider[] _overlap = new Collider[32];
|
||||
private bool _started;
|
||||
private bool _overlapSaturated; // log-once: a full buffer could silently drop the authority's avatar
|
||||
|
||||
private void Awake() => _collider = GetComponent<Collider>();
|
||||
|
||||
private void Update()
|
||||
{
|
||||
if (_started) return;
|
||||
|
||||
var nm = NetworkManager.Singleton;
|
||||
if (!nm || !nm.IsServer) return; // detection + start are server-side
|
||||
var match = MatchManager.Instance;
|
||||
if (!match || match.Phase != MatchManager.MatchPhase.Lobby) return;
|
||||
|
||||
int count = OverlapTrigger();
|
||||
if (count == _overlap.Length && !_overlapSaturated)
|
||||
{
|
||||
_overlapSaturated = true;
|
||||
Debug.LogWarning($"StartMatchTrigger: overlap buffer saturated ({count}); the authority avatar may be missed.", this);
|
||||
}
|
||||
for (int i = 0; i < count; i++)
|
||||
{
|
||||
var netObj = _overlap[i].GetComponentInParent<NetworkObject>();
|
||||
if (!netObj || !netObj.IsPlayerObject) continue; // must be a player avatar
|
||||
if (!match.IsMatchAuthority(netObj.OwnerClientId)) continue; // ...the authority's
|
||||
|
||||
_started = true;
|
||||
match.StartMatch();
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Overlaps the trigger's own volume. Uses the oriented box for a BoxCollider (the authored shape);
|
||||
// falls back to the collider's world AABB for any other collider type. Triggers are ignored so the
|
||||
// query skips this volume itself and reports only solid avatar colliders (e.g. the baked mesh).
|
||||
private int OverlapTrigger()
|
||||
{
|
||||
if (_collider is BoxCollider box)
|
||||
{
|
||||
Vector3 center = transform.TransformPoint(box.center);
|
||||
Vector3 scale = transform.lossyScale;
|
||||
Vector3 half = new Vector3(
|
||||
Mathf.Abs(box.size.x * scale.x),
|
||||
Mathf.Abs(box.size.y * scale.y),
|
||||
Mathf.Abs(box.size.z * scale.z)) * 0.5f;
|
||||
return Physics.OverlapBoxNonAlloc(
|
||||
center, half, _overlap, transform.rotation, ~0, QueryTriggerInteraction.Ignore);
|
||||
}
|
||||
|
||||
Bounds b = _collider.bounds;
|
||||
return Physics.OverlapBoxNonAlloc(
|
||||
b.center, b.extents, _overlap, Quaternion.identity, ~0, QueryTriggerInteraction.Ignore);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: dbc3790cccc4958458bb38d883b69afb
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5dd11660d05b5284b8015c842079c2ac
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,120 @@
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
/// <summary>
|
||||
/// Makes a UI panel draggable. Attach to the panel itself: it follows the pointer from any of the panel's
|
||||
/// non-interactive surface (title bar, padding, labels, background) but ignores presses that begin on a
|
||||
/// widget - a <see cref="Selectable"/> (toggle, slider, ...) or a <see cref="UiPointerSurface"/> (the
|
||||
/// colour-picker field) - so those keep their own click/drag behaviour. It keeps a slice of the panel on
|
||||
/// screen and raises it above its siblings while interacting.
|
||||
///
|
||||
/// Works under any canvas scale (it tracks the pointer in the drag area's local space and applies the
|
||||
/// movement delta) and under any anchor/pivot (the on-screen clamp is derived from the panel's real
|
||||
/// corners in its parent, not from a fixed top-left assumption).
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
public class DraggablePanel : MonoBehaviour, IPointerDownHandler, IBeginDragHandler, IDragHandler, IEndDragHandler
|
||||
{
|
||||
[Tooltip("Panel moved by the drag. Empty = this object's RectTransform.")]
|
||||
public RectTransform target;
|
||||
|
||||
[Tooltip("Panel pixels (canvas units) kept on screen on every edge so it can never be lost.")]
|
||||
public float keepVisible = 48f;
|
||||
|
||||
// Raised once when a real panel drag ends (not for gestures that began on a widget). The HUD uses
|
||||
// this to persist the layout the moment a panel is dropped. Optional - left null by other users.
|
||||
public System.Action Moved;
|
||||
|
||||
private RectTransform _area; // the panel's parent; pointer positions are tracked in its local space
|
||||
private Vector2 _grabOffset;
|
||||
private bool _dragging;
|
||||
|
||||
private void Awake() => Resolve();
|
||||
|
||||
private void Resolve()
|
||||
{
|
||||
if (!target) target = transform as RectTransform;
|
||||
if (target) _area = target.parent as RectTransform;
|
||||
}
|
||||
|
||||
public void OnPointerDown(PointerEventData eventData)
|
||||
{
|
||||
// Raise the panel when pressing its own surface. (A press that lands on a widget is consumed by
|
||||
// that widget's own handler and never reaches us, so this rarely needs the guard - but it is cheap.)
|
||||
if (!StartedOnWidget(eventData)) BringToFront();
|
||||
}
|
||||
|
||||
public void OnBeginDrag(PointerEventData eventData)
|
||||
{
|
||||
_dragging = false;
|
||||
if (!target) Resolve();
|
||||
if (!target || !_area) return;
|
||||
if (StartedOnWidget(eventData)) return; // a toggle/slider/picker owns this gesture, not the panel
|
||||
|
||||
_dragging = true;
|
||||
BringToFront();
|
||||
// Capture grab offset; the constant frame difference between the area pivot and the panel anchor
|
||||
// cancels out because we only ever apply the pointer delta on top of this offset.
|
||||
if (RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
_area, eventData.position, eventData.pressEventCamera, out var local))
|
||||
_grabOffset = target.anchoredPosition - local;
|
||||
}
|
||||
|
||||
public void OnDrag(PointerEventData eventData)
|
||||
{
|
||||
if (!_dragging || !target || !_area) return;
|
||||
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
_area, eventData.position, eventData.pressEventCamera, out var local))
|
||||
return;
|
||||
target.anchoredPosition = Clamp(local + _grabOffset);
|
||||
}
|
||||
|
||||
public void OnEndDrag(PointerEventData eventData)
|
||||
{
|
||||
if (!_dragging) return; // gesture began on a widget -> not our drag, nothing moved
|
||||
_dragging = false;
|
||||
Moved?.Invoke();
|
||||
}
|
||||
|
||||
// True when the press started on an interactive widget inside the panel (which then owns the gesture).
|
||||
private bool StartedOnWidget(PointerEventData eventData)
|
||||
{
|
||||
var hit = eventData.pointerPressRaycast.gameObject;
|
||||
for (var t = hit ? hit.transform : null; t && t != target; t = t.parent)
|
||||
if (t.GetComponent<Selectable>() || t.GetComponent<UiPointerSurface>())
|
||||
return true;
|
||||
return false;
|
||||
}
|
||||
|
||||
private void BringToFront()
|
||||
{
|
||||
if (target) target.SetAsLastSibling();
|
||||
}
|
||||
|
||||
// Anchor/pivot-agnostic clamp: keep at least `keepVisible` px of the panel inside the parent on every
|
||||
// edge, computed from the panel's real corners so it is correct wherever the panel is anchored.
|
||||
private Vector2 Clamp(Vector2 pos)
|
||||
{
|
||||
if (!_area) return pos;
|
||||
Rect area = _area.rect;
|
||||
Rect size = target.rect;
|
||||
Vector2 pivot = target.pivot;
|
||||
|
||||
// Anchor reference point in the area's local space (panels are point-anchored: anchorMin == anchorMax).
|
||||
Vector2 anchorRef = new Vector2(
|
||||
area.xMin + target.anchorMin.x * area.width,
|
||||
area.yMin + target.anchorMin.y * area.height);
|
||||
Vector2 pivotToCorner = new Vector2(pivot.x * size.width, pivot.y * size.height);
|
||||
|
||||
// Bottom-left corner of the panel for this anchoredPosition.
|
||||
Vector2 corner = anchorRef + pos - pivotToCorner;
|
||||
|
||||
float keepX = Mathf.Min(keepVisible, size.width);
|
||||
float keepY = Mathf.Min(keepVisible, size.height);
|
||||
corner.x = Mathf.Clamp(corner.x, area.xMin + keepX - size.width, area.xMax - keepX);
|
||||
corner.y = Mathf.Clamp(corner.y, area.yMin + keepY - size.height, area.yMax - keepY);
|
||||
|
||||
return corner + pivotToCorner - anchorRef;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 14b82ffde5db5c547b5f454ac2791815
|
||||
@@ -0,0 +1,132 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Compact runtime HSV colour picker: a saturation/value square, a hue bar and a live swatch. The
|
||||
/// square is a pure-hue base with two static gradient overlays on top (white-out -> saturation,
|
||||
/// black-out -> value), so only the base tint changes per hue - no per-frame texture rebuilds. Hue is
|
||||
/// never lost while dragging the square (the square only edits S/V, the bar only edits H). Alpha is
|
||||
/// edited separately by a standalone slider in the HUD, not by this picker; <see cref="SetColor"/>
|
||||
/// still stores the incoming alpha so <see cref="Value"/> round-trips it.
|
||||
///
|
||||
/// The child structure (stable names) comes from the baked HUD prefab. <see cref="Rebind"/>
|
||||
/// (re)assigns the runtime gradient sprites (which a prefab cannot store) and (re)hooks the drag
|
||||
/// relays. A leftover alpha bar from a pre-split prefab is hidden by <see cref="Rebind"/>, so no
|
||||
/// re-bake is required.
|
||||
/// </summary>
|
||||
public class HsvColorPicker : MonoBehaviour
|
||||
{
|
||||
/// <summary>Raised with the new colour (RGB; alpha is preserved, not edited here) when the user moves
|
||||
/// the square or hue bar.</summary>
|
||||
public event Action<Color> Changed;
|
||||
|
||||
private float _h, _s, _v, _a = 1f;
|
||||
|
||||
private Image _hueBase; // pure-hue background of the SV square
|
||||
private RectTransform _svHandle;
|
||||
private RectTransform _hueHandle;
|
||||
private Image _swatch;
|
||||
|
||||
/// <summary>Current colour (HSV -> RGB, with the preserved alpha).</summary>
|
||||
public Color Value
|
||||
{
|
||||
get
|
||||
{
|
||||
Color c = Color.HSVToRGB(_h, _s, _v);
|
||||
c.a = _a;
|
||||
return c;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Shows <paramref name="c"/> without raising <see cref="Changed"/> (external set).</summary>
|
||||
public void SetColor(Color c)
|
||||
{
|
||||
Color.RGBToHSV(c, out _h, out _s, out _v);
|
||||
_a = c.a;
|
||||
Refresh();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Re-finds the child widgets, (re)assigns the runtime gradient sprites and (re)hooks the drag
|
||||
/// relays. Idempotent (unsubscribe-then-subscribe), so it is safe whether the structure was just
|
||||
/// built in code or instantiated from a prefab. Call before <see cref="SetColor"/>.
|
||||
/// </summary>
|
||||
public void Rebind()
|
||||
{
|
||||
var sv = transform.Find("SV");
|
||||
if (sv)
|
||||
{
|
||||
_hueBase = sv.GetComponent<Image>();
|
||||
|
||||
var sat = sv.Find("SatOverlay");
|
||||
if (sat && sat.TryGetComponent(out Image satImg)) satImg.sprite = PaintHud.SaturationSprite();
|
||||
var val = sv.Find("ValOverlay");
|
||||
if (val && val.TryGetComponent(out Image valImg)) valImg.sprite = PaintHud.ValueSprite();
|
||||
|
||||
_svHandle = sv.Find("Handle") as RectTransform;
|
||||
|
||||
Rehook(sv, OnSvPicked);
|
||||
}
|
||||
|
||||
var hue = transform.Find("Hue") as RectTransform;
|
||||
if (hue)
|
||||
{
|
||||
if (hue.TryGetComponent(out Image hueImg)) hueImg.sprite = PaintHud.HueSprite();
|
||||
_hueHandle = hue.Find("Handle") as RectTransform;
|
||||
|
||||
Rehook(hue, OnHuePicked);
|
||||
}
|
||||
|
||||
// Alpha now lives on a standalone slider (PaintHud), not in the picker. Hide any alpha bar a
|
||||
// pre-split prefab still carries so it does not show as a dead, non-functional strip.
|
||||
var leftoverAlpha = transform.Find("Alpha");
|
||||
if (leftoverAlpha) leftoverAlpha.gameObject.SetActive(false);
|
||||
|
||||
var swatch = transform.Find("Swatch");
|
||||
if (swatch) _swatch = swatch.GetComponent<Image>();
|
||||
|
||||
Refresh();
|
||||
}
|
||||
|
||||
// Idempotently hooks the handler to the surface's drag relay (unsubscribe-then-subscribe).
|
||||
private static void Rehook(Component surface, Action<Vector2> handler)
|
||||
{
|
||||
if (surface.TryGetComponent(out UiPointerSurface pointer))
|
||||
{
|
||||
pointer.Picked -= handler;
|
||||
pointer.Picked += handler;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnSvPicked(Vector2 p)
|
||||
{
|
||||
_s = p.x;
|
||||
_v = p.y;
|
||||
Refresh();
|
||||
Changed?.Invoke(Value);
|
||||
}
|
||||
|
||||
private void OnHuePicked(Vector2 p)
|
||||
{
|
||||
_h = p.x;
|
||||
Refresh();
|
||||
Changed?.Invoke(Value);
|
||||
}
|
||||
|
||||
private void Refresh()
|
||||
{
|
||||
if (_hueBase) _hueBase.color = Color.HSVToRGB(_h, 1f, 1f);
|
||||
if (_svHandle)
|
||||
{
|
||||
_svHandle.anchorMin = _svHandle.anchorMax = new Vector2(_s, _v);
|
||||
_svHandle.anchoredPosition = Vector2.zero;
|
||||
}
|
||||
if (_hueHandle)
|
||||
{
|
||||
_hueHandle.anchorMin = _hueHandle.anchorMax = new Vector2(_h, 0.5f);
|
||||
_hueHandle.anchoredPosition = Vector2.zero;
|
||||
}
|
||||
if (_swatch) _swatch.color = Color.HSVToRGB(_h, _s, _v);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2a35dda49401e4045ba2165e15116a5f
|
||||
@@ -0,0 +1,37 @@
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// Binds a UI Slider (and optional value label) to the global MouseSensitivity
|
||||
/// multiplier. The slider's range is authored in the inspector (0.1 - 4.0);
|
||||
/// this only reads/writes the value so scene-side range edits are respected.
|
||||
public class MouseSensitivitySlider : MonoBehaviour
|
||||
{
|
||||
[SerializeField] private Slider slider;
|
||||
[Tooltip("Optional TMP label showing the current multiplier (e.g. \"1,00\").")]
|
||||
[SerializeField] private TMP_Text valueLabel;
|
||||
|
||||
void OnEnable()
|
||||
{
|
||||
if (!slider) return;
|
||||
slider.SetValueWithoutNotify(MouseSensitivity.Multiplier);
|
||||
UpdateLabel(MouseSensitivity.Multiplier);
|
||||
slider.onValueChanged.AddListener(OnSliderChanged);
|
||||
}
|
||||
|
||||
void OnDisable()
|
||||
{
|
||||
if (slider) slider.onValueChanged.RemoveListener(OnSliderChanged);
|
||||
}
|
||||
|
||||
void OnSliderChanged(float value)
|
||||
{
|
||||
MouseSensitivity.Multiplier = value;
|
||||
UpdateLabel(value);
|
||||
}
|
||||
|
||||
void UpdateLabel(float value)
|
||||
{
|
||||
if (valueLabel) valueLabel.text = value.ToString("0.00");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: fc6da376d2cfebe47beeb9b6012e76dd
|
||||
@@ -0,0 +1,8 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d09de72a6314d2d40bd136e616623b80
|
||||
folderAsset: yes
|
||||
DefaultImporter:
|
||||
externalObjects: {}
|
||||
userData:
|
||||
assetBundleName:
|
||||
assetBundleVariant:
|
||||
@@ -0,0 +1,307 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using TMPro;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Binder for the "Paintings" library panel. The panel's visual layout (Panel_Paintings, the scrollable
|
||||
/// list, an entry template, and the Save / Confirm dialogs) is authored inside the PaintHudPanel layout
|
||||
/// prefab - this code only finds those objects by their stable names and wires the behaviour, exactly like
|
||||
/// <see cref="WirePanel"/> does for the brush widgets. The list is populated by cloning the authored
|
||||
/// Lib_EntryTemplate per saved slot. Save opens a name dialog (an existing name is overwritten, with a
|
||||
/// warning shown while typing); Load applies the selected slot immediately; Delete goes through a
|
||||
/// confirmation dialog. Loading replaces the current painting and, when networked, re-broadcasts the
|
||||
/// loaded state to every other peer.
|
||||
/// </summary>
|
||||
public partial class PaintHud
|
||||
{
|
||||
// Stable widget names, matching the objects authored in the PaintHudPanel prefab.
|
||||
private const string LibListContent = "Lib_ListContent";
|
||||
private const string LibEntryTemplate = "Lib_EntryTemplate";
|
||||
private const string LibSaveButton = "Lib_Save";
|
||||
private const string LibLoadButton = "Lib_Load";
|
||||
private const string LibDeleteButton = "Lib_Delete";
|
||||
private const string LibResetButton = "Lib_ResetHud";
|
||||
private const string LibSaveDialog = "Lib_SaveDialog";
|
||||
private const string LibNameInput = "Lib_NameInput";
|
||||
private const string LibSaveInfo = "Lib_SaveInfo";
|
||||
private const string LibSaveOk = "Lib_SaveOk";
|
||||
private const string LibSaveCancel = "Lib_SaveCancel";
|
||||
private const string LibConfirmDialog = "Lib_ConfirmDialog";
|
||||
private const string LibConfirmText = "Lib_ConfirmText";
|
||||
private const string LibConfirmOk = "Lib_ConfirmOk";
|
||||
private const string LibConfirmCancel = "Lib_ConfirmCancel";
|
||||
|
||||
private bool _libraryBound;
|
||||
private RectTransform _listContent;
|
||||
private GameObject _entryTemplate;
|
||||
private Button _saveButton, _loadButton, _deleteButton, _resetButton;
|
||||
private string _selectedPainting;
|
||||
|
||||
// Save dialog
|
||||
private GameObject _saveDialog;
|
||||
private TMP_InputField _nameInput;
|
||||
private TMP_Text _saveInfo;
|
||||
private Button _saveOkButton;
|
||||
|
||||
// Confirmation dialog (reused for Load + Delete)
|
||||
private GameObject _confirmDialog;
|
||||
private TMP_Text _confirmText;
|
||||
private Button _confirmButton;
|
||||
private Action _confirmAction;
|
||||
|
||||
private struct EntryRef { public string name; public Image bg; }
|
||||
private readonly List<EntryRef> _entries = new List<EntryRef>();
|
||||
|
||||
private static readonly Color EntryNormal = new Color(0.18f, 0.18f, 0.22f, 1f);
|
||||
private static readonly Color EntrySelected = new Color(0.20f, 0.45f, 0.80f, 1f);
|
||||
private static readonly Color OkDanger = new Color(0.62f, 0.26f, 0.26f, 1f);
|
||||
|
||||
// --- Bind (find authored objects, hook listeners) ---
|
||||
|
||||
/// <summary>
|
||||
/// Binds the authored library panel under <paramref name="root"/> (the instantiated layout prefab).
|
||||
/// Idempotent: binds exactly once. No-op if the prefab carries no library objects.
|
||||
/// </summary>
|
||||
private void BindLibrary(Transform root)
|
||||
{
|
||||
if (_libraryBound || !root) return;
|
||||
|
||||
_listContent = GeometryUtil.FindDeep(root, LibListContent) as RectTransform;
|
||||
var template = GeometryUtil.FindDeep(root, LibEntryTemplate);
|
||||
_entryTemplate = template ? template.gameObject : null;
|
||||
if (!_listContent || !_entryTemplate) return; // panel not present in this layout
|
||||
_entryTemplate.SetActive(false);
|
||||
|
||||
_saveButton = Find<Button>(root, LibSaveButton);
|
||||
_loadButton = Find<Button>(root, LibLoadButton);
|
||||
_deleteButton = Find<Button>(root, LibDeleteButton);
|
||||
_resetButton = Find<Button>(root, LibResetButton); // optional: present only if authored in the layout
|
||||
Hook(_saveButton, OnSaveClicked);
|
||||
Hook(_loadButton, OnLoadClicked);
|
||||
Hook(_deleteButton, OnDeleteClicked);
|
||||
Hook(_resetButton, OnResetClicked);
|
||||
|
||||
var sd = GeometryUtil.FindDeep(root, LibSaveDialog);
|
||||
_saveDialog = sd ? sd.gameObject : null;
|
||||
_nameInput = Find<TMP_InputField>(root, LibNameInput);
|
||||
_saveInfo = Find<TMP_Text>(root, LibSaveInfo);
|
||||
_saveOkButton = Find<Button>(root, LibSaveOk);
|
||||
Hook(_saveOkButton, ConfirmSave);
|
||||
Hook(Find<Button>(root, LibSaveCancel), () => { if (_saveDialog) _saveDialog.SetActive(false); });
|
||||
if (_nameInput) _nameInput.onValueChanged.AddListener(_ => ValidateSaveName());
|
||||
|
||||
var cd = GeometryUtil.FindDeep(root, LibConfirmDialog);
|
||||
_confirmDialog = cd ? cd.gameObject : null;
|
||||
_confirmText = Find<TMP_Text>(root, LibConfirmText);
|
||||
_confirmButton = Find<Button>(root, LibConfirmOk);
|
||||
Hook(_confirmButton, OnConfirmClicked);
|
||||
Hook(Find<Button>(root, LibConfirmCancel), () => { if (_confirmDialog) _confirmDialog.SetActive(false); });
|
||||
|
||||
HideDialogs();
|
||||
_libraryBound = true;
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
private static void Hook(Button button, Action onClick)
|
||||
{
|
||||
if (!button || onClick == null) return;
|
||||
button.onClick.RemoveAllListeners();
|
||||
button.onClick.AddListener(() => onClick());
|
||||
}
|
||||
|
||||
// --- List ---
|
||||
|
||||
private void RefreshList()
|
||||
{
|
||||
if (!_listContent || !_entryTemplate) return;
|
||||
|
||||
for (int i = _listContent.childCount - 1; i >= 0; i--)
|
||||
{
|
||||
var child = _listContent.GetChild(i).gameObject;
|
||||
if (child != _entryTemplate) Destroy(child);
|
||||
}
|
||||
_entries.Clear();
|
||||
|
||||
var names = MeshPainter.ListSavedPaintings();
|
||||
if (!string.IsNullOrEmpty(_selectedPainting) && !names.Contains(_selectedPainting))
|
||||
_selectedPainting = null;
|
||||
|
||||
foreach (var name in names)
|
||||
{
|
||||
var go = Instantiate(_entryTemplate, _listContent);
|
||||
go.name = "Lib_Entry";
|
||||
go.SetActive(true);
|
||||
|
||||
var bg = go.GetComponent<Image>();
|
||||
if (bg) bg.color = name == _selectedPainting ? EntrySelected : EntryNormal;
|
||||
|
||||
var label = go.GetComponentInChildren<TMP_Text>(true);
|
||||
if (label) label.text = name;
|
||||
|
||||
var btn = go.GetComponent<Button>();
|
||||
if (btn)
|
||||
{
|
||||
string captured = name;
|
||||
Hook(btn, () => SelectPainting(captured));
|
||||
}
|
||||
|
||||
_entries.Add(new EntryRef { name = name, bg = bg });
|
||||
}
|
||||
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
private void SelectPainting(string name)
|
||||
{
|
||||
_selectedPainting = name;
|
||||
RefreshHighlights();
|
||||
UpdateButtonStates();
|
||||
}
|
||||
|
||||
private void RefreshHighlights()
|
||||
{
|
||||
foreach (var e in _entries)
|
||||
if (e.bg) e.bg.color = e.name == _selectedPainting ? EntrySelected : EntryNormal;
|
||||
}
|
||||
|
||||
private void UpdateButtonStates()
|
||||
{
|
||||
bool hasPainter = painter;
|
||||
bool hasSel = !string.IsNullOrEmpty(_selectedPainting);
|
||||
if (_saveButton) _saveButton.interactable = hasPainter;
|
||||
if (_loadButton) _loadButton.interactable = hasPainter && hasSel;
|
||||
if (_deleteButton) _deleteButton.interactable = hasSel;
|
||||
}
|
||||
|
||||
private void HideDialogs()
|
||||
{
|
||||
if (_saveDialog) _saveDialog.SetActive(false);
|
||||
if (_confirmDialog) _confirmDialog.SetActive(false);
|
||||
}
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
private void OnSaveClicked()
|
||||
{
|
||||
if (!painter || !_saveDialog) return;
|
||||
// Pre-fill the currently selected painting's name so "Save" defaults to overwriting it (empty when
|
||||
// nothing is selected) and select that text so the first keystroke replaces it for a fresh name.
|
||||
if (_nameInput) _nameInput.text = _selectedPainting ?? "";
|
||||
if (_saveInfo) _saveInfo.text = "";
|
||||
_saveDialog.SetActive(true);
|
||||
_saveDialog.transform.SetAsLastSibling(); // a dragged panel may have moved itself in front; stay on top
|
||||
ValidateSaveName();
|
||||
if (_nameInput)
|
||||
{
|
||||
_nameInput.ActivateInputField();
|
||||
StartCoroutine(SelectNameFieldNextFrame());
|
||||
}
|
||||
}
|
||||
|
||||
// ActivateInputField only takes focus on a later update and resets the caret, so selecting the text this
|
||||
// frame would be cleared. Wait one frame, then select the whole pre-filled name - done explicitly here so
|
||||
// it works regardless of the field's authored "On Focus Select All" toggle.
|
||||
private System.Collections.IEnumerator SelectNameFieldNextFrame()
|
||||
{
|
||||
yield return null;
|
||||
if (!_nameInput) yield break;
|
||||
_nameInput.selectionStringAnchorPosition = _nameInput.text.Length;
|
||||
_nameInput.selectionStringFocusPosition = 0;
|
||||
_nameInput.ForceLabelUpdate();
|
||||
}
|
||||
|
||||
private void ValidateSaveName()
|
||||
{
|
||||
string name = _nameInput ? _nameInput.text.Trim() : "";
|
||||
bool empty = string.IsNullOrEmpty(name);
|
||||
bool exists = !empty && MeshPainter.PaintExists(name);
|
||||
// Overwrite is allowed -> the OK button stays enabled even when the name exists; the warning is
|
||||
// purely informational so you know you are about to replace an existing slot.
|
||||
if (_saveOkButton) _saveOkButton.interactable = !empty && painter;
|
||||
if (_saveInfo) _saveInfo.text = exists ? "A painting with this name already exists and will be overwritten." : "";
|
||||
}
|
||||
|
||||
private void ConfirmSave()
|
||||
{
|
||||
if (!painter || !_nameInput) return;
|
||||
string name = _nameInput.text.Trim();
|
||||
if (string.IsNullOrEmpty(name)) { ValidateSaveName(); return; }
|
||||
|
||||
if (painter.SavePaintAs(name, overwrite: true))
|
||||
{
|
||||
_selectedPainting = name;
|
||||
if (_saveDialog) _saveDialog.SetActive(false);
|
||||
RefreshList();
|
||||
}
|
||||
else if (_saveInfo)
|
||||
{
|
||||
_saveInfo.text = "Save failed.";
|
||||
}
|
||||
}
|
||||
|
||||
private void OnLoadClicked()
|
||||
{
|
||||
if (!painter || string.IsNullOrEmpty(_selectedPainting)) return;
|
||||
DoLoad(_selectedPainting); // load immediately, no confirmation
|
||||
}
|
||||
|
||||
private void OnDeleteClicked()
|
||||
{
|
||||
if (string.IsNullOrEmpty(_selectedPainting)) return;
|
||||
string name = _selectedPainting;
|
||||
ShowConfirm($"Delete \"{name}\"?", OkDanger, () => DoDelete(name));
|
||||
}
|
||||
|
||||
/// <summary>Reset the panels + brush values to their authored defaults (confirmed; the dialog reuses the
|
||||
/// library's confirm path). Falls back to an immediate reset if no confirm dialog is authored.</summary>
|
||||
private void OnResetClicked()
|
||||
{
|
||||
if (_confirmDialog)
|
||||
ShowConfirm("Reset the HUD panels and brush values to their defaults?", OkDanger, ResetHudToDefaults);
|
||||
else
|
||||
ResetHudToDefaults();
|
||||
}
|
||||
|
||||
private void DoLoad(string name)
|
||||
{
|
||||
if (!painter || !painter.LoadPaintByName(name)) return;
|
||||
|
||||
// The load also restored the slot's material scalars onto the painter (emission + camo from v3,
|
||||
// the painted-height knobs from v4) -> push them into the sliders so the HUD reflects the loaded
|
||||
// look, exactly like a pipette pick does. SyncCamoSliders covers the camo + height sliders.
|
||||
SyncFromPainter();
|
||||
SyncCamoSliders();
|
||||
|
||||
// Re-sync the loaded baseline to every other peer (no-op offline / single-player). The restored
|
||||
// emission/camo replicate on their own via PlayerPaintSync's owner-authoritative NetworkVariables.
|
||||
var sync = painter.GetComponentInParent<PlayerPaintSync>();
|
||||
if (sync) sync.BroadcastLoadedPaint();
|
||||
}
|
||||
|
||||
private void DoDelete(string name)
|
||||
{
|
||||
MeshPainter.DeletePaint(name);
|
||||
if (_selectedPainting == name) _selectedPainting = null;
|
||||
RefreshList();
|
||||
}
|
||||
|
||||
private void ShowConfirm(string message, Color okColor, Action onConfirm)
|
||||
{
|
||||
if (!_confirmDialog) return;
|
||||
if (_confirmText) _confirmText.text = message;
|
||||
_confirmAction = onConfirm;
|
||||
if (_confirmButton && _confirmButton.targetGraphic is Image okImg) okImg.color = okColor;
|
||||
_confirmDialog.SetActive(true);
|
||||
_confirmDialog.transform.SetAsLastSibling(); // a dragged panel may have moved itself in front; stay on top
|
||||
}
|
||||
|
||||
private void OnConfirmClicked()
|
||||
{
|
||||
var act = _confirmAction;
|
||||
_confirmAction = null;
|
||||
if (_confirmDialog) _confirmDialog.SetActive(false);
|
||||
act?.Invoke();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 059854c655f028c4ba5f9b4b0bccdbb6
|
||||
@@ -0,0 +1,526 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using UnityEngine;
|
||||
|
||||
public partial class PaintHud
|
||||
{
|
||||
// --- Persistence: panel positions + the widget-bound painter values ---
|
||||
//
|
||||
// Saved when state actually changes (panel drop = instant, brush edits = debounced), restored on the
|
||||
// next start. Deliberately NO save on quit/teardown: standalone builds don't guarantee a clean
|
||||
// shutdown (OnApplicationQuit is skipped on force-close/kill/crash), which silently lost the whole
|
||||
// session's layout in the build. Small structured state -> human-readable JSON, independent of the
|
||||
// painter's bulk pixel maps (MeshPainter.Persistence). The brush values are also mirrored in-memory
|
||||
// (_carry) so they survive the avatar respawn a scene switch triggers.
|
||||
|
||||
[Header("Persistence")]
|
||||
[Tooltip("Save the panel positions and their values when you move a panel or change a brush value, " +
|
||||
"and restore them on the next start. Off = the HUD opens at its authored layout / painter " +
|
||||
"defaults every session.")]
|
||||
public bool persistHud = true;
|
||||
|
||||
[Tooltip("Save file identifier. Empty = derived from the HUD's own scene name. Set explicitly to keep " +
|
||||
"the layout stable across scene renames or to share one layout between scenes.")]
|
||||
public string hudSaveId = "";
|
||||
|
||||
private const int HudFileVersion = 1;
|
||||
|
||||
// Auto-save bookkeeping. Brush edits are debounced (sliders fire every frame while dragged): the due
|
||||
// time is pushed forward and written once they settle.
|
||||
private const float HudSaveDebounce = 0.5f;
|
||||
private readonly HudValues _savedValues = new HudValues(); // mirror of what is currently on disk
|
||||
private readonly HudValues _lastSeenValues = new HudValues(); // painter values observed last frame (settle detection)
|
||||
private bool _hasSavedBaseline;
|
||||
private bool _hudSavePending;
|
||||
private float _hudSaveDueTime;
|
||||
|
||||
// In-memory mirror seeding a freshly respawned painter (instead of its prefab defaults). Single
|
||||
// instance with mutable fields -> no per-frame allocation.
|
||||
private readonly HudValues _carry = new HudValues();
|
||||
private bool _hasCarry;
|
||||
|
||||
// Camo knobs live on the material instance, which only exists after the painter's Start: a restored
|
||||
// (disk) or carried (respawn) value waits here and is applied in the LateUpdate ready-gate.
|
||||
// _camoEverReady gates carry/save so the not-yet-ready 0 never overwrites the authored value.
|
||||
private Vector3 _pendingCamo;
|
||||
private bool _hasPendingCamo;
|
||||
private bool _camoEverReady;
|
||||
|
||||
// Painted-height knobs ride the exact same material-ready deferral as the camo knobs above.
|
||||
private Vector3 _pendingHeight;
|
||||
private bool _hasPendingHeight;
|
||||
|
||||
// Authored defaults for the runtime reset ("as if the save file had been deleted"): prefab panel
|
||||
// positions + prefab/material default brush values, captured before restore/carry mutate them. Camo
|
||||
// is material-backed (readable only once IsReady), so it is filled in the ready-gate instead.
|
||||
private readonly Dictionary<string, Vector2> _authoredPanelPos = new Dictionary<string, Vector2>();
|
||||
private bool _hasAuthoredLayout;
|
||||
private readonly HudValues _defaults = new HudValues();
|
||||
private bool _hasDefaults; // non-camo defaults captured (first bind, before restore/carry)
|
||||
private bool _hasDefaultCamo; // camo defaults captured (first ready-gate, before any pending camo apply)
|
||||
|
||||
private static string HudSaveDir => Path.Combine(Application.persistentDataPath, "MeshPaint");
|
||||
|
||||
private string HudSavePath()
|
||||
{
|
||||
// Derive from the HUD's OWN scene (a stable persistent scene), not the active scene: the latter
|
||||
// now changes across the additive lobby/map loads, so a key from it would split save and restore
|
||||
// into different files and silently lose the saved layout.
|
||||
string key = string.IsNullOrEmpty(hudSaveId)
|
||||
? SanitizeHudKey(gameObject.scene.name + "_hud")
|
||||
: SanitizeHudKey(hudSaveId);
|
||||
return Path.Combine(HudSaveDir, key + ".hudlayout");
|
||||
}
|
||||
|
||||
private static string SanitizeHudKey(string s) => MeshPainter.SanitizeFileKey(s);
|
||||
|
||||
[Serializable]
|
||||
private class HudState
|
||||
{
|
||||
public int version = HudFileVersion;
|
||||
public bool hasValues; // false = no painter was bound when saved -> restore positions only
|
||||
public bool hasCamoValues; // false = pre-camo save file -> keep the material's authored camo (no clobber)
|
||||
public bool hasHeightValues; // false = pre-height save file -> keep the material's authored height knobs (no clobber)
|
||||
public List<PanelPos> panels = new List<PanelPos>();
|
||||
public HudValues values = new HudValues();
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
private struct PanelPos
|
||||
{
|
||||
public string name;
|
||||
public Vector2 pos; // anchoredPosition (point-anchored panels -> fully determines placement)
|
||||
}
|
||||
|
||||
// Mirrors exactly the painter fields the HUD widgets bind to (PaintHud.WirePanel).
|
||||
[Serializable]
|
||||
private class HudValues
|
||||
{
|
||||
public bool paintAlbedo, paintMetallicSmoothness, paintOcclusion, paintEmission, spray;
|
||||
public Color brushColor;
|
||||
public float metallic, smoothness, metallicSmoothnessOpacity, occlusion, occlusionOpacity;
|
||||
public Color emissionColor;
|
||||
public float emissionIntensity;
|
||||
public int brushSize;
|
||||
public int activeBrushIndex;
|
||||
public int sprayDots;
|
||||
public float sprayDotSizeMin, sprayDotSizeMax, sprayDotAlphaMin, sprayDotAlphaMax;
|
||||
public float camoHalfLambert, camoHalfLambertBrightness, camoSpecularDampen; // PlayerCamoLit material knobs
|
||||
public float occlusionThreshold, heightNormalIntensity, parallaxIntensity; // painted-height knobs (material-backed)
|
||||
}
|
||||
|
||||
/// <summary>A draggable panel was dropped -> persist the layout immediately.</summary>
|
||||
private void OnPanelMoved() => SaveHudState();
|
||||
|
||||
/// <summary>Writes the current panel positions and bound painter values to disk.</summary>
|
||||
public void SaveHudState()
|
||||
{
|
||||
if (!persistHud || !_panel) return;
|
||||
|
||||
try
|
||||
{
|
||||
var state = new HudState();
|
||||
var panel = _panel.transform;
|
||||
|
||||
foreach (var name in DraggablePanels)
|
||||
{
|
||||
if (GeometryUtil.FindDeep(panel, name) is RectTransform rt)
|
||||
state.panels.Add(new PanelPos { name = name, pos = rt.anchoredPosition });
|
||||
}
|
||||
|
||||
if (painter)
|
||||
{
|
||||
CopyValues(painter, state.values);
|
||||
state.hasValues = true;
|
||||
state.hasCamoValues = painter.IsReady; // only claim camo when it was actually readable
|
||||
state.hasHeightValues = painter.IsReady; // height knobs share the camo readiness
|
||||
}
|
||||
else if (_hasCarry)
|
||||
{
|
||||
// No painter bound at teardown (e.g. quit with no avatar) -> persist the last mirrored values.
|
||||
state.values = _carry;
|
||||
state.hasValues = true;
|
||||
state.hasCamoValues = _camoEverReady; // _carry holds valid camo only after a ready painter
|
||||
state.hasHeightValues = _camoEverReady;
|
||||
}
|
||||
|
||||
Directory.CreateDirectory(HudSaveDir);
|
||||
File.WriteAllText(HudSavePath(), JsonUtility.ToJson(state, true));
|
||||
|
||||
// Refresh the on-disk baseline so the debounce does not immediately re-save unchanged values.
|
||||
if (painter) CopyValues(painter, _savedValues);
|
||||
_hasSavedBaseline = painter;
|
||||
_hudSavePending = false;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[PaintHud] Saving HUD state failed: {e.Message}", this);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Restores the saved panel positions and brush values. Values are written into the painter (the
|
||||
/// source of truth the widgets read) and then pushed to every widget via <see cref="SyncFromPainter"/>,
|
||||
/// reusing the exact same painter -> HUD path the pipette pick uses. Positions are applied last.
|
||||
/// </summary>
|
||||
public void RestoreHudState()
|
||||
{
|
||||
if (!persistHud || !_panel) return;
|
||||
|
||||
var state = LoadHudState();
|
||||
if (state == null) return;
|
||||
|
||||
if (painter && state.hasValues && state.values != null)
|
||||
{
|
||||
ApplyValues(painter, state.values);
|
||||
SyncFromPainter();
|
||||
}
|
||||
|
||||
// Camo is material-backed -> stash for the LateUpdate ready-gate. hasCamoValues guards pre-camo
|
||||
// save files (applying their 0 would clobber the authored camo).
|
||||
if (state.hasCamoValues && state.values != null)
|
||||
{
|
||||
_pendingCamo = new Vector3(state.values.camoHalfLambert,
|
||||
state.values.camoHalfLambertBrightness,
|
||||
state.values.camoSpecularDampen);
|
||||
_hasPendingCamo = true;
|
||||
}
|
||||
|
||||
// Same deferral + guard for the painted-height knobs.
|
||||
if (state.hasHeightValues && state.values != null)
|
||||
{
|
||||
_pendingHeight = new Vector3(state.values.occlusionThreshold,
|
||||
state.values.heightNormalIntensity,
|
||||
state.values.parallaxIntensity);
|
||||
_hasPendingHeight = true;
|
||||
}
|
||||
|
||||
// Point-anchored panels: anchoredPosition is resolution-independent under the canvas'
|
||||
// ScaleWithScreenSize scaler, so the raw saved value places them identically next session.
|
||||
var root = _panel.transform;
|
||||
if (state.panels != null)
|
||||
foreach (var p in state.panels)
|
||||
if (GeometryUtil.FindDeep(root, p.name) is RectTransform rt)
|
||||
rt.anchoredPosition = p.pos;
|
||||
}
|
||||
|
||||
// --- In-session carry: brush values survive the avatar respawn a scene switch triggers ---
|
||||
|
||||
private void LateUpdate()
|
||||
{
|
||||
if (painter)
|
||||
{
|
||||
// Apply restored/carried camo + height to the material once it is ready, then seed the
|
||||
// sliders. Runs BEFORE the carry mirror + change detection below, so the just-applied value
|
||||
// is already the baseline and never re-triggers a redundant auto-save.
|
||||
if (!_camoSynced && painter.IsReady)
|
||||
{
|
||||
// Capture the authored camo as the reset default BEFORE any pending value is applied over
|
||||
// it - once per HUD, so a reset always returns to the genuine material default.
|
||||
if (!_hasDefaultCamo)
|
||||
{
|
||||
CaptureCamoDefaults();
|
||||
_hasDefaultCamo = true;
|
||||
}
|
||||
|
||||
if (_hasPendingCamo)
|
||||
{
|
||||
painter.CamoKnobs = _pendingCamo;
|
||||
_hasPendingCamo = false;
|
||||
}
|
||||
if (_hasPendingHeight)
|
||||
{
|
||||
painter.HeightKnobs = _pendingHeight;
|
||||
_hasPendingHeight = false;
|
||||
}
|
||||
SyncCamoSliders();
|
||||
|
||||
_savedValues.camoHalfLambert = _lastSeenValues.camoHalfLambert = painter.CamoHalfLambert;
|
||||
_savedValues.camoHalfLambertBrightness = _lastSeenValues.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
|
||||
_savedValues.camoSpecularDampen = _lastSeenValues.camoSpecularDampen = painter.CamoSpecularDampen;
|
||||
_savedValues.occlusionThreshold = _lastSeenValues.occlusionThreshold = painter.OcclusionThreshold;
|
||||
_savedValues.heightNormalIntensity = _lastSeenValues.heightNormalIntensity = painter.HeightNormalIntensity;
|
||||
_savedValues.parallaxIntensity = _lastSeenValues.parallaxIntensity = painter.ParallaxIntensity;
|
||||
|
||||
_camoEverReady = true;
|
||||
_camoSynced = true;
|
||||
}
|
||||
|
||||
// Mirror the live painter every frame; the next BindPainter (after a respawn) seeds the fresh
|
||||
// painter from this snapshot. Captured while the painter lives - immune to despawn timing.
|
||||
CaptureCarry();
|
||||
|
||||
// Debounced auto-save: (re)arm the settle timer only on frames where a value actually CHANGED
|
||||
// since the last frame - comparing against the on-disk baseline instead would push the
|
||||
// deadline forward forever (an edit differs from disk until written), so it would never fire.
|
||||
if (persistHud && _hasSavedBaseline && !ValuesEqual(painter, _lastSeenValues))
|
||||
{
|
||||
CopyValues(painter, _lastSeenValues);
|
||||
if (!ValuesEqual(painter, _savedValues))
|
||||
{
|
||||
_hudSavePending = true;
|
||||
_hudSaveDueTime = Time.unscaledTime + HudSaveDebounce;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (_hudSavePending && Time.unscaledTime >= _hudSaveDueTime)
|
||||
SaveHudState();
|
||||
}
|
||||
|
||||
/// <summary>Marks the current brush values as the on-disk baseline so auto-save only fires on real edits.</summary>
|
||||
private void SnapshotSavedBaseline()
|
||||
{
|
||||
if (painter)
|
||||
{
|
||||
CopyValues(painter, _savedValues);
|
||||
CopyValues(painter, _lastSeenValues);
|
||||
}
|
||||
_hasSavedBaseline = painter;
|
||||
_hudSavePending = false;
|
||||
}
|
||||
|
||||
/// <summary>True when every brush value on the painter still matches the snapshot last written to disk.</summary>
|
||||
private static bool ValuesEqual(MeshPainter p, HudValues v)
|
||||
{
|
||||
return p.paintAlbedo == v.paintAlbedo
|
||||
&& p.paintMetallicSmoothness == v.paintMetallicSmoothness
|
||||
&& p.paintOcclusion == v.paintOcclusion
|
||||
&& p.paintEmission == v.paintEmission
|
||||
&& p.spray == v.spray
|
||||
&& p.brushColor == v.brushColor
|
||||
&& p.metallic == v.metallic
|
||||
&& p.smoothness == v.smoothness
|
||||
&& p.metallicSmoothnessOpacity == v.metallicSmoothnessOpacity
|
||||
&& p.occlusion == v.occlusion
|
||||
&& p.occlusionOpacity == v.occlusionOpacity
|
||||
&& p.emissionColor == v.emissionColor
|
||||
&& p.emissionIntensity == v.emissionIntensity
|
||||
&& p.brushSize == v.brushSize
|
||||
&& p.activeBrushIndex == v.activeBrushIndex
|
||||
&& p.sprayDots == v.sprayDots
|
||||
&& p.sprayDotSizeMin == v.sprayDotSizeMin
|
||||
&& p.sprayDotSizeMax == v.sprayDotSizeMax
|
||||
&& p.sprayDotAlphaMin == v.sprayDotAlphaMin
|
||||
&& p.sprayDotAlphaMax == v.sprayDotAlphaMax
|
||||
&& p.CamoHalfLambert == v.camoHalfLambert
|
||||
&& p.CamoHalfLambertBrightness == v.camoHalfLambertBrightness
|
||||
&& p.CamoSpecularDampen == v.camoSpecularDampen
|
||||
&& p.OcclusionThreshold == v.occlusionThreshold
|
||||
&& p.HeightNormalIntensity == v.heightNormalIntensity
|
||||
&& p.ParallaxIntensity == v.parallaxIntensity;
|
||||
}
|
||||
|
||||
/// <summary>Snapshots the bound painter's brush values into the in-memory mirror.</summary>
|
||||
public void CaptureCarry()
|
||||
{
|
||||
if (!painter) return;
|
||||
CopyValues(painter, _carry);
|
||||
_hasCarry = true;
|
||||
}
|
||||
|
||||
/// <summary>Writes the mirrored brush values onto the (freshly bound) painter and pushes them to the widgets.</summary>
|
||||
public void ApplyCarry()
|
||||
{
|
||||
if (!painter || !_hasCarry) return;
|
||||
ApplyValues(painter, _carry);
|
||||
SyncFromPainter();
|
||||
|
||||
// Camo is material-backed -> defer to the ready-gate using the carried (last live) values. Guarded
|
||||
// by _camoEverReady so a carry taken before any material was ready can't apply a stale 0.
|
||||
if (_camoEverReady)
|
||||
{
|
||||
_pendingCamo = new Vector3(_carry.camoHalfLambert,
|
||||
_carry.camoHalfLambertBrightness,
|
||||
_carry.camoSpecularDampen);
|
||||
_hasPendingCamo = true;
|
||||
_pendingHeight = new Vector3(_carry.occlusionThreshold,
|
||||
_carry.heightNormalIntensity,
|
||||
_carry.parallaxIntensity);
|
||||
_hasPendingHeight = true;
|
||||
}
|
||||
}
|
||||
|
||||
private static void CopyValues(MeshPainter from, HudValues v)
|
||||
{
|
||||
v.paintAlbedo = from.paintAlbedo;
|
||||
v.paintMetallicSmoothness = from.paintMetallicSmoothness;
|
||||
v.paintOcclusion = from.paintOcclusion;
|
||||
v.paintEmission = from.paintEmission;
|
||||
v.spray = from.spray;
|
||||
v.brushColor = from.brushColor;
|
||||
v.metallic = from.metallic;
|
||||
v.smoothness = from.smoothness;
|
||||
v.metallicSmoothnessOpacity = from.metallicSmoothnessOpacity;
|
||||
v.occlusion = from.occlusion;
|
||||
v.occlusionOpacity = from.occlusionOpacity;
|
||||
v.emissionColor = from.emissionColor;
|
||||
v.emissionIntensity = from.emissionIntensity;
|
||||
v.brushSize = from.brushSize;
|
||||
v.activeBrushIndex = from.activeBrushIndex;
|
||||
v.sprayDots = from.sprayDots;
|
||||
v.sprayDotSizeMin = from.sprayDotSizeMin;
|
||||
v.sprayDotSizeMax = from.sprayDotSizeMax;
|
||||
v.sprayDotAlphaMin = from.sprayDotAlphaMin;
|
||||
v.sprayDotAlphaMax = from.sprayDotAlphaMax;
|
||||
|
||||
// Camo getters read 0 until the material exists - only mirror once ready, otherwise preserve the
|
||||
// desired values already in the target (a restore/carry the ready-gate still has to apply).
|
||||
if (from.IsReady)
|
||||
{
|
||||
v.camoHalfLambert = from.CamoHalfLambert;
|
||||
v.camoHalfLambertBrightness = from.CamoHalfLambertBrightness;
|
||||
v.camoSpecularDampen = from.CamoSpecularDampen;
|
||||
v.occlusionThreshold = from.OcclusionThreshold;
|
||||
v.heightNormalIntensity = from.HeightNormalIntensity;
|
||||
v.parallaxIntensity = from.ParallaxIntensity;
|
||||
}
|
||||
}
|
||||
|
||||
private static void ApplyValues(MeshPainter to, HudValues v)
|
||||
{
|
||||
to.paintAlbedo = v.paintAlbedo;
|
||||
to.paintMetallicSmoothness = v.paintMetallicSmoothness;
|
||||
to.paintOcclusion = v.paintOcclusion;
|
||||
to.paintEmission = v.paintEmission;
|
||||
to.spray = v.spray;
|
||||
to.brushColor = v.brushColor;
|
||||
to.metallic = v.metallic;
|
||||
to.smoothness = v.smoothness;
|
||||
to.metallicSmoothnessOpacity = v.metallicSmoothnessOpacity;
|
||||
to.occlusion = v.occlusion;
|
||||
to.occlusionOpacity = v.occlusionOpacity;
|
||||
to.emissionColor = v.emissionColor;
|
||||
to.emissionIntensity = v.emissionIntensity;
|
||||
to.brushSize = v.brushSize;
|
||||
to.activeBrushIndex = v.activeBrushIndex;
|
||||
to.sprayDots = v.sprayDots;
|
||||
to.sprayDotSizeMin = v.sprayDotSizeMin;
|
||||
to.sprayDotSizeMax = v.sprayDotSizeMax;
|
||||
to.sprayDotAlphaMin = v.sprayDotAlphaMin;
|
||||
to.sprayDotAlphaMax = v.sprayDotAlphaMax;
|
||||
|
||||
// Camo + height setters are a no-op until the material exists; the guaranteed deferred apply runs
|
||||
// from the LateUpdate ready-gate via _pendingCamo/_pendingHeight. Writing here too keeps Copy/Apply/Equal symmetric.
|
||||
to.CamoHalfLambert = v.camoHalfLambert;
|
||||
to.CamoHalfLambertBrightness = v.camoHalfLambertBrightness;
|
||||
to.CamoSpecularDampen = v.camoSpecularDampen;
|
||||
to.OcclusionThreshold = v.occlusionThreshold;
|
||||
to.HeightNormalIntensity = v.heightNormalIntensity;
|
||||
to.ParallaxIntensity = v.parallaxIntensity;
|
||||
}
|
||||
|
||||
private HudState LoadHudState()
|
||||
{
|
||||
string path = HudSavePath();
|
||||
if (!File.Exists(path)) return null;
|
||||
|
||||
try
|
||||
{
|
||||
var state = JsonUtility.FromJson<HudState>(File.ReadAllText(path));
|
||||
if (state == null || state.version != HudFileVersion) return null;
|
||||
return state;
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[PaintHud] Loading HUD state failed: {e.Message}", this);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Reset to authored defaults (drives the HUD's reset button) ---
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots the authored panel positions from the freshly instantiated layout, before any saved
|
||||
/// layout is restored over them. The runtime reset restores exactly these. Called once from Start
|
||||
/// (right after the layout prefab is instantiated); idempotent.
|
||||
/// </summary>
|
||||
public void CaptureAuthoredLayout()
|
||||
{
|
||||
if (_hasAuthoredLayout || !_panel) return;
|
||||
var root = _panel.transform;
|
||||
foreach (var name in DraggablePanels)
|
||||
if (GeometryUtil.FindDeep(root, name) is RectTransform rt)
|
||||
_authoredPanelPos[name] = rt.anchoredPosition;
|
||||
_hasAuthoredLayout = true;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Snapshots the painter's prefab-default brush values on the first bind, before carry/restore mutate
|
||||
/// the live painter. Non-camo only here (a freshly spawned painter holds its prefab defaults); the
|
||||
/// material-backed camo is not readable yet and is captured later in the LateUpdate ready-gate.
|
||||
/// </summary>
|
||||
private void CaptureDefaults()
|
||||
{
|
||||
if (_hasDefaults || !painter) return;
|
||||
CopyValues(painter, _defaults); // CopyValues skips camo until IsReady -> camo stays for the ready-gate
|
||||
_hasDefaults = true;
|
||||
}
|
||||
|
||||
/// <summary>Snapshots the live camo + height knobs into the reset defaults (caller decides _hasDefaultCamo).</summary>
|
||||
private void CaptureCamoDefaults()
|
||||
{
|
||||
_defaults.camoHalfLambert = painter.CamoHalfLambert;
|
||||
_defaults.camoHalfLambertBrightness = painter.CamoHalfLambertBrightness;
|
||||
_defaults.camoSpecularDampen = painter.CamoSpecularDampen;
|
||||
_defaults.occlusionThreshold = painter.OcclusionThreshold;
|
||||
_defaults.heightNormalIntensity = painter.HeightNormalIntensity;
|
||||
_defaults.parallaxIntensity = painter.ParallaxIntensity;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resets the HUD to its authored defaults, exactly as if the saved layout file had been deleted: the
|
||||
/// panels jump back to their prefab positions and every brush value returns to the painter's prefab /
|
||||
/// material defaults. The save file is deleted too, so the reset also survives the next start.
|
||||
/// </summary>
|
||||
[ContextMenu("Reset HUD Layout And Values")]
|
||||
public void ResetHudToDefaults()
|
||||
{
|
||||
ClearSavedHudState();
|
||||
|
||||
if (_panel && _hasAuthoredLayout)
|
||||
{
|
||||
var root = _panel.transform;
|
||||
foreach (var kv in _authoredPanelPos)
|
||||
if (GeometryUtil.FindDeep(root, kv.Key) is RectTransform rt)
|
||||
rt.anchoredPosition = kv.Value;
|
||||
}
|
||||
|
||||
if (painter && _hasDefaults)
|
||||
{
|
||||
// Reset clicked before the ready-gate captured the camo defaults? keep the live camo rather than
|
||||
// slamming it to the not-yet-filled 0 (which would flatten the material's lighting).
|
||||
if (!_hasDefaultCamo && painter.IsReady)
|
||||
CaptureCamoDefaults();
|
||||
|
||||
ApplyValues(painter, _defaults);
|
||||
SyncFromPainter();
|
||||
SyncCamoSliders();
|
||||
|
||||
// ApplyValues wrote the camo + height knobs straight to the material (painter is ready at a
|
||||
// runtime reset), so drop any still-pending restored values the ready-gate would re-apply over the default.
|
||||
_hasPendingCamo = false;
|
||||
_hasPendingHeight = false;
|
||||
|
||||
CaptureCarry(); // a later respawn now carries the defaults, not the pre-reset values
|
||||
SnapshotSavedBaseline(); // values match the (now deleted) save -> the reset itself triggers no auto-save
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Deletes the saved HUD layout (next start opens at the authored layout / defaults). Editor convenience.</summary>
|
||||
[ContextMenu("Clear Saved HUD Layout")]
|
||||
public void ClearSavedHudState()
|
||||
{
|
||||
try
|
||||
{
|
||||
string path = HudSavePath();
|
||||
if (File.Exists(path)) File.Delete(path);
|
||||
Debug.Log($"[PaintHud] Cleared saved HUD layout: {path}", this);
|
||||
}
|
||||
catch (Exception e)
|
||||
{
|
||||
Debug.LogWarning($"[PaintHud] Clearing saved HUD layout failed: {e.Message}", this);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 4fb3415c5348e5c49955e222fd43a55c
|
||||
@@ -0,0 +1,632 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
using UnityEngine.EventSystems;
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
using UnityEngine.InputSystem.UI;
|
||||
#endif
|
||||
|
||||
/// <summary>
|
||||
/// In-game paint UI. Shows its panels only while the active <see cref="GameMode"/> is
|
||||
/// <see cref="PaintMode"/> (follows the manager's ModeChanged) and binds its widgets directly to the
|
||||
/// live <see cref="MeshPainter"/> fields, so edits apply immediately.
|
||||
///
|
||||
/// The layout (independently draggable sub-panels per channel + a global brush card) comes from the
|
||||
/// authored prefab (<see cref="layoutPrefab"/>). The BINDINGS live in code and are (re)applied at
|
||||
/// runtime by <see cref="WirePanel"/> against stable widget names - the prefab stores only the
|
||||
/// visual layout, never behaviour.
|
||||
/// </summary>
|
||||
[DisallowMultipleComponent]
|
||||
public partial class PaintHud : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Mode manager driving visibility. Empty = found automatically.")]
|
||||
public GameModeManager modeManager;
|
||||
|
||||
[Tooltip("Painter the HUD edits. Empty = the manager's painter, else found automatically.")]
|
||||
public MeshPainter painter;
|
||||
|
||||
[Tooltip("Authored panel layout. Instantiated and bound at startup.")]
|
||||
public GameObject layoutPrefab;
|
||||
|
||||
// Stable widget names shared by the baked prefab and the binder.
|
||||
private const string PanelGeneral = "Panel_General";
|
||||
private const string PanelSpray = "Panel_Spray";
|
||||
private const string PanelAlbedo = "Panel_Albedo";
|
||||
private const string PanelMetallicSmoothness = "Panel_MetallicSmoothness";
|
||||
private const string PanelOcclusion = "Panel_Occlusion";
|
||||
private const string PanelEmission = "Panel_Emission";
|
||||
private const string PanelBrushSize = "Panel_BrushSize";
|
||||
private const string PanelPaintings = "Panel_Paintings";
|
||||
private const string ToggleAlbedo = "Toggle_Albedo";
|
||||
private const string ToggleMetallicSmoothness = "Toggle_MetallicSmoothness";
|
||||
private const string ToggleOcclusion = "Toggle_Occlusion";
|
||||
private const string ToggleEmission = "Toggle_Emission";
|
||||
private const string ToggleSpray = "Toggle_Spray";
|
||||
private const string TogglePipette = "Toggle_Pipette";
|
||||
private const string PickerAlbedo = "Picker_Albedo";
|
||||
private const string PickerEmission = "Picker_Emission";
|
||||
private const string SliderAlbedoAlpha = "Slider_AlbedoAlpha";
|
||||
private const string SliderEmissionAlpha = "Slider_EmissionAlpha";
|
||||
private const string SliderMetallic = "Slider_Metallic";
|
||||
private const string SliderSmoothness = "Slider_Smoothness";
|
||||
private const string SliderMetallicSmoothnessAlpha = "Slider_MetallicSmoothnessAlpha";
|
||||
private const string SliderOcclusion = "Slider_Occlusion";
|
||||
private const string SliderOcclusionAlpha = "Slider_OcclusionAlpha";
|
||||
private const string SliderEmissionIntensity = "Slider_EmissionIntensity";
|
||||
private const string SliderBrushSize = "Slider_BrushSize";
|
||||
private const string SliderSprayDots = "Slider_SprayDots";
|
||||
private const string SliderSprayDotSizeMin = "Slider_SprayDotSizeMin";
|
||||
private const string SliderSprayDotSizeMax = "Slider_SprayDotSizeMax";
|
||||
private const string SliderSprayDotAlphaMin = "Slider_SprayDotAlphaMin";
|
||||
private const string SliderSprayDotAlphaMax = "Slider_SprayDotAlphaMax";
|
||||
private const string SliderHalfLambert = "Slider_HalfLambert";
|
||||
private const string SliderHalfLambertBrightness = "Slider_HalfLambertBrightness";
|
||||
private const string SliderSpecularDamp = "Slider_SpecularDamp";
|
||||
private const string SliderOcclusionThreshold = "Slider_OcclusionThreshold";
|
||||
private const string SliderNormalIntensity = "Slider_NormalIntensity";
|
||||
private const string SliderParallaxIntensity = "Slider_ParallaxIntensity";
|
||||
private const string MaskSelector = "MaskSelector";
|
||||
private const string MaskSwatchPrefix = "MaskSwatch_";
|
||||
|
||||
// Panels made draggable at runtime - grabbable from any non-widget surface (both prefab and code-gen paths).
|
||||
private static readonly string[] DraggablePanels =
|
||||
{
|
||||
PanelAlbedo,
|
||||
PanelMetallicSmoothness,
|
||||
PanelOcclusion,
|
||||
PanelEmission,
|
||||
PanelBrushSize,
|
||||
PanelSpray,
|
||||
PanelGeneral,
|
||||
PanelPaintings
|
||||
};
|
||||
|
||||
private GameObject _panel;
|
||||
private Font _font;
|
||||
private bool _camoSynced; // camo sliders are material-backed -> seeded once the painter's material instance exists
|
||||
|
||||
private void Awake()
|
||||
{
|
||||
if (!modeManager) modeManager = FindFirstObjectByType<GameModeManager>();
|
||||
}
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (modeManager)
|
||||
{
|
||||
modeManager.ModeChanged += OnModeChanged;
|
||||
modeManager.PainterChanged += OnPainterChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (modeManager)
|
||||
{
|
||||
modeManager.ModeChanged -= OnModeChanged;
|
||||
modeManager.PainterChanged -= OnPainterChanged;
|
||||
}
|
||||
}
|
||||
|
||||
private void Start()
|
||||
{
|
||||
_font = LoadFont();
|
||||
|
||||
EnsureEventSystem();
|
||||
EnsureCanvas();
|
||||
|
||||
if (layoutPrefab)
|
||||
{
|
||||
var go = Instantiate(layoutPrefab, transform);
|
||||
go.name = layoutPrefab.name; // strip the "(Clone)" suffix
|
||||
_panel = go;
|
||||
}
|
||||
|
||||
// Snapshot the authored panel positions now, while the layout is untouched - before RestoreHudState
|
||||
// (in BindPainter) overwrites them. The reset button restores exactly these.
|
||||
CaptureAuthoredLayout();
|
||||
|
||||
// The paint library now lives inside the authored layout prefab; bind its widgets like the brush panel.
|
||||
BindLibrary(_panel ? _panel.transform : null);
|
||||
|
||||
// Bind as soon as a painter exists. With networked spawn-on-connect there is none at scene
|
||||
// start; PainterChanged then wires the widgets once the local avatar spawns.
|
||||
var p = painter;
|
||||
if (!p) p = modeManager ? modeManager.Painter : null;
|
||||
if (!p) p = FindFirstObjectByType<MeshPainter>();
|
||||
if (p) BindPainter(p);
|
||||
|
||||
// Set initial visibility (the manager's first SwitchTo may have fired before this Start).
|
||||
OnModeChanged(modeManager ? modeManager.ActiveMode : null);
|
||||
}
|
||||
|
||||
private void OnPainterChanged(MeshPainter newPainter)
|
||||
{
|
||||
if (newPainter) BindPainter(newPainter);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// (Re)binds the HUD's widgets to a live painter. Called from Start when one already exists
|
||||
/// (non-networked scene) or from the manager's PainterChanged event when the local networked
|
||||
/// avatar spawns. Binds exactly once per painter: re-binding the same painter is a no-op, so
|
||||
/// the deferred network path and the immediate scene path never double-add widget listeners.
|
||||
/// </summary>
|
||||
private void BindPainter(MeshPainter newPainter)
|
||||
{
|
||||
if (!newPainter || newPainter == painter || !_panel) return;
|
||||
|
||||
UnhookPainterEvents();
|
||||
painter = newPainter;
|
||||
_camoSynced = false; // re-seed the camo sliders once this painter's material instance is ready
|
||||
|
||||
WirePanel(_panel.transform);
|
||||
|
||||
// Re-sync every widget when the pipette adopts a sampled material (painter -> HUD).
|
||||
painter.Picked += SyncFromPainter;
|
||||
// Re-sync the brush-size slider when Shift+wheel resizes the brush in-world (painter -> HUD).
|
||||
painter.BrushSizeChanged += SyncBrushSize;
|
||||
|
||||
// Snapshot the prefab-default brush values before carry/restore mutate them (the reset button
|
||||
// returns to exactly these). Once per HUD.
|
||||
CaptureDefaults();
|
||||
|
||||
// First bind this session: restore from disk. A later bind is an avatar respawn -> carry the
|
||||
// in-session values onto the fresh painter instead.
|
||||
if (_hasCarry) ApplyCarry();
|
||||
else RestoreHudState();
|
||||
CaptureCarry(); // seed/refresh the mirror from the now-current painter
|
||||
SnapshotSavedBaseline(); // values now match disk -> auto-save only fires on a real edit
|
||||
|
||||
UpdateButtonStates(); // enable Save now that a painter is bound
|
||||
}
|
||||
|
||||
private void OnDestroy() => UnhookPainterEvents();
|
||||
|
||||
private void UnhookPainterEvents()
|
||||
{
|
||||
if (!painter) return;
|
||||
painter.Picked -= SyncFromPainter;
|
||||
painter.BrushSizeChanged -= SyncBrushSize;
|
||||
}
|
||||
|
||||
private void OnModeChanged(GameMode mode)
|
||||
{
|
||||
// Require a bound painter too: pre-spawn the manager sits in a player-less Paint mode, but there
|
||||
// is nothing to paint yet - keep the HUD hidden until a painter is bound.
|
||||
bool paint = mode is PaintMode && painter;
|
||||
if (_panel) _panel.SetActive(paint); // toggles the brush panels and the library panel together
|
||||
if (paint)
|
||||
{
|
||||
if (_libraryBound) RefreshList(); // pick up slots saved/changed since last shown
|
||||
}
|
||||
else HideDialogs();
|
||||
}
|
||||
|
||||
private static Font LoadFont()
|
||||
{
|
||||
var f = Resources.GetBuiltinResource<Font>("LegacyRuntime.ttf");
|
||||
if (!f) f = Resources.GetBuiltinResource<Font>("Arial.ttf");
|
||||
return f;
|
||||
}
|
||||
|
||||
// --- Canvas ---
|
||||
|
||||
private void EnsureCanvas()
|
||||
{
|
||||
var canvas = GetComponent<Canvas>();
|
||||
if (!canvas) canvas = gameObject.AddComponent<Canvas>();
|
||||
canvas.renderMode = RenderMode.ScreenSpaceOverlay;
|
||||
canvas.sortingOrder = 100;
|
||||
|
||||
var scaler = GetComponent<CanvasScaler>();
|
||||
if (!scaler) scaler = gameObject.AddComponent<CanvasScaler>();
|
||||
scaler.uiScaleMode = CanvasScaler.ScaleMode.ScaleWithScreenSize;
|
||||
scaler.referenceResolution = new Vector2(1920f, 1080f);
|
||||
scaler.screenMatchMode = CanvasScaler.ScreenMatchMode.Expand;
|
||||
|
||||
if (!GetComponent<GraphicRaycaster>()) gameObject.AddComponent<GraphicRaycaster>();
|
||||
}
|
||||
|
||||
private void EnsureEventSystem()
|
||||
{
|
||||
if (FindFirstObjectByType<EventSystem>()) return;
|
||||
|
||||
var es = new GameObject("EventSystem", typeof(EventSystem));
|
||||
#if ENABLE_INPUT_SYSTEM
|
||||
var module = es.AddComponent<InputSystemUIInputModule>();
|
||||
if (!module.actionsAsset) module.AssignDefaultActions();
|
||||
#else
|
||||
es.AddComponent<StandaloneInputModule>();
|
||||
#endif
|
||||
}
|
||||
|
||||
// --- Wiring (behaviour; shared by the code-gen and prefab paths) ---
|
||||
|
||||
private void WirePanel(Transform panel)
|
||||
{
|
||||
EnsureFonts(panel); // builtin font references may not survive prefab serialization
|
||||
|
||||
WireToggle(panel, ToggleAlbedo, painter && painter.paintAlbedo, v => { if (painter) painter.paintAlbedo = v; });
|
||||
WireToggle(panel, ToggleMetallicSmoothness, painter && painter.paintMetallicSmoothness, v => { if (painter) painter.paintMetallicSmoothness = v; });
|
||||
WireToggle(panel, ToggleOcclusion, painter && painter.paintOcclusion, v => { if (painter) painter.paintOcclusion = v; });
|
||||
WireToggle(panel, ToggleEmission, painter && painter.paintEmission, v => { if (painter) painter.paintEmission = v; });
|
||||
WireToggle(panel, ToggleSpray, painter && painter.spray, v => { if (painter) painter.spray = v; });
|
||||
WireToggle(panel, TogglePipette, painter && painter.pipette, v => { if (painter) painter.pipette = v; });
|
||||
|
||||
WirePanelFade(panel, PanelAlbedo, ToggleAlbedo);
|
||||
WirePanelFade(panel, PanelMetallicSmoothness, ToggleMetallicSmoothness);
|
||||
WirePanelFade(panel, PanelOcclusion, ToggleOcclusion);
|
||||
WirePanelFade(panel, PanelEmission, ToggleEmission);
|
||||
WirePanelFade(panel, PanelSpray, ToggleSpray);
|
||||
|
||||
// Picker drives only RGB (keeping the painter's current alpha); a standalone slider drives alpha.
|
||||
WirePicker(panel, PickerAlbedo, painter ? painter.brushColor : Color.red,
|
||||
c => { if (painter) painter.brushColor = WithRgb(painter.brushColor, c); });
|
||||
WireSlider(panel, SliderAlbedoAlpha, painter ? painter.brushColor.a : .6f,
|
||||
v => { if (painter) painter.brushColor = WithAlpha(painter.brushColor, v); });
|
||||
|
||||
WireSlider(panel, SliderMetallic, painter ? painter.metallic : 0f, v => { if (painter) painter.metallic = v; });
|
||||
WireSlider(panel, SliderSmoothness, painter ? painter.smoothness : 0.5f, v => { if (painter) painter.smoothness = v; });
|
||||
WireSlider(panel, SliderMetallicSmoothnessAlpha, painter ? painter.metallicSmoothnessOpacity : .6f, v => { if (painter) painter.metallicSmoothnessOpacity = v; });
|
||||
WireSlider(panel, SliderOcclusion, painter ? painter.occlusion : 0.5f, v => { if (painter) painter.occlusion = v; });
|
||||
WireSlider(panel, SliderOcclusionAlpha, painter ? painter.occlusionOpacity : .6f, v => { if (painter) painter.occlusionOpacity = v; });
|
||||
|
||||
WirePicker(panel, PickerEmission, painter ? painter.emissionColor : Color.black,
|
||||
c => { if (painter) painter.emissionColor = WithRgb(painter.emissionColor, c); });
|
||||
WireSlider(panel, SliderEmissionIntensity, painter ? painter.emissionIntensity : 1f, v => { if (painter) painter.emissionIntensity = v; });
|
||||
WireSlider(panel, SliderEmissionAlpha, painter ? painter.emissionColor.a : .6f,
|
||||
v => { if (painter) painter.emissionColor = WithAlpha(painter.emissionColor, v); });
|
||||
|
||||
WireSlider(panel, SliderBrushSize, painter ? painter.brushSize : 16f, v => { if (painter) painter.brushSize = Mathf.RoundToInt(v); });
|
||||
|
||||
WireMaskSelector(panel);
|
||||
|
||||
WireSlider(panel, SliderSprayDots, painter ? painter.sprayDots : 24f, v => { if (painter) painter.sprayDots = Mathf.RoundToInt(v); });
|
||||
WireSlider(panel, SliderSprayDotSizeMin, painter ? painter.sprayDotSizeMin : .05f, v => { if (painter) painter.sprayDotSizeMin = v; });
|
||||
WireSlider(panel, SliderSprayDotSizeMax, painter ? painter.sprayDotSizeMax : .18f, v => { if (painter) painter.sprayDotSizeMax = v; });
|
||||
WireSlider(panel, SliderSprayDotAlphaMin, painter ? painter.sprayDotAlphaMin : .2f, v => { if (painter) painter.sprayDotAlphaMin = v; });
|
||||
WireSlider(panel, SliderSprayDotAlphaMax, painter ? painter.sprayDotAlphaMax : .6f, v => { if (painter) painter.sprayDotAlphaMax = v; });
|
||||
|
||||
// Camo knobs are material-backed and not readable yet (the HUD binds before the painter's Start);
|
||||
// SyncCamoSliders re-seeds them once IsReady. Setting a slider before then is a safe no-op.
|
||||
WireSlider(panel, SliderHalfLambert, painter ? painter.CamoHalfLambert : 0f, v => { if (painter) painter.CamoHalfLambert = v; });
|
||||
WireSlider(panel, SliderHalfLambertBrightness, painter ? painter.CamoHalfLambertBrightness : 0f, v => { if (painter) painter.CamoHalfLambertBrightness = v; });
|
||||
WireSlider(panel, SliderSpecularDamp, painter ? painter.CamoSpecularDampen : 0f, v => { if (painter) painter.CamoSpecularDampen = v; });
|
||||
|
||||
// Painted-height knobs: material-backed like the camo sliders above.
|
||||
WireSlider(panel, SliderOcclusionThreshold, painter ? painter.OcclusionThreshold : 0.2f, v => { if (painter) painter.OcclusionThreshold = v; });
|
||||
WireSlider(panel, SliderNormalIntensity, painter ? painter.HeightNormalIntensity : 1.4f, v => { if (painter) painter.HeightNormalIntensity = v; });
|
||||
WireSlider(panel, SliderParallaxIntensity, painter ? painter.ParallaxIntensity : 1f, v => { if (painter) painter.ParallaxIntensity = v; });
|
||||
|
||||
foreach (var name in DraggablePanels)
|
||||
MakeDraggable(panel, name);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes the painter's current values back into every widget (painter -> HUD), e.g. after a
|
||||
/// pipette pick changed them outside the UI. No listeners are added here: setting a toggle/slider
|
||||
/// value fires its existing change listener, which keeps the painter, value labels and panel-fade
|
||||
/// alphas in sync; the colour pickers update silently via SetColor (it does not raise Changed).
|
||||
/// </summary>
|
||||
private void SyncFromPainter()
|
||||
{
|
||||
if (!_panel || !painter) return;
|
||||
var panel = _panel.transform;
|
||||
|
||||
SetToggle(panel, ToggleAlbedo, painter.paintAlbedo);
|
||||
SetToggle(panel, ToggleMetallicSmoothness, painter.paintMetallicSmoothness);
|
||||
SetToggle(panel, ToggleOcclusion, painter.paintOcclusion);
|
||||
SetToggle(panel, ToggleEmission, painter.paintEmission);
|
||||
SetToggle(panel, ToggleSpray, painter.spray);
|
||||
SetToggle(panel, TogglePipette, painter.pipette);
|
||||
|
||||
SetPicker(panel, PickerAlbedo, painter.brushColor);
|
||||
SetPicker(panel, PickerEmission, painter.emissionColor);
|
||||
SetSlider(panel, SliderAlbedoAlpha, painter.brushColor.a);
|
||||
SetSlider(panel, SliderEmissionAlpha, painter.emissionColor.a);
|
||||
|
||||
SetSlider(panel, SliderMetallic, painter.metallic);
|
||||
SetSlider(panel, SliderSmoothness, painter.smoothness);
|
||||
SetSlider(panel, SliderMetallicSmoothnessAlpha, painter.metallicSmoothnessOpacity);
|
||||
SetSlider(panel, SliderOcclusion, painter.occlusion);
|
||||
SetSlider(panel, SliderOcclusionAlpha, painter.occlusionOpacity);
|
||||
SetSlider(panel, SliderEmissionIntensity, painter.emissionIntensity);
|
||||
SetSlider(panel, SliderBrushSize, painter.brushSize);
|
||||
|
||||
SetSlider(panel, SliderSprayDots, painter.sprayDots);
|
||||
SetSlider(panel, SliderSprayDotSizeMin, painter.sprayDotSizeMin);
|
||||
SetSlider(panel, SliderSprayDotSizeMax, painter.sprayDotSizeMax);
|
||||
SetSlider(panel, SliderSprayDotAlphaMin, painter.sprayDotAlphaMin);
|
||||
SetSlider(panel, SliderSprayDotAlphaMax, painter.sprayDotAlphaMax);
|
||||
|
||||
RefreshMaskHighlights();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Pushes only the brush size back into its slider (painter -> HUD), e.g. after Shift+wheel
|
||||
/// resized the brush in-world. The slider clamps to its own range and its listener writes the
|
||||
/// clamped value back to the painter, so the wheel and the slider stay in agreement.
|
||||
/// </summary>
|
||||
private void SyncBrushSize()
|
||||
{
|
||||
if (!_panel || !painter) return;
|
||||
SetSlider(_panel.transform, SliderBrushSize, painter.brushSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Seeds the camo + height sliders from the live material (painter -> HUD). The HUD binds before the
|
||||
/// painter's Start builds the instance, so the values are not readable at WirePanel time; this runs
|
||||
/// once the painter reports <see cref="MeshPainter.IsReady"/> (see LateUpdate).
|
||||
/// </summary>
|
||||
private void SyncCamoSliders()
|
||||
{
|
||||
if (!_panel || !painter) return;
|
||||
var panel = _panel.transform;
|
||||
SetSlider(panel, SliderHalfLambert, painter.CamoHalfLambert);
|
||||
SetSlider(panel, SliderHalfLambertBrightness, painter.CamoHalfLambertBrightness);
|
||||
SetSlider(panel, SliderSpecularDamp, painter.CamoSpecularDampen);
|
||||
// Painted-height knobs share the same material-ready timing as the camo sliders.
|
||||
SetSlider(panel, SliderOcclusionThreshold, painter.OcclusionThreshold);
|
||||
SetSlider(panel, SliderNormalIntensity, painter.HeightNormalIntensity);
|
||||
SetSlider(panel, SliderParallaxIntensity, painter.ParallaxIntensity);
|
||||
}
|
||||
|
||||
private static void SetToggle(Transform panel, string name, bool value)
|
||||
{
|
||||
var toggle = Find<Toggle>(panel, name);
|
||||
if (toggle) toggle.isOn = value; // only fires onValueChanged when it actually changes
|
||||
}
|
||||
|
||||
private static void SetSlider(Transform panel, string name, float value)
|
||||
{
|
||||
var slider = FindSlider(panel, name, out _);
|
||||
if (!slider) return;
|
||||
var curve = slider.GetComponent<SliderCurve>();
|
||||
if (curve) curve.SetOutput(value); // inverts the value onto the handle, then fires the slider listener
|
||||
else slider.value = value; // listener refreshes the value label + painter
|
||||
}
|
||||
|
||||
private static void SetPicker(Transform panel, string name, Color color)
|
||||
{
|
||||
var picker = Find<HsvColorPicker>(panel, name);
|
||||
if (picker) picker.SetColor(color);
|
||||
}
|
||||
|
||||
// Split colour editing: the picker drives RGB, the alpha slider drives alpha - neither clobbers the other.
|
||||
private static Color WithRgb(Color current, Color rgb) => new Color(rgb.r, rgb.g, rgb.b, current.a);
|
||||
private static Color WithAlpha(Color current, float a) => new Color(current.r, current.g, current.b, a);
|
||||
|
||||
// Active = accent frame, inactive = subtle frame. Selection lives on the painter (activeBrushIndex);
|
||||
// the swatch click only sets it - the painter resolves the new mask on its next Update.
|
||||
private static readonly Color MaskFrameSelected = new Color(0.30f, 0.62f, 1f, 1f);
|
||||
private static readonly Color MaskFrameUnselected = new Color(0.18f, 0.18f, 0.21f, 1f);
|
||||
|
||||
/// <summary>
|
||||
/// Binds the baked mask swatches (one per <c>MaskSwatch_i</c>) to the painter's brush masks:
|
||||
/// each swatch previews <c>painter.brushMasks[i]</c> and selects it on click. A swatch with no
|
||||
/// matching mask is hidden, so the prefab can carry more swatches than the painter has masks.
|
||||
/// </summary>
|
||||
private void WireMaskSelector(Transform panel)
|
||||
{
|
||||
var selector = GeometryUtil.FindDeep(panel, MaskSelector);
|
||||
if (!selector) return;
|
||||
var masks = painter ? painter.brushMasks : null;
|
||||
|
||||
for (int i = 0; ; i++)
|
||||
{
|
||||
var swatch = GeometryUtil.FindDeep(selector, MaskSwatchPrefix + i);
|
||||
if (!swatch) break;
|
||||
|
||||
bool has = masks != null && i < masks.Count && masks[i];
|
||||
swatch.gameObject.SetActive(has);
|
||||
if (!has) continue;
|
||||
|
||||
var preview = swatch.Find("Preview");
|
||||
var previewImg = preview ? preview.GetComponent<Image>() : null;
|
||||
if (previewImg) previewImg.sprite = MaskSprite(masks[i]);
|
||||
|
||||
var btn = swatch.GetComponent<Button>();
|
||||
if (btn)
|
||||
{
|
||||
int idx = i; // capture per swatch
|
||||
Hook(btn, () => SelectMask(idx));
|
||||
}
|
||||
}
|
||||
|
||||
RefreshMaskHighlights();
|
||||
}
|
||||
|
||||
private void SelectMask(int index)
|
||||
{
|
||||
if (painter) painter.activeBrushIndex = index;
|
||||
RefreshMaskHighlights();
|
||||
}
|
||||
|
||||
private void RefreshMaskHighlights()
|
||||
{
|
||||
if (!_panel || !painter) return;
|
||||
var selector = GeometryUtil.FindDeep(_panel.transform, MaskSelector);
|
||||
if (!selector) return;
|
||||
|
||||
for (int i = 0; ; i++)
|
||||
{
|
||||
var swatch = GeometryUtil.FindDeep(selector, MaskSwatchPrefix + i);
|
||||
if (!swatch) break;
|
||||
var frame = swatch.GetComponent<Image>();
|
||||
if (frame) frame.color = i == painter.activeBrushIndex ? MaskFrameSelected : MaskFrameUnselected;
|
||||
}
|
||||
}
|
||||
|
||||
// One sprite per mask texture, reused across rewires (display only - no CPU readback needed).
|
||||
private static readonly System.Collections.Generic.Dictionary<Texture2D, Sprite> _maskSprites
|
||||
= new System.Collections.Generic.Dictionary<Texture2D, Sprite>();
|
||||
|
||||
private static Sprite MaskSprite(Texture2D tex)
|
||||
{
|
||||
if (!tex) return null;
|
||||
if (_maskSprites.TryGetValue(tex, out var cached) && cached) return cached;
|
||||
var s = ToSprite(tex);
|
||||
_maskSprites[tex] = s;
|
||||
return s;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Attaches (idempotently) a <see cref="DraggablePanel"/> to the whole panel so it can be dragged
|
||||
/// from any of its non-interactive surface (header, padding, labels, background); the component
|
||||
/// ignores presses that start on a widget (toggle, slider, colour picker).
|
||||
/// </summary>
|
||||
private void MakeDraggable(Transform root, string panelName)
|
||||
{
|
||||
var panel = GeometryUtil.FindDeep(root, panelName) as RectTransform;
|
||||
if (!panel) return;
|
||||
var drag = panel.GetComponent<DraggablePanel>();
|
||||
if (!drag) drag = panel.gameObject.AddComponent<DraggablePanel>();
|
||||
drag.target = panel;
|
||||
drag.Moved = OnPanelMoved; // persist the layout the moment a panel is dropped
|
||||
}
|
||||
|
||||
private void EnsureFonts(Transform panel)
|
||||
{
|
||||
foreach (var t in panel.GetComponentsInChildren<Text>(true))
|
||||
if (!t.font) t.font = _font;
|
||||
}
|
||||
|
||||
private static void WireToggle(Transform panel, string name, bool value, Action<bool> onChanged)
|
||||
{
|
||||
var toggle = Find<Toggle>(panel, name);
|
||||
if (!toggle) return;
|
||||
toggle.isOn = value;
|
||||
toggle.onValueChanged.AddListener(v => onChanged(v));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Ensures a <see cref="CanvasGroup"/> on the channel panel and drives its alpha from the toggle:
|
||||
/// 1 while the channel is enabled, 0.5 while disabled. Call after <see cref="WireToggle"/> so the
|
||||
/// initial alpha reflects the toggle's painter-synced state. The group stays interactive (default
|
||||
/// interactable/blocksRaycasts), so a dimmed panel can still be clicked to re-enable its channel.
|
||||
/// </summary>
|
||||
private static void WirePanelFade(Transform root, string panelName, string toggleName)
|
||||
{
|
||||
var panel = GeometryUtil.FindDeep(root, panelName);
|
||||
if (!panel) return;
|
||||
var group = panel.GetComponent<CanvasGroup>();
|
||||
if (!group) group = panel.gameObject.AddComponent<CanvasGroup>();
|
||||
|
||||
var toggle = Find<Toggle>(panel, toggleName);
|
||||
if (!toggle) { group.alpha = 1f; return; }
|
||||
|
||||
group.alpha = toggle.isOn ? 1f : 0.5f;
|
||||
toggle.onValueChanged.AddListener(v => group.alpha = v ? 1f : 0.5f);
|
||||
}
|
||||
|
||||
/// <summary>Finds a slider cell by name and the Slider inside it (null when either is missing).</summary>
|
||||
private static Slider FindSlider(Transform panel, string name, out Transform cell)
|
||||
{
|
||||
cell = GeometryUtil.FindDeep(panel, name);
|
||||
return cell ? cell.GetComponentInChildren<Slider>() : null;
|
||||
}
|
||||
|
||||
private static void WireSlider(Transform panel, string name, float value, Action<float> onChanged)
|
||||
{
|
||||
var slider = FindSlider(panel, name, out Transform cell);
|
||||
if (!slider) return;
|
||||
var curve = slider.GetComponent<SliderCurve>(); // optional non-linear response on the same object
|
||||
var valueRt = cell.Find("Value");
|
||||
var valueText = valueRt ? valueRt.GetComponent<TMPro.TMP_Text>() : null;
|
||||
|
||||
// With a curve the slider handle is linear but the bound value is the curved output; whole-number
|
||||
// quantisation moves onto the curve so the slider itself stays continuous.
|
||||
if (curve) curve.SetOutputWithoutNotify(value); // seed the handle by inverting the value (no listener yet)
|
||||
else slider.value = value;
|
||||
|
||||
Func<float> read = () => curve ? curve.Value : slider.value;
|
||||
|
||||
Action<float> setText = v =>
|
||||
{
|
||||
if (valueText) valueText.text = slider.wholeNumbers ? Mathf.RoundToInt(v).ToString() : v.ToString("0.00");
|
||||
};
|
||||
setText(read()); // show the actual (clamped/curved) value, not the unclamped input
|
||||
slider.onValueChanged.AddListener(_ => { float v = read(); setText(v); onChanged(v); });
|
||||
}
|
||||
|
||||
private void WirePicker(Transform panel, string name, Color color, Action<Color> onChanged)
|
||||
{
|
||||
var picker = Find<HsvColorPicker>(panel, name);
|
||||
if (!picker) return;
|
||||
picker.Rebind(); // re-find children, re-assign runtime gradient sprites, re-hook drag relays
|
||||
picker.SetColor(color);
|
||||
picker.Changed += onChanged;
|
||||
}
|
||||
|
||||
private static T Find<T>(Transform panel, string name) where T : Component
|
||||
{
|
||||
var child = GeometryUtil.FindDeep(panel, name);
|
||||
return child ? child.GetComponent<T>() : null;
|
||||
}
|
||||
|
||||
// Cached gradient sprites for the colour picker.
|
||||
private static Sprite _satSprite, _valSprite, _hueSprite;
|
||||
|
||||
/// <summary>Horizontal white-out: opaque white (left) -> transparent (right) = saturation.</summary>
|
||||
public static Sprite SaturationSprite()
|
||||
{
|
||||
if (_satSprite) return _satSprite;
|
||||
const int n = 64;
|
||||
var tex = NewGradientTex(n, 1);
|
||||
for (int x = 0; x < n; x++)
|
||||
{
|
||||
byte a = (byte)Mathf.RoundToInt(255f * (1f - x / (float)(n - 1)));
|
||||
tex.SetPixel(x, 0, new Color32(255, 255, 255, a));
|
||||
}
|
||||
tex.Apply(false);
|
||||
_satSprite = ToSprite(tex);
|
||||
return _satSprite;
|
||||
}
|
||||
|
||||
/// <summary>Vertical black-out: opaque black (bottom) -> transparent (top) = value.</summary>
|
||||
public static Sprite ValueSprite()
|
||||
{
|
||||
if (_valSprite) return _valSprite;
|
||||
const int n = 64;
|
||||
var tex = NewGradientTex(1, n);
|
||||
for (int y = 0; y < n; y++)
|
||||
{
|
||||
byte a = (byte)Mathf.RoundToInt(255f * (1f - y / (float)(n - 1)));
|
||||
tex.SetPixel(0, y, new Color32(0, 0, 0, a));
|
||||
}
|
||||
tex.Apply(false);
|
||||
_valSprite = ToSprite(tex);
|
||||
return _valSprite;
|
||||
}
|
||||
|
||||
/// <summary>Horizontal full-saturation hue ramp 0..1.</summary>
|
||||
public static Sprite HueSprite()
|
||||
{
|
||||
if (_hueSprite) return _hueSprite;
|
||||
const int n = 64;
|
||||
var tex = NewGradientTex(n, 1);
|
||||
for (int x = 0; x < n; x++)
|
||||
tex.SetPixel(x, 0, Color.HSVToRGB(x / (float)(n - 1), 1f, 1f));
|
||||
tex.Apply(false);
|
||||
_hueSprite = ToSprite(tex);
|
||||
return _hueSprite;
|
||||
}
|
||||
|
||||
private static Texture2D NewGradientTex(int w, int h)
|
||||
{
|
||||
return new Texture2D(w, h, TextureFormat.RGBA32, false, false) // sRGB sampling for correct colours
|
||||
{
|
||||
name = "PaintHud_Gradient",
|
||||
wrapMode = TextureWrapMode.Clamp,
|
||||
filterMode = FilterMode.Bilinear,
|
||||
hideFlags = HideFlags.HideAndDontSave
|
||||
};
|
||||
}
|
||||
|
||||
private static Sprite ToSprite(Texture2D tex)
|
||||
{
|
||||
var s = Sprite.Create(tex, new Rect(0f, 0f, tex.width, tex.height), new Vector2(0.5f, 0.5f), 100f);
|
||||
s.hideFlags = HideFlags.HideAndDontSave;
|
||||
return s;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d0635b02a5454ec408df3a69b04f85a7
|
||||
@@ -0,0 +1,84 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.UI;
|
||||
|
||||
/// <summary>
|
||||
/// Reshapes a UI <see cref="Slider"/>'s response with an <see cref="AnimationCurve"/> so the value runs
|
||||
/// non-linearly between the slider's authored <c>minValue</c>/<c>maxValue</c>. The curve maps the handle
|
||||
/// position (x, 0..1 - linear in screen space) to a value fraction (y, 0..1) that is then scaled to the
|
||||
/// slider's range. The slider's own min/max stays the single source of truth for the range; this only
|
||||
/// bends the mapping inside it - so dropping the component onto an existing slider needs no range re-entry.
|
||||
///
|
||||
/// <see cref="Value"/> is the curved output, <see cref="SetOutput"/> positions the handle to show a given
|
||||
/// output (inverting the curve), and <see cref="ValueChanged"/> raises the curved value whenever the
|
||||
/// handle moves - so the component is usable both standalone (wire <see cref="ValueChanged"/>) and from
|
||||
/// code that drives the raw slider (read <see cref="Value"/>).
|
||||
///
|
||||
/// The curve should be monotonic increasing (the default linear is); the inverse used by
|
||||
/// <see cref="SetOutput"/> assumes that.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(Slider))]
|
||||
[DisallowMultipleComponent]
|
||||
public class SliderCurve : MonoBehaviour
|
||||
{
|
||||
[Tooltip("Maps the handle position (x, 0..1) to a value fraction (y, 0..1), scaled to the slider's " +
|
||||
"minValue/maxValue. Default linear = no change. Should be monotonic increasing.")]
|
||||
public AnimationCurve response = AnimationCurve.Linear(0f, 0f, 1f, 1f);
|
||||
|
||||
/// <summary>Raised with the curved output whenever the handle moves.</summary>
|
||||
public event Action<float> ValueChanged;
|
||||
|
||||
private Slider _slider;
|
||||
private Slider Slider => _slider ? _slider : (_slider = GetComponent<Slider>());
|
||||
|
||||
/// <summary>The curved output value for the current handle position.</summary>
|
||||
public float Value => Slider ? Curve(Slider.normalizedValue) : 0f;
|
||||
|
||||
private void OnEnable()
|
||||
{
|
||||
if (Slider) Slider.onValueChanged.AddListener(OnSliderChanged);
|
||||
}
|
||||
|
||||
private void OnDisable()
|
||||
{
|
||||
if (_slider) _slider.onValueChanged.RemoveListener(OnSliderChanged);
|
||||
}
|
||||
|
||||
private void OnSliderChanged(float _) => ValueChanged?.Invoke(Value);
|
||||
|
||||
/// <summary>Positions the handle so <see cref="Value"/> reads <paramref name="output"/>, raising the
|
||||
/// slider's <c>onValueChanged</c> (and thus <see cref="ValueChanged"/>).</summary>
|
||||
public void SetOutput(float output)
|
||||
{
|
||||
if (Slider) Slider.normalizedValue = Position(output); // normalizedValue setter notifies
|
||||
}
|
||||
|
||||
/// <summary>As <see cref="SetOutput"/> but without raising any change event (for silent seeding).</summary>
|
||||
public void SetOutputWithoutNotify(float output)
|
||||
{
|
||||
if (Slider) Slider.SetValueWithoutNotify(Mathf.Lerp(Slider.minValue, Slider.maxValue, Position(output)));
|
||||
}
|
||||
|
||||
// handle position (0..1) -> curved output value in [minValue, maxValue]
|
||||
private float Curve(float t)
|
||||
{
|
||||
float y = Mathf.Clamp01(response.Evaluate(Mathf.Clamp01(t)));
|
||||
float v = Mathf.Lerp(Slider.minValue, Slider.maxValue, y);
|
||||
return Slider && Slider.wholeNumbers ? Mathf.Round(v) : v;
|
||||
}
|
||||
|
||||
// curved output value -> handle position (0..1), inverting the (monotonic) curve via bisection
|
||||
private float Position(float output)
|
||||
{
|
||||
var s = Slider;
|
||||
if (!s) return 0f;
|
||||
float target = Mathf.Clamp01(Mathf.InverseLerp(s.minValue, s.maxValue, output)); // desired y
|
||||
float lo = 0f, hi = 1f;
|
||||
for (int i = 0; i < 24; i++)
|
||||
{
|
||||
float mid = 0.5f * (lo + hi);
|
||||
if (Mathf.Clamp01(response.Evaluate(mid)) < target) lo = mid; else hi = mid;
|
||||
}
|
||||
return 0.5f * (lo + hi);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 2e66618c2739718459e4ecd2253c4ba7
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
using UnityEngine.EventSystems;
|
||||
|
||||
/// <summary>
|
||||
/// Turns a pointer press/drag on a UI graphic into a normalized (x, y) position in
|
||||
/// [0,1] within its RectTransform (origin bottom-left). Added in code to the colour
|
||||
/// picker's saturation/value field and hue bar so they react to both clicking and
|
||||
/// dragging. The graphic on the same object must be a raycast target.
|
||||
/// </summary>
|
||||
[RequireComponent(typeof(RectTransform))]
|
||||
public class UiPointerSurface : MonoBehaviour, IPointerDownHandler, IDragHandler
|
||||
{
|
||||
/// <summary>Fired on press and while dragging with the normalized (x, y) in [0,1].</summary>
|
||||
public event Action<Vector2> Picked;
|
||||
|
||||
private RectTransform _rect;
|
||||
|
||||
private void Awake() => _rect = (RectTransform)transform;
|
||||
|
||||
public void OnPointerDown(PointerEventData eventData) => Report(eventData);
|
||||
public void OnDrag(PointerEventData eventData) => Report(eventData);
|
||||
|
||||
private void Report(PointerEventData eventData)
|
||||
{
|
||||
// pressEventCamera is null for a Screen Space - Overlay canvas, which is the
|
||||
// correct camera argument there.
|
||||
if (!RectTransformUtility.ScreenPointToLocalPointInRectangle(
|
||||
_rect, eventData.position, eventData.pressEventCamera, out Vector2 local))
|
||||
return;
|
||||
|
||||
Rect r = _rect.rect;
|
||||
if (r.width <= 0f || r.height <= 0f) return;
|
||||
|
||||
float x = Mathf.Clamp01((local.x - r.xMin) / r.width);
|
||||
float y = Mathf.Clamp01((local.y - r.yMin) / r.height);
|
||||
Picked?.Invoke(new Vector2(x, y));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3aefb85fd241856408808aa5401c9d64
|
||||
Reference in new issue
Block a user