Cozy Wather + buld systeme

This commit is contained in:
2026-07-07 16:43:51 +02:00
parent 031ac42e5d
commit 6b26ae3376
2140 changed files with 3482825 additions and 2252 deletions
@@ -0,0 +1,749 @@
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 }
#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
/// <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: the object field (prefab / scene object / mesh) plus quick Frame and auto-rotate
/// controls that act on the loaded subject.
/// </summary>
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;
}
/// <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.
/// </summary>
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
/// <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, refreshing the AssetDatabase 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);
RefreshIfInProject(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;
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
/// <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>
/// Imports the written file so it appears in the Project window when it lives under Assets.
/// </summary>
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();
}
/// <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
}
}