Add pose icon capture pipeline, icon scale, Esc/Tab opens overview in paint mode
- PoseIconStage: permanent editor capture pipeline (context menu 'Capture All Icons') — renders each entry at 4096x4096, alpha-crops, writes Icon_pose_*.png and wires sprites onto matching PlayerPose assets - PlayerPose: new iconScale property (0.1-2), applied to the icon child in PoseMenu's radial layout - OverviewMenu: paint-mode special-case removed — Esc/Tab opens the overview menu in every mode Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
1 parent
2de153d8fe
commit
5f5892047c
4 files changed
+214
-11
No files matched your search
@@ -4,7 +4,8 @@ using UnityEngine;
|
||||
/// <summary>
|
||||
/// Authoring tool for the Pose Icon scene: holds a list of pose entries (animation clip + root
|
||||
/// position/rotation) and applies the selected one to the paintman rig in edit mode, for framing
|
||||
/// each pose in front of the camera (icon screenshots are a later step). Clips are sampled via
|
||||
/// each pose in front of the camera. The context menu's "Capture All Icons" renders every entry
|
||||
/// into Assets/Textures/Icons and wires the sprites onto the PlayerPose assets. Clips are sampled via
|
||||
/// AnimationClip.SampleAnimation (no foot IK), matching how PlayerPoseController plays them.
|
||||
/// </summary>
|
||||
[ExecuteAlways]
|
||||
@@ -39,7 +40,7 @@ public class PoseIconStage : MonoBehaviour
|
||||
// Rest-pose snapshot of the rig, taken before the first sample this session. A clip only writes
|
||||
// the bones its curves cover, so switching between clips would otherwise leave residue from the
|
||||
// previous pose on un-keyed bones (same reasoning as PlayerPoseController.CaptureRestPose).
|
||||
// Not serialized: reload the scene to get a clean rest pose if it was saved mid-pose.
|
||||
// Read from the prefab asset when available, so a scene saved mid-pose cannot poison the snapshot.
|
||||
private Transform[] _restBones;
|
||||
private Vector3[] _restPos;
|
||||
private Quaternion[] _restRot;
|
||||
@@ -49,8 +50,35 @@ public class PoseIconStage : MonoBehaviour
|
||||
if (!target)
|
||||
target = GetComponentInChildren<Animator>();
|
||||
_dirty = true;
|
||||
#if UNITY_EDITOR
|
||||
UnityEditor.SceneManagement.EditorSceneManager.sceneSaving += OnSceneSaving;
|
||||
UnityEditor.SceneManagement.EditorSceneManager.sceneSaved += OnSceneSaved;
|
||||
#endif
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
void OnDisable()
|
||||
{
|
||||
UnityEditor.SceneManagement.EditorSceneManager.sceneSaving -= OnSceneSaving;
|
||||
UnityEditor.SceneManagement.EditorSceneManager.sceneSaved -= OnSceneSaved;
|
||||
}
|
||||
|
||||
// Save the scene in rest pose: a scene saved mid-pose would become the next session's rest
|
||||
// snapshot (the prefab asset's own rest cannot be used - the clips do not key the bone that
|
||||
// uprights the blend-imported rig), silently degrading every pose that samples on top of it.
|
||||
void OnSceneSaving(UnityEngine.SceneManagement.Scene scene, string path)
|
||||
{
|
||||
if (scene == gameObject.scene && _restBones != null)
|
||||
ResetToRestPose();
|
||||
}
|
||||
|
||||
void OnSceneSaved(UnityEngine.SceneManagement.Scene scene)
|
||||
{
|
||||
if (scene == gameObject.scene)
|
||||
_dirty = true;
|
||||
}
|
||||
#endif
|
||||
|
||||
void OnValidate()
|
||||
{
|
||||
// Only flag here; Update applies (coalesced to once per frame, safe object access).
|
||||
@@ -112,4 +140,180 @@ public class PoseIconStage : MonoBehaviour
|
||||
t.localRotation = _restRot[i];
|
||||
}
|
||||
}
|
||||
|
||||
#if UNITY_EDITOR
|
||||
// ---- Icon capture (editor only) ----
|
||||
// Right-click the component header -> "Capture All Icons": renders every entry through the scene
|
||||
// camera at CaptureResolution (background cleared to alpha 0 so the alpha channel is the mask),
|
||||
// un-blends edge pixels against the background color, content-crops with a small margin, squares
|
||||
// the result, writes Assets/Textures/Icons/Icon_pose_<name>.png and assigns the imported sprite
|
||||
// to the matching PlayerPose asset (matched by idle clip).
|
||||
|
||||
private const int CaptureResolution = 4096;
|
||||
private const float CropMargin = 0.04f;
|
||||
private const string IconFolder = "Assets/Textures/Icons";
|
||||
|
||||
[ContextMenu("Capture All Icons")]
|
||||
private void CaptureAllIcons()
|
||||
{
|
||||
Camera cam = Camera.main;
|
||||
if (!cam)
|
||||
{
|
||||
Debug.LogError("[PoseIconStage] No enabled MainCamera in the scene.");
|
||||
return;
|
||||
}
|
||||
if (!target || entries.Count == 0)
|
||||
return;
|
||||
|
||||
var poses = new List<PlayerPose>();
|
||||
foreach (string guid in UnityEditor.AssetDatabase.FindAssets("t:PlayerPose"))
|
||||
poses.Add(UnityEditor.AssetDatabase.LoadAssetAtPath<PlayerPose>(
|
||||
UnityEditor.AssetDatabase.GUIDToAssetPath(guid)));
|
||||
|
||||
// Skinned meshes only follow sampled bones in unfocused/background renders when forced.
|
||||
var skinned = target.GetComponentsInChildren<SkinnedMeshRenderer>(true);
|
||||
var forcePrev = new bool[skinned.Length];
|
||||
for (int i = 0; i < skinned.Length; i++)
|
||||
{
|
||||
forcePrev[i] = skinned[i].forceMatrixRecalculationPerRender;
|
||||
skinned[i].forceMatrixRecalculationPerRender = true;
|
||||
}
|
||||
|
||||
CameraClearFlags prevFlags = cam.clearFlags;
|
||||
Color prevBg = cam.backgroundColor;
|
||||
cam.clearFlags = CameraClearFlags.SolidColor;
|
||||
Color bg = new Color(prevBg.r, prevBg.g, prevBg.b, 0f); // alpha 0 -> alpha channel is the mask
|
||||
cam.backgroundColor = bg;
|
||||
|
||||
int savedIndex = currentIndex;
|
||||
var rt = new RenderTexture(CaptureResolution, CaptureResolution, 32, RenderTextureFormat.ARGB32);
|
||||
var readback = new Texture2D(CaptureResolution, CaptureResolution, TextureFormat.RGBA32, false);
|
||||
int written = 0;
|
||||
try
|
||||
{
|
||||
for (int e = 0; e < entries.Count; e++)
|
||||
{
|
||||
Entry entry = entries[e];
|
||||
if (entry == null || !entry.clip)
|
||||
continue;
|
||||
currentIndex = e;
|
||||
Apply();
|
||||
|
||||
var request = new UnityEngine.Rendering.RenderPipeline.StandardRequest { destination = rt };
|
||||
UnityEngine.Rendering.RenderPipeline.SubmitRenderRequest(cam, request);
|
||||
|
||||
RenderTexture prevActive = RenderTexture.active;
|
||||
RenderTexture.active = rt;
|
||||
readback.ReadPixels(new Rect(0, 0, CaptureResolution, CaptureResolution), 0, 0);
|
||||
readback.Apply();
|
||||
RenderTexture.active = prevActive;
|
||||
|
||||
PlayerPose pose = poses.Find(p => p && p.Clip == entry.clip);
|
||||
string suffix = pose
|
||||
? pose.name.Replace("Pose_", "").ToLowerInvariant()
|
||||
: entry.clip.name.ToLowerInvariant();
|
||||
string assetPath = $"{IconFolder}/Icon_pose_{suffix}.png";
|
||||
|
||||
if (SaveIcon(readback, bg, assetPath) && pose)
|
||||
AssignIcon(pose, assetPath);
|
||||
written++;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
currentIndex = Mathf.Clamp(savedIndex, 0, entries.Count - 1);
|
||||
Apply();
|
||||
cam.clearFlags = prevFlags;
|
||||
cam.backgroundColor = prevBg;
|
||||
for (int i = 0; i < skinned.Length; i++)
|
||||
skinned[i].forceMatrixRecalculationPerRender = forcePrev[i];
|
||||
rt.Release();
|
||||
DestroyImmediate(rt);
|
||||
DestroyImmediate(readback);
|
||||
}
|
||||
|
||||
UnityEditor.AssetDatabase.SaveAssets();
|
||||
Debug.Log($"[PoseIconStage] Captured {written} pose icon(s) to {IconFolder}.");
|
||||
}
|
||||
|
||||
/// <summary>Un-blends edges, content-crops with margin, squares, writes the PNG and imports it.</summary>
|
||||
private static bool SaveIcon(Texture2D source, Color bg, string assetPath)
|
||||
{
|
||||
Color32[] px = source.GetPixels32();
|
||||
int w = source.width, h = source.height;
|
||||
|
||||
// Content bounds from the alpha mask.
|
||||
int minX = w, minY = h, maxX = -1, maxY = -1;
|
||||
for (int y = 0; y < h; y++)
|
||||
for (int x = 0; x < w; x++)
|
||||
if (px[y * w + x].a > 0)
|
||||
{
|
||||
if (x < minX) minX = x;
|
||||
if (x > maxX) maxX = x;
|
||||
if (y < minY) minY = y;
|
||||
if (y > maxY) maxY = y;
|
||||
}
|
||||
if (maxX < 0)
|
||||
{
|
||||
Debug.LogWarning($"[PoseIconStage] Empty render for {assetPath} - nothing written.");
|
||||
return false;
|
||||
}
|
||||
|
||||
int bw = maxX - minX + 1, bh = maxY - minY + 1;
|
||||
int margin = Mathf.RoundToInt(Mathf.Max(bw, bh) * CropMargin);
|
||||
int size = Mathf.Max(bw, bh) + 2 * margin;
|
||||
int offX = (size - bw) / 2, offY = (size - bh) / 2;
|
||||
|
||||
var outPx = new Color32[size * size];
|
||||
for (int y = 0; y < bh; y++)
|
||||
{
|
||||
for (int x = 0; x < bw; x++)
|
||||
{
|
||||
Color32 c = px[(minY + y) * w + minX + x];
|
||||
if (c.a > 0 && c.a < 255)
|
||||
{
|
||||
// Un-blend semi-transparent edge pixels against the background color
|
||||
// (fg = (c - bg * (1 - a)) / a), otherwise the fringe keeps the bg tint.
|
||||
float a = c.a / 255f;
|
||||
c.r = (byte)Mathf.Clamp(Mathf.RoundToInt((c.r - bg.r * 255f * (1f - a)) / a), 0, 255);
|
||||
c.g = (byte)Mathf.Clamp(Mathf.RoundToInt((c.g - bg.g * 255f * (1f - a)) / a), 0, 255);
|
||||
c.b = (byte)Mathf.Clamp(Mathf.RoundToInt((c.b - bg.b * 255f * (1f - a)) / a), 0, 255);
|
||||
}
|
||||
outPx[(offY + y) * size + offX + x] = c;
|
||||
}
|
||||
}
|
||||
|
||||
var outTex = new Texture2D(size, size, TextureFormat.RGBA32, false);
|
||||
outTex.SetPixels32(outPx);
|
||||
byte[] png = outTex.EncodeToPNG();
|
||||
DestroyImmediate(outTex);
|
||||
|
||||
System.IO.Directory.CreateDirectory(IconFolder);
|
||||
System.IO.File.WriteAllBytes(assetPath, png);
|
||||
UnityEditor.AssetDatabase.ImportAsset(assetPath);
|
||||
|
||||
// First-time import: match the settings of the existing UI icons.
|
||||
var importer = (UnityEditor.TextureImporter)UnityEditor.AssetImporter.GetAtPath(assetPath);
|
||||
if (importer.textureType != UnityEditor.TextureImporterType.Sprite)
|
||||
{
|
||||
importer.textureType = UnityEditor.TextureImporterType.Sprite;
|
||||
importer.spriteImportMode = UnityEditor.SpriteImportMode.Single;
|
||||
importer.mipmapEnabled = false;
|
||||
importer.alphaIsTransparency = true;
|
||||
importer.maxTextureSize = 256;
|
||||
importer.SaveAndReimport();
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private static void AssignIcon(PlayerPose pose, string assetPath)
|
||||
{
|
||||
var sprite = UnityEditor.AssetDatabase.LoadAssetAtPath<Sprite>(assetPath);
|
||||
if (!sprite)
|
||||
return;
|
||||
var so = new UnityEditor.SerializedObject(pose);
|
||||
so.FindProperty("icon").objectReferenceValue = sprite;
|
||||
so.ApplyModifiedPropertiesWithoutUndo();
|
||||
}
|
||||
#endif
|
||||
}
|
||||
@@ -9,8 +9,7 @@ using UnityEngine.InputSystem;
|
||||
/// The Esc overview menu (the "Overview UI" canvas in Main). Owns ALL Escape handling (Tab acts as an
|
||||
/// alternative to Esc): Esc toggles
|
||||
/// this panel as a modal pause overlay (gameplay suspended via <see cref="GameModeManager.SetPaused"/>);
|
||||
/// while the session browser, match results or pose menu overlay is open instead, Esc closes that, and while paint
|
||||
/// mode is active Esc exits to the camera mode (Player Control, or First Person for the hunter). Buttons
|
||||
/// while the session browser, match results or pose menu overlay is open instead, Esc closes that. Buttons
|
||||
/// switch game modes (via <see cref="GameModeManager.TrySwitchTo{T}"/>) or open the session browser /
|
||||
/// match results overlay, and every click closes the panel. Mode buttons are only shown for modes present in the current role-scoped set
|
||||
/// (<see cref="GameModeManager.Modes"/>); the active mode's button is disabled. Hotkeys mirror the
|
||||
@@ -124,13 +123,6 @@ public class OverviewMenu : MonoBehaviour
|
||||
// pausing or switching modes here corrupts the camera rigs - Esc/Tab do nothing.
|
||||
else if (MatchManager.Instance && MatchManager.Instance.IsLoadingLocally)
|
||||
{ }
|
||||
// Paint mode: Esc exits back to the camera mode instead of opening the menu. Player Control
|
||||
// first; the hunter's role-scoped set has none, so it falls through to First Person.
|
||||
else if (gameMode && gameMode.EffectiveMode is PaintMode)
|
||||
{
|
||||
if (!gameMode.TrySwitchTo<PlayerControlMode>() && !gameMode.TrySwitchTo<FirstPersonMode>())
|
||||
Open();
|
||||
}
|
||||
// No active/paused mode = the inert no-player state (pre-lobby Main, where the session panel
|
||||
// is always shown) - nothing to pause, don't open.
|
||||
else if (gameMode && gameMode.EffectiveMode != null)
|
||||
|
||||
@@ -132,6 +132,7 @@ public class PoseMenu : MonoBehaviour
|
||||
icon.sprite = p ? p.Icon : null;
|
||||
icon.preserveAspect = true;
|
||||
icon.enabled = icon.sprite; // no icon authored -> no solid white square
|
||||
icon.rectTransform.localScale = Vector3.one * (p ? p.IconScale : 1f);
|
||||
}
|
||||
TMP_Text label = item.GetComponentInChildren<TMP_Text>(true);
|
||||
if (label) label.text = p && !string.IsNullOrEmpty(p.Title) ? p.Title : (p ? p.name : "");
|
||||
|
||||
Reference in new issue
Block a user