using System; using System.IO; using UnityEditor; using UnityEditor.UIElements; using UnityEngine; using UnityEngine.UIElements; namespace Ashwild.EditorTools { /// /// 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. /// public class ScreenshotStudioWindow : EditorWindow { #region Types /// /// The named lighting rigs offered in the presets dropdown; Apply pushes each into the stage. /// private enum LightingPreset { Studio, Soft, Dramatic, Flat } #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 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 /// /// Opens (or focuses) the Screenshot Studio window. /// [MenuItem("Tools/Screenshot Studio")] public static void Open() { ScreenshotStudioWindow window = GetWindow(); window.titleContent = new GUIContent("Screenshot Studio"); window.minSize = new Vector2(880, 520); } #endregion #region Window Lifecycle /// /// Builds the window tree and the render stage. Loads the last export folder from EditorPrefs. /// 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(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(); } /// /// Tears the stage down so the preview scene and textures never survive the window. /// private void OnDisable() { rotateSchedule?.Pause(); stage?.Dispose(); stage = null; } #endregion #region Preview Pane /// /// 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. /// private VisualElement BuildPreviewPane() { VisualElement pane = new VisualElement(); pane.AddToClassList("ss-preview"); previewImage = new Image { scaleMode = ScaleMode.ScaleToFit }; previewImage.AddToClassList("ss-preview__image"); previewImage.RegisterCallback(OnPreviewPointerDown); previewImage.RegisterCallback(OnPreviewPointerMove); previewImage.RegisterCallback(OnPreviewPointerUp); previewImage.RegisterCallback(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 /// /// Builds the scrollable right pane container the control cards are rendered into. /// private VisualElement BuildControlsPane() { controls = new ScrollView(); controls.AddToClassList("ss-controls"); return controls; } /// /// 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. /// private void RebuildControls() { controls.Clear(); controls.Add(BuildTargetCard()); controls.Add(BuildCameraCard()); controls.Add(BuildLightingCard()); controls.Add(BuildBackgroundCard()); controls.Add(BuildExportCard()); } /// /// Target card: the object field (prefab / scene object / mesh) plus quick Frame and auto-rotate /// controls that act on the loaded subject. /// private VisualElement BuildTargetCard() { VisualElement card = Card("Target"); ObjectField field = new ObjectField("Object") { objectType = typeof(UnityEngine.Object), allowSceneObjects = true }; field.RegisterValueChangedCallback(evt => LoadTarget(evt.newValue)); card.Add(field); VisualElement buttons = new VisualElement(); buttons.AddToClassList("ss-buttons"); Button frame = new Button(() => { stage.FrameTarget(); RenderPreview(); }) { text = "Frame" }; frame.AddToClassList("ss-btn"); buttons.Add(frame); Toggle rotate = new Toggle("Auto-rotate") { value = autoRotate }; rotate.RegisterValueChangedCallback(evt => SetAutoRotate(evt.newValue)); buttons.Add(rotate); card.Add(buttons); return card; } /// /// Camera card: projection, field of view and framing padding. /// 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; } /// /// Lighting card: a preset picker plus fine control over the key light (colour, intensity, /// direction), the fill and rim intensities and the ambient wash. /// private VisualElement BuildLightingCard() { VisualElement card = Card("Lighting"); DropdownField presets = new DropdownField("Preset", new System.Collections.Generic.List { "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; } /// /// Background card: transparent vs solid, and the solid fill colour (disabled while transparent). /// 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; } /// /// Export card: resolution (presets + custom), supersampling, the output folder and file name, /// and the PNG / turntable export actions. /// private VisualElement BuildExportCard() { VisualElement card = Card("Export"); DropdownField resolution = new DropdownField("Resolution", new System.Collections.Generic.List { "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; } /// /// 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. /// private VisualElement BuildFolderRow() { VisualElement row = new VisualElement(); row.AddToClassList("ss-row"); Label path = new Label(ShortFolder()); path.AddToClassList("ss-path"); path.tooltip = exportFolder; Button browse = new Button(() => { string chosen = EditorUtility.OpenFolderPanel("Export folder", exportFolder, 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 /// /// Builds a titled card container matching the studio styling; callers append their fields. /// 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; } /// /// 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. /// private VisualElement SliderRow(string label, float min, float max, float value, Action 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 /// /// 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. /// 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 /// /// Applies a named lighting rig to the stage and rebuilds the lighting controls to match. /// 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(); } /// /// Sets the export resolution from the preset dropdown; "Custom" leaves the current width/height /// untouched so the fields stay editable. /// 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 /// /// 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. /// 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(); } /// /// 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. /// 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 /// /// Begins an orbit drag, capturing the pointer so the drag continues even when it leaves the image. /// private void OnPreviewPointerDown(PointerDownEvent evt) { if (!stage.HasTarget) return; dragging = true; lastPointer = evt.position; previewImage.CapturePointer(evt.pointerId); } /// /// Orbits the camera by the pointer delta while dragging (horizontal → yaw, vertical → pitch). /// 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(); } /// /// Ends the orbit drag and releases the pointer capture. /// private void OnPreviewPointerUp(PointerUpEvent evt) { dragging = false; previewImage.ReleasePointer(evt.pointerId); } /// /// Zooms the framing on scroll, clamped so the subject can neither invert nor fly off. /// 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 /// /// Toggles the turntable spin, driving a steady yaw increment through the window scheduler so the /// preview animates without a per-frame editor update hook. /// 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 /// /// Captures the current view at the export resolution and writes it as a PNG to the chosen folder, /// avoiding overwrites by auto-numbering, refreshing the AssetDatabase when the path is in-project, /// and revealing the file. Guards a missing target and failed writes with clear logs. /// 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); RefreshIfInProject(path); Debug.Log($"[ScreenshotStudio] Exported {exportWidth}×{exportHeight} PNG → {path}"); ShowNotification(new GUIContent($"Exported {Path.GetFileName(path)}")); } /// /// 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. /// 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; 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); File.WriteAllBytes(Path.Combine(folder, $"{baseName}_{i:000}.png"), png); } } catch (Exception e) { Debug.LogError($"[ScreenshotStudio] Turntable export failed: {e.Message}"); } finally { EditorUtility.ClearProgressBar(); stage.Yaw = startYaw; RefreshIfInProject(folder); RenderPreview(); Debug.Log($"[ScreenshotStudio] Exported {turntableFrames}-frame turntable → {folder}"); ShowNotification(new GUIContent($"Exported {turntableFrames} frames")); } } #endregion #region Export Helpers /// /// The resolved export folder, falling back to the project Assets folder when unset or missing. /// private string ResolveFolder() { if (string.IsNullOrEmpty(exportFolder) || !Directory.Exists(exportFolder)) return Application.dataPath; return exportFolder; } /// /// The export file name, defaulting to the target's name and always ending in ".png". /// private string ResolveFileName() { string name = string.IsNullOrWhiteSpace(exportFileName) ? "Screenshot" : exportFileName.Trim(); if (!name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)) name += ".png"; return name; } /// /// Returns a non-colliding path in the folder, appending _001, _002, … when the name is taken. /// 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; } /// /// Imports the written file so it appears in the Project window when it lives under Assets. /// private static void RefreshIfInProject(string path) { string full = Path.GetFullPath(path).Replace('\\', '/'); string root = Path.GetFullPath(Application.dataPath).Replace('\\', '/'); if (!full.StartsWith(root, StringComparison.OrdinalIgnoreCase)) return; AssetDatabase.Refresh(); } /// /// A compact, right-aligned form of the export folder for the path label. /// private string ShortFolder() { string folder = ResolveFolder(); const int max = 34; return folder.Length <= max ? folder : "…" + folder.Substring(folder.Length - max); } #endregion } }