78bfdf2828
# Conflicts: # Assets/External/Animated PBR Chest Demo/Materials/WoodChest.mat # Packages/com.distantlands.cozy.core/Content/Integration/Import for BiRP.unitypackage.meta # Packages/com.distantlands.cozy.core/Content/Integration/Import for HDRP.unitypackage.meta # Packages/com.distantlands.cozy.core/Content/Integration/Import for URP.unitypackage.meta
822 lines
32 KiB
C#
822 lines
32 KiB
C#
using System;
|
||
using System.IO;
|
||
using UnityEditor;
|
||
using UnityEditor.UIElements;
|
||
using UnityEngine;
|
||
using UnityEngine.UIElements;
|
||
|
||
namespace Ashwild.EditorTools
|
||
{
|
||
/// <summary>
|
||
/// The Screenshot Studio — a UI Toolkit window that renders a prefab, scene object or raw mesh in an
|
||
/// isolated preview world and bakes polished PNGs from it. The left pane is a live, orbitable 3D
|
||
/// preview (drag to orbit, scroll to zoom); the right pane hosts the camera, lighting, background and
|
||
/// export controls. All rendering and capture is delegated to ScreenshotStage; this window is the
|
||
/// view and the orchestrator that writes the stage config and triggers renders/exports.
|
||
/// </summary>
|
||
public class ScreenshotStudioWindow : EditorWindow
|
||
{
|
||
#region Types
|
||
|
||
/// <summary>
|
||
/// The named lighting rigs offered in the presets dropdown; Apply pushes each into the stage.
|
||
/// </summary>
|
||
private enum LightingPreset { Studio, Soft, Dramatic, Flat }
|
||
|
||
/// <summary>
|
||
/// Which asset kind the Target object picker is filtered to — prefabs/scene objects or raw
|
||
/// meshes. Materials, scripts and other assets are intentionally excluded from both.
|
||
/// </summary>
|
||
private enum TargetKind { Prefab, Mesh }
|
||
|
||
#endregion
|
||
|
||
#region Constants
|
||
|
||
private const string UssPath = "Assets/GAME/Script/Editor/ScreenshotStudio/ScreenshotStudio.uss";
|
||
private const string FolderPrefKey = "Ashwild.ScreenshotStudio.Folder";
|
||
private const string FilePrefKey = "Ashwild.ScreenshotStudio.FileName";
|
||
private const int LivePreviewLongSide = 1024;
|
||
|
||
#endregion
|
||
|
||
#region State
|
||
|
||
private ScreenshotStage stage;
|
||
|
||
private int exportWidth = 1024;
|
||
private int exportHeight = 1024;
|
||
private bool supersample = true;
|
||
private int turntableFrames = 12;
|
||
private string exportFolder;
|
||
private string exportFileName = string.Empty;
|
||
private bool nameEditedByUser;
|
||
|
||
private TargetKind targetKind = TargetKind.Prefab;
|
||
|
||
private bool autoRotate;
|
||
private IVisualElementScheduledItem rotateSchedule;
|
||
|
||
private bool dragging;
|
||
private Vector2 lastPointer;
|
||
|
||
#endregion
|
||
|
||
#region UI References
|
||
|
||
private Image previewImage;
|
||
private Label emptyOverlay;
|
||
private VisualElement controls;
|
||
private ColorField backgroundColorField;
|
||
|
||
#endregion
|
||
|
||
#region Menu
|
||
|
||
/// <summary>
|
||
/// Opens (or focuses) the Screenshot Studio window.
|
||
/// </summary>
|
||
[MenuItem("Tools/Screenshot Studio")]
|
||
public static void Open()
|
||
{
|
||
ScreenshotStudioWindow window = GetWindow<ScreenshotStudioWindow>();
|
||
window.titleContent = new GUIContent("Screenshot Studio");
|
||
window.minSize = new Vector2(880, 520);
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Window Lifecycle
|
||
|
||
/// <summary>
|
||
/// Builds the window tree and the render stage. Loads the last export folder from EditorPrefs.
|
||
/// </summary>
|
||
private void CreateGUI()
|
||
{
|
||
stage = new ScreenshotStage();
|
||
exportFolder = EditorPrefs.GetString(FolderPrefKey, Application.dataPath);
|
||
exportFileName = EditorPrefs.GetString(FilePrefKey, string.Empty);
|
||
|
||
VisualElement root = rootVisualElement;
|
||
root.AddToClassList("ss-root");
|
||
|
||
StyleSheet sheet = AssetDatabase.LoadAssetAtPath<StyleSheet>(UssPath);
|
||
if (sheet != null) root.styleSheets.Add(sheet);
|
||
|
||
VisualElement body = new VisualElement();
|
||
body.AddToClassList("ss-body");
|
||
body.Add(BuildControlsPane());
|
||
body.Add(BuildPreviewPane());
|
||
root.Add(body);
|
||
|
||
RebuildControls();
|
||
RenderPreview();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Tears the stage down so the preview scene and textures never survive the window.
|
||
/// </summary>
|
||
private void OnDisable()
|
||
{
|
||
rotateSchedule?.Pause();
|
||
stage?.Dispose();
|
||
stage = null;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Preview Pane
|
||
|
||
/// <summary>
|
||
/// Builds the large live-preview surface on the left: an Image fed by the stage's render texture,
|
||
/// wired for orbit (drag) and zoom (wheel), with an empty-state overlay shown until a target loads.
|
||
/// </summary>
|
||
private VisualElement BuildPreviewPane()
|
||
{
|
||
VisualElement pane = new VisualElement();
|
||
pane.AddToClassList("ss-preview");
|
||
|
||
previewImage = new Image { scaleMode = ScaleMode.ScaleToFit };
|
||
previewImage.AddToClassList("ss-preview__image");
|
||
previewImage.RegisterCallback<PointerDownEvent>(OnPreviewPointerDown);
|
||
previewImage.RegisterCallback<PointerMoveEvent>(OnPreviewPointerMove);
|
||
previewImage.RegisterCallback<PointerUpEvent>(OnPreviewPointerUp);
|
||
previewImage.RegisterCallback<WheelEvent>(OnPreviewWheel);
|
||
pane.Add(previewImage);
|
||
|
||
emptyOverlay = new Label("Assign a prefab, scene object or mesh to begin.\nDrag to orbit · scroll to zoom.");
|
||
emptyOverlay.AddToClassList("ss-preview__empty");
|
||
pane.Add(emptyOverlay);
|
||
|
||
return pane;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Controls Pane
|
||
|
||
/// <summary>
|
||
/// Builds the scrollable right pane container the control cards are rendered into.
|
||
/// </summary>
|
||
private VisualElement BuildControlsPane()
|
||
{
|
||
controls = new ScrollView();
|
||
controls.AddToClassList("ss-controls");
|
||
return controls;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Rebuilds every control card from the current stage + window state. Called on open and whenever
|
||
/// a preset changes several values at once, so the widgets always mirror the live config.
|
||
/// </summary>
|
||
private void RebuildControls()
|
||
{
|
||
controls.Clear();
|
||
controls.Add(BuildTargetCard());
|
||
controls.Add(BuildCameraCard());
|
||
controls.Add(BuildLightingCard());
|
||
controls.Add(BuildBackgroundCard());
|
||
controls.Add(BuildExportCard());
|
||
}
|
||
|
||
/// <summary>
|
||
/// Target card: a Prefab/Mesh kind selector that filters the object picker to only those assets
|
||
/// (so materials, scripts and the like never clutter it), the object field itself, per-axis
|
||
/// rotation sliders that pose the subject, plus quick Frame and auto-rotate controls.
|
||
/// </summary>
|
||
private VisualElement BuildTargetCard()
|
||
{
|
||
VisualElement card = Card("Target");
|
||
|
||
ObjectField field = new ObjectField("Object")
|
||
{
|
||
objectType = TargetObjectType(),
|
||
allowSceneObjects = targetKind == TargetKind.Prefab
|
||
};
|
||
field.RegisterValueChangedCallback(evt => LoadTarget(evt.newValue));
|
||
|
||
EnumField kind = new EnumField("Kind", targetKind);
|
||
kind.RegisterValueChangedCallback(evt =>
|
||
{
|
||
targetKind = (TargetKind)evt.newValue;
|
||
field.value = null;
|
||
field.objectType = TargetObjectType();
|
||
field.allowSceneObjects = targetKind == TargetKind.Prefab;
|
||
});
|
||
card.Add(kind);
|
||
card.Add(field);
|
||
|
||
card.Add(SliderRow("Rotate X", 0f, 360f, stage.SubjectEuler.x,
|
||
v => stage.SetSubjectRotation(new Vector3(v, stage.SubjectEuler.y, stage.SubjectEuler.z))));
|
||
card.Add(SliderRow("Rotate Y", 0f, 360f, stage.SubjectEuler.y,
|
||
v => stage.SetSubjectRotation(new Vector3(stage.SubjectEuler.x, v, stage.SubjectEuler.z))));
|
||
card.Add(SliderRow("Rotate Z", 0f, 360f, stage.SubjectEuler.z,
|
||
v => stage.SetSubjectRotation(new Vector3(stage.SubjectEuler.x, stage.SubjectEuler.y, v))));
|
||
|
||
VisualElement buttons = new VisualElement();
|
||
buttons.AddToClassList("ss-buttons");
|
||
|
||
Button frame = new Button(() => { stage.FrameTarget(); RenderPreview(); }) { text = "Frame" };
|
||
frame.AddToClassList("ss-btn");
|
||
buttons.Add(frame);
|
||
|
||
Button resetRotation = new Button(() => { stage.SetSubjectRotation(Vector3.zero); RebuildControls(); RenderPreview(); }) { text = "Reset Rotation" };
|
||
resetRotation.AddToClassList("ss-btn");
|
||
buttons.Add(resetRotation);
|
||
|
||
Toggle rotate = new Toggle("Auto-rotate") { value = autoRotate };
|
||
rotate.RegisterValueChangedCallback(evt => SetAutoRotate(evt.newValue));
|
||
buttons.Add(rotate);
|
||
|
||
card.Add(buttons);
|
||
return card;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The Unity type the object picker is restricted to for the current kind — GameObject for
|
||
/// prefabs/scene objects, Mesh for raw meshes — so nothing else appears in the picker.
|
||
/// </summary>
|
||
private Type TargetObjectType() => targetKind == TargetKind.Mesh ? typeof(Mesh) : typeof(GameObject);
|
||
|
||
/// <summary>
|
||
/// Camera card: projection, field of view and framing padding.
|
||
/// </summary>
|
||
private VisualElement BuildCameraCard()
|
||
{
|
||
VisualElement card = Card("Camera");
|
||
|
||
Toggle ortho = new Toggle("Orthographic") { value = stage.Orthographic };
|
||
ortho.RegisterValueChangedCallback(evt => { stage.Orthographic = evt.newValue; RenderPreview(); });
|
||
card.Add(ortho);
|
||
|
||
card.Add(SliderRow("Field of View", 10f, 80f, stage.FieldOfView, v => stage.FieldOfView = v));
|
||
card.Add(SliderRow("Padding", 1f, 3f, stage.Padding, v => stage.Padding = v));
|
||
return card;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Lighting card: a preset picker plus fine control over the key light (colour, intensity,
|
||
/// direction), the fill and rim intensities and the ambient wash.
|
||
/// </summary>
|
||
private VisualElement BuildLightingCard()
|
||
{
|
||
VisualElement card = Card("Lighting");
|
||
|
||
DropdownField presets = new DropdownField("Preset",
|
||
new System.Collections.Generic.List<string> { "Studio", "Soft", "Dramatic", "Flat" }, 0);
|
||
presets.RegisterValueChangedCallback(evt => ApplyPreset((LightingPreset)presets.index));
|
||
card.Add(presets);
|
||
|
||
ColorField keyColor = new ColorField("Key Colour") { value = stage.KeyColor, showAlpha = false };
|
||
keyColor.RegisterValueChangedCallback(evt => { stage.KeyColor = evt.newValue; RenderPreview(); });
|
||
card.Add(keyColor);
|
||
|
||
card.Add(SliderRow("Key Intensity", 0f, 3f, stage.KeyIntensity, v => stage.KeyIntensity = v));
|
||
card.Add(SliderRow("Key Yaw", 0f, 360f, stage.KeyYaw, v => stage.KeyYaw = v));
|
||
card.Add(SliderRow("Key Pitch", -80f, 80f, stage.KeyPitch, v => stage.KeyPitch = v));
|
||
card.Add(SliderRow("Fill Intensity", 0f, 2f, stage.FillIntensity, v => stage.FillIntensity = v));
|
||
card.Add(SliderRow("Rim Intensity", 0f, 3f, stage.RimIntensity, v => stage.RimIntensity = v));
|
||
|
||
ColorField ambient = new ColorField("Ambient Colour") { value = stage.AmbientColor, showAlpha = false };
|
||
ambient.RegisterValueChangedCallback(evt => { stage.AmbientColor = evt.newValue; RenderPreview(); });
|
||
card.Add(ambient);
|
||
|
||
card.Add(SliderRow("Ambient Intensity", 0f, 1.5f, stage.AmbientIntensity, v => stage.AmbientIntensity = v));
|
||
return card;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Background card: transparent vs solid, and the solid fill colour (disabled while transparent).
|
||
/// </summary>
|
||
private VisualElement BuildBackgroundCard()
|
||
{
|
||
VisualElement card = Card("Background");
|
||
|
||
EnumField mode = new EnumField("Mode", stage.Background);
|
||
mode.RegisterValueChangedCallback(evt =>
|
||
{
|
||
stage.Background = (ScreenshotStage.BackgroundMode)evt.newValue;
|
||
backgroundColorField.SetEnabled(stage.Background == ScreenshotStage.BackgroundMode.SolidColor);
|
||
RenderPreview();
|
||
});
|
||
card.Add(mode);
|
||
|
||
backgroundColorField = new ColorField("Colour") { value = stage.BackgroundColor, showAlpha = false };
|
||
backgroundColorField.SetEnabled(stage.Background == ScreenshotStage.BackgroundMode.SolidColor);
|
||
backgroundColorField.RegisterValueChangedCallback(evt => { stage.BackgroundColor = evt.newValue; RenderPreview(); });
|
||
card.Add(backgroundColorField);
|
||
|
||
return card;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Export card: resolution (presets + custom), supersampling, the output folder and file name,
|
||
/// and the PNG / turntable export actions.
|
||
/// </summary>
|
||
private VisualElement BuildExportCard()
|
||
{
|
||
VisualElement card = Card("Export");
|
||
|
||
DropdownField resolution = new DropdownField("Resolution",
|
||
new System.Collections.Generic.List<string> { "512", "1024", "2048", "4096", "Custom" }, 1);
|
||
resolution.RegisterValueChangedCallback(evt => ApplyResolutionPreset(resolution.value));
|
||
card.Add(resolution);
|
||
|
||
VisualElement size = new VisualElement();
|
||
size.AddToClassList("ss-row");
|
||
IntegerField width = new IntegerField("Width") { value = exportWidth };
|
||
width.RegisterValueChangedCallback(evt => exportWidth = Mathf.Clamp(evt.newValue, 16, 8192));
|
||
IntegerField height = new IntegerField("Height") { value = exportHeight };
|
||
height.RegisterValueChangedCallback(evt => exportHeight = Mathf.Clamp(evt.newValue, 16, 8192));
|
||
width.AddToClassList("ss-half");
|
||
height.AddToClassList("ss-half");
|
||
size.Add(width);
|
||
size.Add(height);
|
||
card.Add(size);
|
||
|
||
Toggle ss = new Toggle("Supersample ×2") { value = supersample };
|
||
ss.tooltip = "Renders at double resolution and downscales for crisper edges.";
|
||
ss.RegisterValueChangedCallback(evt => supersample = evt.newValue);
|
||
card.Add(ss);
|
||
|
||
card.Add(BuildFolderRow());
|
||
|
||
TextField file = new TextField("File Name") { value = exportFileName };
|
||
file.RegisterValueChangedCallback(evt => { exportFileName = evt.newValue; nameEditedByUser = true; });
|
||
card.Add(file);
|
||
|
||
Button export = new Button(ExportPng) { text = "Export PNG" };
|
||
export.AddToClassList("ss-btn");
|
||
export.AddToClassList("ss-btn--primary");
|
||
card.Add(export);
|
||
|
||
VisualElement turntable = new VisualElement();
|
||
turntable.AddToClassList("ss-row");
|
||
IntegerField frames = new IntegerField("Turntable") { value = turntableFrames };
|
||
frames.RegisterValueChangedCallback(evt => turntableFrames = Mathf.Clamp(evt.newValue, 2, 360));
|
||
frames.style.flexGrow = 1;
|
||
Button exportTurntable = new Button(ExportTurntable) { text = "Export Sequence" };
|
||
exportTurntable.AddToClassList("ss-btn");
|
||
turntable.Add(frames);
|
||
turntable.Add(exportTurntable);
|
||
card.Add(turntable);
|
||
|
||
return card;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds the output-folder row: a read-only path label and a Browse button that opens a folder
|
||
/// picker and remembers the choice in EditorPrefs. The picker starts from the resolved export
|
||
/// folder (normalised to native separators) so it opens where the label points — the native
|
||
/// Windows dialog ignores forward-slash paths like Application.dataPath and would otherwise fall
|
||
/// back to the shell's last-used location (often another project entirely).
|
||
/// </summary>
|
||
private VisualElement BuildFolderRow()
|
||
{
|
||
VisualElement row = new VisualElement();
|
||
row.AddToClassList("ss-row");
|
||
|
||
Label path = new Label(ShortFolder());
|
||
path.AddToClassList("ss-path");
|
||
path.tooltip = ResolveFolder();
|
||
|
||
Button browse = new Button(() =>
|
||
{
|
||
string start = ResolveFolder().Replace('/', Path.DirectorySeparatorChar);
|
||
string chosen = EditorUtility.OpenFolderPanel("Export folder", start, string.Empty);
|
||
if (string.IsNullOrEmpty(chosen)) return;
|
||
exportFolder = chosen;
|
||
EditorPrefs.SetString(FolderPrefKey, exportFolder);
|
||
path.text = ShortFolder();
|
||
path.tooltip = exportFolder;
|
||
})
|
||
{ text = "Browse" };
|
||
browse.AddToClassList("ss-btn");
|
||
|
||
row.Add(path);
|
||
row.Add(browse);
|
||
return row;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Control Builders
|
||
|
||
/// <summary>
|
||
/// Builds a titled card container matching the studio styling; callers append their fields.
|
||
/// </summary>
|
||
private static VisualElement Card(string title)
|
||
{
|
||
VisualElement card = new VisualElement();
|
||
card.AddToClassList("ss-card");
|
||
|
||
Label heading = new Label(title);
|
||
heading.AddToClassList("ss-card__title");
|
||
card.Add(heading);
|
||
return card;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Builds a labelled slider paired with a numeric field; both edit the same value and re-render
|
||
/// the preview live. The setter writes straight into the stage config.
|
||
/// </summary>
|
||
private VisualElement SliderRow(string label, float min, float max, float value, Action<float> setter)
|
||
{
|
||
VisualElement row = new VisualElement();
|
||
row.AddToClassList("ss-row");
|
||
|
||
Slider slider = new Slider(label, min, max) { value = value };
|
||
slider.AddToClassList("ss-slider");
|
||
slider.style.flexGrow = 1;
|
||
|
||
FloatField field = new FloatField { value = value };
|
||
field.AddToClassList("ss-num");
|
||
|
||
slider.RegisterValueChangedCallback(evt =>
|
||
{
|
||
field.SetValueWithoutNotify(evt.newValue);
|
||
setter(evt.newValue);
|
||
RenderPreview();
|
||
});
|
||
field.RegisterValueChangedCallback(evt =>
|
||
{
|
||
float clamped = Mathf.Clamp(evt.newValue, min, max);
|
||
slider.SetValueWithoutNotify(clamped);
|
||
setter(clamped);
|
||
RenderPreview();
|
||
});
|
||
|
||
row.Add(slider);
|
||
row.Add(field);
|
||
return row;
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Target
|
||
|
||
/// <summary>
|
||
/// Loads a new subject into the stage, defaults the export file name to its name (unless the user
|
||
/// already typed one), rebuilds the controls to reflect the reset framing and re-renders.
|
||
/// </summary>
|
||
private void LoadTarget(UnityEngine.Object source)
|
||
{
|
||
stage.SetTarget(source);
|
||
|
||
if (source != null && !nameEditedByUser)
|
||
exportFileName = source.name;
|
||
|
||
emptyOverlay.style.display = stage.HasTarget ? DisplayStyle.None : DisplayStyle.Flex;
|
||
RebuildControls();
|
||
RenderPreview();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Presets
|
||
|
||
/// <summary>
|
||
/// Applies a named lighting rig to the stage and rebuilds the lighting controls to match.
|
||
/// </summary>
|
||
private void ApplyPreset(LightingPreset preset)
|
||
{
|
||
switch (preset)
|
||
{
|
||
case LightingPreset.Studio:
|
||
stage.KeyColor = Color.white; stage.KeyIntensity = 1.1f; stage.KeyYaw = 50f; stage.KeyPitch = 40f;
|
||
stage.FillIntensity = 0.45f; stage.RimIntensity = 0.9f;
|
||
stage.AmbientColor = new Color(0.5f, 0.54f, 0.62f); stage.AmbientIntensity = 0.35f;
|
||
break;
|
||
case LightingPreset.Soft:
|
||
stage.KeyColor = new Color(1f, 0.97f, 0.92f); stage.KeyIntensity = 0.85f; stage.KeyYaw = 40f; stage.KeyPitch = 55f;
|
||
stage.FillIntensity = 0.7f; stage.RimIntensity = 0.4f;
|
||
stage.AmbientColor = new Color(0.62f, 0.64f, 0.7f); stage.AmbientIntensity = 0.6f;
|
||
break;
|
||
case LightingPreset.Dramatic:
|
||
stage.KeyColor = new Color(1f, 0.95f, 0.85f); stage.KeyIntensity = 1.7f; stage.KeyYaw = 65f; stage.KeyPitch = 25f;
|
||
stage.FillIntensity = 0.1f; stage.RimIntensity = 1.6f;
|
||
stage.AmbientColor = new Color(0.3f, 0.34f, 0.45f); stage.AmbientIntensity = 0.12f;
|
||
break;
|
||
case LightingPreset.Flat:
|
||
stage.KeyColor = Color.white; stage.KeyIntensity = 0.9f; stage.KeyYaw = 0f; stage.KeyPitch = 30f;
|
||
stage.FillIntensity = 0.9f; stage.RimIntensity = 0f;
|
||
stage.AmbientColor = Color.white; stage.AmbientIntensity = 0.8f;
|
||
break;
|
||
}
|
||
|
||
RebuildControls();
|
||
RenderPreview();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Sets the export resolution from the preset dropdown; "Custom" leaves the current width/height
|
||
/// untouched so the fields stay editable.
|
||
/// </summary>
|
||
private void ApplyResolutionPreset(string choice)
|
||
{
|
||
if (choice == "Custom") return;
|
||
if (int.TryParse(choice, out int size))
|
||
{
|
||
exportWidth = size;
|
||
exportHeight = size;
|
||
RebuildControls();
|
||
RenderPreview();
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Preview Rendering
|
||
|
||
/// <summary>
|
||
/// Renders the subject into the live texture at the preview aspect and pushes it into the Image.
|
||
/// A no-op (blank surface) until a target is loaded.
|
||
/// </summary>
|
||
private void RenderPreview()
|
||
{
|
||
if (stage == null) return;
|
||
|
||
if (!stage.HasTarget)
|
||
{
|
||
previewImage.image = null;
|
||
previewImage.MarkDirtyRepaint();
|
||
return;
|
||
}
|
||
|
||
Vector2Int size = LiveSize();
|
||
RenderTexture rt = stage.RenderLive(size.x, size.y);
|
||
previewImage.image = rt;
|
||
previewImage.MarkDirtyRepaint();
|
||
}
|
||
|
||
/// <summary>
|
||
/// The live render resolution: the export aspect ratio scaled so its long side is a fixed size,
|
||
/// keeping the preview crisp without paying full export cost every frame.
|
||
/// </summary>
|
||
private Vector2Int LiveSize()
|
||
{
|
||
float aspect = (float)Mathf.Max(1, exportWidth) / Mathf.Max(1, exportHeight);
|
||
int w = aspect >= 1f ? LivePreviewLongSide : Mathf.RoundToInt(LivePreviewLongSide * aspect);
|
||
int h = aspect >= 1f ? Mathf.RoundToInt(LivePreviewLongSide / aspect) : LivePreviewLongSide;
|
||
return new Vector2Int(Mathf.Max(16, w), Mathf.Max(16, h));
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Preview Interaction
|
||
|
||
/// <summary>
|
||
/// Begins an orbit drag, capturing the pointer so the drag continues even when it leaves the image.
|
||
/// </summary>
|
||
private void OnPreviewPointerDown(PointerDownEvent evt)
|
||
{
|
||
if (!stage.HasTarget) return;
|
||
dragging = true;
|
||
lastPointer = evt.position;
|
||
previewImage.CapturePointer(evt.pointerId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Orbits the camera by the pointer delta while dragging (horizontal → yaw, vertical → pitch).
|
||
/// </summary>
|
||
private void OnPreviewPointerMove(PointerMoveEvent evt)
|
||
{
|
||
if (!dragging) return;
|
||
|
||
Vector2 delta = (Vector2)evt.position - lastPointer;
|
||
lastPointer = evt.position;
|
||
|
||
stage.Yaw += delta.x * 0.5f;
|
||
stage.Pitch = Mathf.Clamp(stage.Pitch - delta.y * 0.5f, -89f, 89f);
|
||
RenderPreview();
|
||
}
|
||
|
||
/// <summary>
|
||
/// Ends the orbit drag and releases the pointer capture.
|
||
/// </summary>
|
||
private void OnPreviewPointerUp(PointerUpEvent evt)
|
||
{
|
||
dragging = false;
|
||
previewImage.ReleasePointer(evt.pointerId);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Zooms the framing on scroll, clamped so the subject can neither invert nor fly off.
|
||
/// </summary>
|
||
private void OnPreviewWheel(WheelEvent evt)
|
||
{
|
||
if (!stage.HasTarget) return;
|
||
stage.Zoom = Mathf.Clamp(stage.Zoom * (1f - evt.delta.y * 0.05f), 0.2f, 5f);
|
||
RenderPreview();
|
||
evt.StopPropagation();
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Auto-Rotate
|
||
|
||
/// <summary>
|
||
/// Toggles the turntable spin, driving a steady yaw increment through the window scheduler so the
|
||
/// preview animates without a per-frame editor update hook.
|
||
/// </summary>
|
||
private void SetAutoRotate(bool enabled)
|
||
{
|
||
autoRotate = enabled;
|
||
if (enabled)
|
||
{
|
||
rotateSchedule = rootVisualElement.schedule.Execute(() =>
|
||
{
|
||
stage.Yaw += 1.2f;
|
||
RenderPreview();
|
||
}).Every(33);
|
||
}
|
||
else
|
||
{
|
||
rotateSchedule?.Pause();
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Export
|
||
|
||
/// <summary>
|
||
/// Captures the current view at the export resolution and writes it as a PNG to the chosen folder,
|
||
/// avoiding overwrites by auto-numbering, importing it as a single-mode Sprite when the path is
|
||
/// in-project, and revealing the file. Guards a missing target and failed writes with clear logs.
|
||
/// </summary>
|
||
private void ExportPng()
|
||
{
|
||
if (!stage.HasTarget)
|
||
{
|
||
Debug.LogError("[ScreenshotStudio] No target assigned — nothing to export.");
|
||
return;
|
||
}
|
||
|
||
Texture2D shot = stage.Capture(exportWidth, exportHeight, supersample);
|
||
if (shot == null) return;
|
||
|
||
byte[] png = shot.EncodeToPNG();
|
||
DestroyImmediate(shot);
|
||
|
||
string path = UniquePath(ResolveFolder(), ResolveFileName());
|
||
try
|
||
{
|
||
File.WriteAllBytes(path, png);
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogError($"[ScreenshotStudio] Failed to write '{path}': {e.Message}");
|
||
return;
|
||
}
|
||
|
||
EditorPrefs.SetString(FilePrefKey, exportFileName);
|
||
ImportAsSprite(path);
|
||
Debug.Log($"[ScreenshotStudio] Exported {exportWidth}×{exportHeight} PNG → {path}");
|
||
ShowNotification(new GUIContent($"Exported {Path.GetFileName(path)}"));
|
||
}
|
||
|
||
/// <summary>
|
||
/// Bakes a turntable: N evenly-spaced frames around the Y axis, each written as a numbered PNG.
|
||
/// Restores the original yaw afterwards and shows a cancellable progress bar.
|
||
/// </summary>
|
||
private void ExportTurntable()
|
||
{
|
||
if (!stage.HasTarget)
|
||
{
|
||
Debug.LogError("[ScreenshotStudio] No target assigned — nothing to export.");
|
||
return;
|
||
}
|
||
|
||
string folder = ResolveFolder();
|
||
string baseName = Path.GetFileNameWithoutExtension(ResolveFileName());
|
||
float startYaw = stage.Yaw;
|
||
System.Collections.Generic.List<string> written = new System.Collections.Generic.List<string>();
|
||
|
||
try
|
||
{
|
||
for (int i = 0; i < turntableFrames; i++)
|
||
{
|
||
if (EditorUtility.DisplayCancelableProgressBar("Screenshot Studio",
|
||
$"Rendering frame {i + 1}/{turntableFrames}", (float)i / turntableFrames))
|
||
break;
|
||
|
||
stage.Yaw = startYaw + 360f * i / turntableFrames;
|
||
Texture2D frame = stage.Capture(exportWidth, exportHeight, supersample);
|
||
if (frame == null) break;
|
||
|
||
byte[] png = frame.EncodeToPNG();
|
||
DestroyImmediate(frame);
|
||
string framePath = Path.Combine(folder, $"{baseName}_{i:000}.png");
|
||
File.WriteAllBytes(framePath, png);
|
||
written.Add(framePath);
|
||
}
|
||
}
|
||
catch (Exception e)
|
||
{
|
||
Debug.LogError($"[ScreenshotStudio] Turntable export failed: {e.Message}");
|
||
}
|
||
finally
|
||
{
|
||
EditorUtility.ClearProgressBar();
|
||
stage.Yaw = startYaw;
|
||
foreach (string framePath in written) ImportAsSprite(framePath);
|
||
RenderPreview();
|
||
Debug.Log($"[ScreenshotStudio] Exported {written.Count}-frame turntable → {folder}");
|
||
ShowNotification(new GUIContent($"Exported {written.Count} frames"));
|
||
}
|
||
}
|
||
|
||
#endregion
|
||
|
||
#region Export Helpers
|
||
|
||
/// <summary>
|
||
/// The resolved export folder, falling back to the project Assets folder when unset or missing.
|
||
/// </summary>
|
||
private string ResolveFolder()
|
||
{
|
||
if (string.IsNullOrEmpty(exportFolder) || !Directory.Exists(exportFolder))
|
||
return Application.dataPath;
|
||
return exportFolder;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The export file name, defaulting to the target's name and always ending in ".png".
|
||
/// </summary>
|
||
private string ResolveFileName()
|
||
{
|
||
string name = string.IsNullOrWhiteSpace(exportFileName) ? "Screenshot" : exportFileName.Trim();
|
||
if (!name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) name += ".png";
|
||
return name;
|
||
}
|
||
|
||
/// <summary>
|
||
/// Returns a non-colliding path in the folder, appending _001, _002, … when the name is taken.
|
||
/// </summary>
|
||
private static string UniquePath(string folder, string fileName)
|
||
{
|
||
string full = Path.Combine(folder, fileName);
|
||
if (!File.Exists(full)) return full;
|
||
|
||
string stem = Path.GetFileNameWithoutExtension(fileName);
|
||
string ext = Path.GetExtension(fileName);
|
||
for (int i = 1; i < 10000; i++)
|
||
{
|
||
string candidate = Path.Combine(folder, $"{stem}_{i:000}{ext}");
|
||
if (!File.Exists(candidate)) return candidate;
|
||
}
|
||
return full;
|
||
}
|
||
|
||
/// <summary>
|
||
/// The project-relative "Assets/…" path for a written file, or null when it lives outside the
|
||
/// project — so external-folder exports skip the in-project import step entirely.
|
||
/// </summary>
|
||
private static string ToAssetPath(string path)
|
||
{
|
||
string full = Path.GetFullPath(path).Replace('\\', '/');
|
||
string root = Path.GetFullPath(Application.dataPath).Replace('\\', '/');
|
||
if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return null;
|
||
|
||
return "Assets" + full.Substring(root.Length);
|
||
}
|
||
|
||
/// <summary>
|
||
/// Imports the written file when it lives under Assets and retypes it as a single-mode Sprite, so
|
||
/// studio exports drop straight into UI/inventory work without a manual texture-type change in the
|
||
/// inspector. Files exported to an external folder are left untouched.
|
||
/// </summary>
|
||
private static void ImportAsSprite(string path)
|
||
{
|
||
string assetPath = ToAssetPath(path);
|
||
if (assetPath == null) return;
|
||
|
||
AssetDatabase.ImportAsset(assetPath, ImportAssetOptions.ForceSynchronousImport);
|
||
|
||
TextureImporter importer = AssetImporter.GetAtPath(assetPath) as TextureImporter;
|
||
if (importer == null)
|
||
{
|
||
Debug.LogError($"[ScreenshotStudio] No TextureImporter for '{assetPath}' — cannot set Sprite type.");
|
||
return;
|
||
}
|
||
|
||
importer.textureType = TextureImporterType.Sprite;
|
||
importer.spriteImportMode = SpriteImportMode.Single;
|
||
importer.SaveAndReimport();
|
||
}
|
||
|
||
/// <summary>
|
||
/// A compact, right-aligned form of the export folder for the path label.
|
||
/// </summary>
|
||
private string ShortFolder()
|
||
{
|
||
string folder = ResolveFolder();
|
||
const int max = 34;
|
||
return folder.Length <= max ? folder : "…" + folder.Substring(folder.Length - max);
|
||
}
|
||
|
||
#endregion
|
||
}
|
||
}
|