using System; using System.Collections.Generic; using System.IO; using System.Linq; using System.Text; using UnityEngine; using UnityEditor.Graphing; using UnityEditor.Graphing.Util; using Debug = UnityEngine.Debug; using Object = UnityEngine.Object; using UnityEngine.Rendering; using UnityEditor.UIElements; using Edge = UnityEditor.Experimental.GraphView.Edge; using UnityEditor.Experimental.GraphView; using UnityEditor.ShaderGraph.Internal; using UnityEditor.ShaderGraph.Serialization; using UnityEngine.UIElements; using UnityEditor.VersionControl; using Unity.Profiling; using UnityEngine.Assertions; namespace UnityEditor.ShaderGraph.Drawing { class MaterialGraphEditWindow : EditorWindow { // For conversion to Sub Graph: keys for remembering the user's desired path const string k_PrevSubGraphPathKey = "SHADER_GRAPH_CONVERT_TO_SUB_GRAPH_PATH"; const string k_PrevSubGraphPathDefaultValue = "?"; // Special character that NTFS does not allow, so that no directory could match it. [SerializeField] string m_Selected; [SerializeField] GraphObject m_GraphObject; // this stores the contents of the file on disk, as of the last time we saved or loaded it from disk [SerializeField] string m_LastSerializedFileContents; [NonSerialized] HashSet m_ChangedFileDependencyGUIDs = new HashSet(); ColorSpace m_ColorSpace; RenderPipelineAsset m_RenderPipelineAsset; [NonSerialized] bool m_FrameAllAfterLayout; [NonSerialized] bool m_HasError; [NonSerialized] bool m_ProTheme; [NonSerialized] int m_customInterpWarn; [NonSerialized] int m_customInterpErr; [SerializeField] bool m_AssetMaybeChangedOnDisk; [SerializeField] bool m_AssetMaybeDeleted; internal bool WereWindowResourcesDisposed { get; private set; } MessageManager m_MessageManager; MessageManager messageManager { get { return m_MessageManager ?? (m_MessageManager = new MessageManager()); } } GraphEditorView m_GraphEditorView; internal GraphEditorView graphEditorView { get { return m_GraphEditorView; } private set { if (m_GraphEditorView != null) { m_GraphEditorView.RemoveFromHierarchy(); m_GraphEditorView.Dispose(); } m_GraphEditorView = value; if (m_GraphEditorView != null) { m_GraphEditorView.saveRequested += () => SaveAsset(); m_GraphEditorView.saveAsRequested += SaveAs; m_GraphEditorView.convertToSubgraphRequested += ToSubGraph; m_GraphEditorView.showInProjectRequested += PingAsset; m_GraphEditorView.isCheckedOut += IsGraphAssetCheckedOut; m_GraphEditorView.checkOut += CheckoutAsset; m_GraphEditorView.RegisterCallback(OnGeometryChanged); m_FrameAllAfterLayout = true; this.rootVisualElement.Add(graphEditorView); } } } internal GraphObject graphObject { get { return m_GraphObject; } set { if (m_GraphObject != null) DestroyImmediate(m_GraphObject); m_GraphObject = value; } } public string selectedGuid { get { return m_Selected; } private set { m_Selected = value; } } public string assetName { get { return titleContent.text; } } [field: NonSerialized] internal bool isVisible { get; private set; } bool AssetFileExists() { var assetPath = AssetDatabase.GUIDToAssetPath(selectedGuid); return File.Exists(assetPath); } // returns true when the graph has been successfully saved, or the user has indicated they are ok with discarding the local graph // returns false when saving has failed bool DisplayDeletedFromDiskDialog(bool reopen = true) { // first double check if we've actually been deleted bool saved = false; bool okToClose = false; string originalAssetPath = AssetDatabase.GUIDToAssetPath(selectedGuid); while (true) { int option = EditorUtility.DisplayDialogComplex( "Graph removed from project", "The file has been deleted or removed from the project folder.\n\n" + originalAssetPath + "\n\nWould you like to save your Graph Asset?", "Save As...", "Cancel", "Discard Graph and Close Window"); if (option == 0) { string savedPath = SaveAsImplementation(false); if (savedPath != null) { saved = true; // either close or reopen the local window editor graphObject = null; selectedGuid = (reopen ? AssetDatabase.AssetPathToGUID(savedPath) : null); break; } } else if (option == 2) { okToClose = true; graphObject = null; selectedGuid = null; break; } else if (option == 1) { // continue in deleted state... break; } } return (saved || okToClose); } void Update() { if (m_HasError) return; bool updateTitle = false; if (m_AssetMaybeDeleted) { m_AssetMaybeDeleted = false; if (AssetFileExists()) { // it exists... just to be sure, let's check if it changed m_AssetMaybeChangedOnDisk = true; } else { // it was really deleted, ask the user what to do bool handled = DisplayDeletedFromDiskDialog(true); } updateTitle = true; } if (PlayerSettings.colorSpace != m_ColorSpace) { graphEditorView = null; m_ColorSpace = PlayerSettings.colorSpace; } if (GraphicsSettings.currentRenderPipeline != m_RenderPipelineAsset) { graphEditorView = null; m_RenderPipelineAsset = GraphicsSettings.currentRenderPipeline; } if (EditorGUIUtility.isProSkin != m_ProTheme) { if (graphObject != null && graphObject.graph != null) { updateTitle = true; // trigger icon swap m_ProTheme = EditorGUIUtility.isProSkin; } } bool revalidate = false; if (m_customInterpWarn != ShaderGraphProjectSettings.instance.customInterpolatorWarningThreshold) { m_customInterpWarn = ShaderGraphProjectSettings.instance.customInterpolatorWarningThreshold; revalidate = true; } if (m_customInterpErr != ShaderGraphProjectSettings.instance.customInterpolatorErrorThreshold) { m_customInterpErr = ShaderGraphProjectSettings.instance.customInterpolatorErrorThreshold; revalidate = true; } if (revalidate) { graphEditorView?.graphView?.graph?.ValidateGraph(); } if (m_AssetMaybeChangedOnDisk) { m_AssetMaybeChangedOnDisk = false; // if we don't have any graph, then it doesn't really matter if the file on disk changed or not // as we're going to reload it below anyways if (graphObject?.graph != null) { // check if it actually did change on disk if (FileOnDiskHasChanged()) { // don't worry people about "losing changes" unless there are changes to lose bool graphChanged = GraphHasChangedSinceLastSerialization(); if (EditorUtility.DisplayDialog( "Graph has changed on disk", AssetDatabase.GUIDToAssetPath(selectedGuid) + "\n\n" + (graphChanged ? "Do you want to reload it and lose the changes made in the graph?" : "Do you want to reload it?"), graphChanged ? "Discard Changes And Reload" : "Reload", "Don't Reload")) { // clear graph, trigger reload graphObject = null; } } } updateTitle = true; } try { if (graphObject == null && selectedGuid != null) { var guid = selectedGuid; selectedGuid = null; Initialize(guid); } if (graphObject == null) { Close(); return; } var materialGraph = graphObject.graph as GraphData; if (materialGraph == null) return; if (graphEditorView == null) { messageManager.ClearAll(); materialGraph.messageManager = messageManager; string assetPath = AssetDatabase.GUIDToAssetPath(selectedGuid); string graphName = Path.GetFileNameWithoutExtension(assetPath); graphEditorView = new GraphEditorView(this, materialGraph, messageManager, graphName) { viewDataKey = selectedGuid, }; m_ColorSpace = PlayerSettings.colorSpace; m_RenderPipelineAsset = GraphicsSettings.currentRenderPipeline; graphObject.Validate(); // update blackboard title for the new graphEditorView updateTitle = true; } if (m_ChangedFileDependencyGUIDs.Count > 0 && graphObject != null && graphObject.graph != null) { bool reloadedSomething = false; foreach (var guid in m_ChangedFileDependencyGUIDs) { if (AssetDatabase.GUIDToAssetPath(guid) != null) { // update preview for changed textures graphEditorView?.previewManager?.ReloadChangedFiles(guid); } } var subGraphNodes = graphObject.graph.GetNodes(); foreach (var subGraphNode in subGraphNodes) { var reloaded = subGraphNode.Reload(m_ChangedFileDependencyGUIDs); reloadedSomething |= reloaded; } if (subGraphNodes.Count() > 0) { // Keywords always need to be updated to test against variant limit // No Keywords may indicate removal and this may have now made the Graph valid again // Need to validate Graph to clear errors in this case materialGraph.OnKeywordChanged(); UpdateDropdownEntries(); materialGraph.OnDropdownChanged(); } foreach (var customFunctionNode in graphObject.graph.GetNodes()) { var reloaded = customFunctionNode.Reload(m_ChangedFileDependencyGUIDs); reloadedSomething |= reloaded; } // reloading files may change serialization if (reloadedSomething) { updateTitle = true; // may also need to re-run validation/concretization graphObject.Validate(); } m_ChangedFileDependencyGUIDs.Clear(); } var wasUndoRedoPerformed = graphObject.wasUndoRedoPerformed; if (wasUndoRedoPerformed) { graphEditorView.HandleGraphChanges(true); graphObject.graph.ClearChanges(); graphObject.HandleUndoRedo(); } if (graphObject.isDirty || wasUndoRedoPerformed) { updateTitle = true; graphObject.isDirty = false; hasUnsavedChanges = false; } // Called again to handle changes from deserialization in case an undo/redo was performed graphEditorView.HandleGraphChanges(wasUndoRedoPerformed); graphObject.graph.ClearChanges(); if (wasUndoRedoPerformed) { graphEditorView.inspectorView.RefreshInspectables(); } if (updateTitle) UpdateTitle(); } catch (Exception e) { m_HasError = true; m_GraphEditorView = null; graphObject = null; Debug.LogException(e); throw; } } public void ReloadSubGraphsOnNextUpdate(List changedFileGUIDs) { foreach (var changedFileGUID in changedFileGUIDs) { m_ChangedFileDependencyGUIDs.Add(changedFileGUID); } } void UpdateDropdownEntries() { var subGraphNodes = graphObject.graph.GetNodes(); foreach (var subGraphNode in subGraphNodes) { var nodeView = graphEditorView.graphView.nodes.ToList().OfType() .FirstOrDefault(p => p.node != null && p.node == subGraphNode); if (nodeView != null) { nodeView.UpdateDropdownEntries(); } } } void OnEnable() { this.SetAntiAliasing(4); } void OnDisable() { m_GraphEditorView?.UnregisterCallback(OnGeometryChanged); m_GraphEditorView?.Dispose(); messageManager.ClearAll(); m_GraphEditorView = null; m_GraphObject = null; m_MessageManager = null; m_RenderPipelineAsset = null; Resources.UnloadUnusedAssets(); WereWindowResourcesDisposed = true; } // returns true only when the file on disk doesn't match the graph we last loaded or saved to disk (i.e. someone else changed it) internal bool FileOnDiskHasChanged() { var currentFileJson = ReadAssetFile(); return !string.Equals(currentFileJson, m_LastSerializedFileContents, StringComparison.Ordinal); } // returns true only when the graph in this window would serialize different from the last time we loaded or saved it internal bool GraphHasChangedSinceLastSerialization() { Assert.IsTrue(graphObject?.graph != null); // this should be checked by calling code var currentGraphJson = MultiJson.Serialize(graphObject.graph); return !string.Equals(currentGraphJson, m_LastSerializedFileContents, StringComparison.Ordinal); } // returns true only when saving the graph in this window would serialize different from the file on disk internal bool GraphIsDifferentFromFileOnDisk() { Assert.IsTrue(graphObject?.graph != null); // this should be checked by calling code var currentGraphJson = MultiJson.Serialize(graphObject.graph); var currentFileJson = ReadAssetFile(); return !string.Equals(currentGraphJson, currentFileJson, StringComparison.Ordinal); } // NOTE: we're using the AssetPostprocessor Asset Import and Deleted callbacks as a proxy for detecting file changes // We could probably replace both m_AssetMaybeDeleted and m_AssetMaybeChangedOnDisk with a combined "need to check the real status of the file" flag public void CheckForChanges() { if (!m_AssetMaybeDeleted && graphObject?.graph != null) { m_AssetMaybeChangedOnDisk = true; UpdateTitle(); } } public void AssetWasDeleted() { m_AssetMaybeDeleted = true; UpdateTitle(); } public void UpdateTitle() { string assetPath = AssetDatabase.GUIDToAssetPath(selectedGuid); string shaderName = Path.GetFileNameWithoutExtension(assetPath); // update blackboard title (before we add suffixes) if (graphEditorView != null) graphEditorView.assetName = shaderName; // build the window title (with suffixes) string title = shaderName; if (graphObject?.graph == null) title = title + " (nothing loaded)"; else { if (GraphHasChangedSinceLastSerialization()) { hasUnsavedChanges = true; // This is the message EditorWindow will show when prompting to close while dirty saveChangesMessage = GetSaveChangesMessage(); } else { hasUnsavedChanges = false; saveChangesMessage = ""; } if (!AssetFileExists()) title = title + " (deleted)"; } // get window icon bool isSubGraph = graphObject?.graph?.isSubGraph ?? false; Texture2D icon; { string theme = EditorGUIUtility.isProSkin ? "_dark" : "_light"; if (isSubGraph) icon = Resources.Load("Icons/sg_subgraph_icon_gray" + theme); else icon = Resources.Load("Icons/sg_graph_icon_gray" + theme); } titleContent = new GUIContent(title, icon); } void OnDestroy() { // Prompting the user if they want to close is mostly handled via the EditorWindow's system (hasUnsavedChanges). // There's unfortunately a code path (Reload Window) that doesn't go through this path. The old logic is left // here as a fallback to catch this. This has one edge case with "Reload Window" -> "Cancel" which will produce // two shader graph windows: one unmodified (that the editor opens) and one modified (that we open below). // we are closing the shadergraph window MaterialGraphEditWindow newWindow = null; if (!PromptSaveIfDirtyOnQuit()) { // user does not want to close the window. // we can't stop the close from this code path though.. // all we can do is open a new window and transfer our data to the new one to avoid losing it // newWin = Instantiate(this); newWindow = EditorWindow.CreateWindow(typeof(MaterialGraphEditWindow), typeof(SceneView)); newWindow.Initialize(this); } else { // the window is closing for good.. cleanup undo history for the graph object Undo.ClearUndo(graphObject); } // Discard any unsaved modification on the generated material if (graphObject && graphObject.materialArtifact && EditorUtility.IsDirty(graphObject.materialArtifact)) { var material = new Material(AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(graphObject.AssetGuid))); graphObject.materialArtifact.CopyPropertiesFromMaterial(material); CoreUtils.Destroy(material); } graphObject = null; graphEditorView = null; // show new window if we have one if (newWindow != null) { newWindow.Show(); newWindow.Focus(); } } public void PingAsset() { if (selectedGuid != null) { var path = AssetDatabase.GUIDToAssetPath(selectedGuid); var asset = AssetDatabase.LoadAssetAtPath(path); EditorGUIUtility.PingObject(asset); } } public bool IsGraphAssetCheckedOut() { if (selectedGuid != null) { var path = AssetDatabase.GUIDToAssetPath(selectedGuid); var asset = AssetDatabase.LoadAssetAtPath(path); if (!AssetDatabase.IsOpenForEdit(asset, StatusQueryOptions.UseCachedIfPossible)) return false; return true; } return false; } public void CheckoutAsset() { if (selectedGuid != null) { var path = AssetDatabase.GUIDToAssetPath(selectedGuid); var asset = AssetDatabase.LoadAssetAtPath(path); Task task = Provider.Checkout(asset, CheckoutMode.Both); task.Wait(); } } // returns true if the asset was succesfully saved public bool SaveAsset() { bool saved = false; if (selectedGuid != null && graphObject != null) { var path = AssetDatabase.GUIDToAssetPath(selectedGuid); if (string.IsNullOrEmpty(path) || graphObject == null) return false; if (GraphUtil.CheckForRecursiveDependencyOnPendingSave(path, graphObject.graph.GetNodes(), "Save")) return false; ShaderGraphAnalytics.SendShaderGraphEvent(selectedGuid, graphObject.graph); var oldShader = AssetDatabase.LoadAssetAtPath(path); if (oldShader != null) ShaderUtil.ClearShaderMessages(oldShader); var newFileContents = FileUtilities.WriteShaderGraphToDisk(path, graphObject.graph); if (newFileContents != null) { saved = true; m_LastSerializedFileContents = newFileContents; AssetDatabase.ImportAsset(path); } OnSaveGraph(path); hasUnsavedChanges = false; } UpdateTitle(); return saved; } void OnSaveGraph(string path) { if (GraphData.onSaveGraph == null) return; if (graphObject.graph.isSubGraph) return; var shader = AssetDatabase.LoadAssetAtPath(path); if (shader == null) return; foreach (var target in graphObject.graph.activeTargets) { GraphData.onSaveGraph(shader, target.saveContext); } } public void SaveAs() { SaveAsImplementation(true); } // returns the asset path the file was saved to, or NULL if nothing was saved string SaveAsImplementation(bool openWhenSaved) { string savedFilePath = null; if (selectedGuid != null && graphObject?.graph != null) { var oldFilePath = AssetDatabase.GUIDToAssetPath(selectedGuid); if (string.IsNullOrEmpty(oldFilePath) || graphObject == null) return null; // The asset's name needs to be removed from the path, otherwise SaveFilePanel assumes it's a folder string oldDirectory = Path.GetDirectoryName(oldFilePath); var extension = graphObject.graph.isSubGraph ? ShaderSubGraphImporter.Extension : ShaderGraphImporter.Extension; var newFilePath = EditorUtility.SaveFilePanelInProject("Save Graph As...", Path.GetFileNameWithoutExtension(oldFilePath), extension, "", oldDirectory); newFilePath = newFilePath.Replace(Application.dataPath, "Assets"); if (newFilePath != oldFilePath) { if (!string.IsNullOrEmpty(newFilePath)) { // If the newPath already exists, we are overwriting an existing file, and could be creating recursions. Let's check. if (GraphUtil.CheckForRecursiveDependencyOnPendingSave(newFilePath, graphObject.graph.GetNodes(), "Save As")) return null; bool success = (FileUtilities.WriteShaderGraphToDisk(newFilePath, graphObject.graph) != null); AssetDatabase.ImportAsset(newFilePath); if (success) { if (openWhenSaved) ShaderGraphImporterEditor.ShowGraphEditWindow(newFilePath); OnSaveGraph(newFilePath); savedFilePath = newFilePath; } } } else { // saving to the current path if (SaveAsset()) { graphObject.isDirty = false; savedFilePath = oldFilePath; } } } return savedFilePath; } public void ToSubGraph() { var graphView = graphEditorView.graphView; string path; string sessionStateResult = SessionState.GetString(k_PrevSubGraphPathKey, k_PrevSubGraphPathDefaultValue); string pathToOriginSG = Path.GetDirectoryName(AssetDatabase.GUIDToAssetPath(selectedGuid)); if (!sessionStateResult.Equals(k_PrevSubGraphPathDefaultValue)) { path = sessionStateResult; } else { path = pathToOriginSG; } path = EditorUtility.SaveFilePanelInProject("Save Sub Graph", "New Shader Sub Graph", ShaderSubGraphImporter.Extension, "", path); path = path.Replace(Application.dataPath, "Assets"); // Friendly warning that the user is generating a subgraph that would overwrite the one they are currently working on. if (AssetDatabase.AssetPathToGUID(path) == selectedGuid) { if (!EditorUtility.DisplayDialog("Overwrite Current Subgraph", "Do you want to overwrite this Sub Graph that you are currently working on? You cannot undo this operation.", "Yes", "Cancel")) { path = ""; } } if (path.Length == 0) return; var nodes = graphView.selection.OfType().Where(x => !(x.node is PropertyNode || x.node is SubGraphOutputNode)).Select(x => x.node).Where(x => x.allowedInSubGraph).ToArray(); // Convert To Subgraph could create recursive reference loops if the target path already exists // Let's check for that here if (!string.IsNullOrEmpty(path)) { if (GraphUtil.CheckForRecursiveDependencyOnPendingSave(path, nodes.OfType(), "Convert To SubGraph")) return; } graphObject.RegisterCompleteObjectUndo("Convert To Subgraph"); var bounds = Rect.MinMaxRect(float.PositiveInfinity, float.PositiveInfinity, float.NegativeInfinity, float.NegativeInfinity); foreach (var node in nodes) { var center = node.drawState.position.center; bounds = Rect.MinMaxRect( Mathf.Min(bounds.xMin, center.x), Mathf.Min(bounds.yMin, center.y), Mathf.Max(bounds.xMax, center.x), Mathf.Max(bounds.yMax, center.y)); } var middle = bounds.center; bounds.center = Vector2.zero; // Collect graph inputs var graphInputs = graphView.selection.OfType().Select(x => x.userData as ShaderInput); var categories = graphView.selection.OfType().Select(x => x.userData as CategoryData); // Collect the property nodes and get the corresponding properties var propertyNodes = graphView.selection.OfType().Where(x => (x.node is PropertyNode)).Select(x => ((PropertyNode)x.node).property); var metaProperties = graphView.graph.properties.Where(x => propertyNodes.Contains(x)); // Collect the keyword nodes and get the corresponding keywords var keywordNodes = graphView.selection.OfType().Where(x => (x.node is KeywordNode)).Select(x => ((KeywordNode)x.node).keyword); var dropdownNodes = graphView.selection.OfType().Where(x => (x.node is DropdownNode)).Select(x => ((DropdownNode)x.node).dropdown); var metaKeywords = graphView.graph.keywords.Where(x => keywordNodes.Contains(x)); var metaDropdowns = graphView.graph.dropdowns.Where(x => dropdownNodes.Contains(x)); var copyPasteGraph = new CopyPasteGraph(graphView.selection.OfType().Select(x => x.userData), nodes, graphView.selection.OfType().Select(x => x.userData as Graphing.Edge), graphInputs, categories, metaProperties, metaKeywords, metaDropdowns, graphView.selection.OfType().Select(x => x.userData), true, false); // why do we serialize and deserialize only to make copies of everything in the steps below? // is this just to clear out all non-serialized data? var deserialized = CopyPasteGraph.FromJson(MultiJson.Serialize(copyPasteGraph), graphView.graph); if (deserialized == null) return; var subGraph = new GraphData { isSubGraph = true, path = "Sub Graphs" }; var subGraphOutputNode = new SubGraphOutputNode(); { var drawState = subGraphOutputNode.drawState; drawState.position = new Rect(new Vector2(bounds.xMax + 200f, 0f), drawState.position.size); subGraphOutputNode.drawState = drawState; } subGraph.AddNode(subGraphOutputNode); subGraph.outputNode = subGraphOutputNode; // Always copy deserialized keyword inputs foreach (ShaderKeyword keyword in deserialized.metaKeywords) { var copiedInput = (ShaderKeyword)subGraph.AddCopyOfShaderInput(keyword); // Update the keyword nodes that depends on the copied keyword var dependentKeywordNodes = deserialized.GetNodes().Where(x => x.keyword == keyword); foreach (var node in dependentKeywordNodes) { node.owner = graphView.graph; node.keyword = copiedInput; } } // Always copy deserialized dropdown inputs foreach (ShaderDropdown dropdown in deserialized.metaDropdowns) { var copiedInput = (ShaderDropdown)subGraph.AddCopyOfShaderInput(dropdown); // Update the dropdown nodes that depends on the copied dropdown var dependentDropdownNodes = deserialized.GetNodes().Where(x => x.dropdown == dropdown); foreach (var node in dependentDropdownNodes) { node.owner = graphView.graph; node.dropdown = copiedInput; } } foreach (GroupData groupData in deserialized.groups) { subGraph.CreateGroup(groupData); } foreach (var node in deserialized.GetNodes()) { var drawState = node.drawState; drawState.position = new Rect(drawState.position.position - middle, drawState.position.size); node.drawState = drawState; // Checking if the group guid is also being copied. // If not then nullify that guid if (node.group != null && !subGraph.groups.Contains(node.group)) { node.group = null; } subGraph.AddNode(node); } foreach (var note in deserialized.stickyNotes) { if (note.group != null && !subGraph.groups.Contains(note.group)) { note.group = null; } subGraph.AddStickyNote(note); } // figure out what needs remapping var externalOutputSlots = new List(); var externalInputSlots = new List(); var passthroughSlots = new List(); foreach (var edge in deserialized.edges) { var outputSlot = edge.outputSlot; var inputSlot = edge.inputSlot; var outputSlotExistsInSubgraph = subGraph.ContainsNode(outputSlot.node); var inputSlotExistsInSubgraph = subGraph.ContainsNode(inputSlot.node); // pasting nice internal links! if (outputSlotExistsInSubgraph && inputSlotExistsInSubgraph) { subGraph.Connect(outputSlot, inputSlot); } // one edge needs to go to outside world else if (outputSlotExistsInSubgraph) { externalInputSlots.Add(edge); } else if (inputSlotExistsInSubgraph) { externalOutputSlots.Add(edge); } else { externalInputSlots.Add(edge); externalOutputSlots.Add(edge); passthroughSlots.Add(edge); } } // Find the unique edges coming INTO the graph var uniqueIncomingEdges = externalOutputSlots.GroupBy( edge => edge.outputSlot, edge => edge, (key, edges) => new { slotRef = key, edges = edges.ToList() }); var externalInputNeedingConnection = new List>(); var amountOfProps = uniqueIncomingEdges.Count(); const int height = 40; const int subtractHeight = 20; var propPos = new Vector2(0, -((amountOfProps / 2) + height) - subtractHeight); var passthroughSlotRefLookup = new Dictionary(); var passedInProperties = new Dictionary(); foreach (var group in uniqueIncomingEdges) { var sr = group.slotRef; var fromNode = sr.node; var fromSlot = sr.slot; var materialGraph = graphObject.graph; var fromProperty = fromNode is PropertyNode fromPropertyNode ? materialGraph.properties.FirstOrDefault(p => p == fromPropertyNode.property) : null; AbstractShaderProperty prop; if (fromProperty != null && passedInProperties.TryGetValue(fromProperty, out prop)) { } else { switch (fromSlot.concreteValueType) { case ConcreteSlotValueType.Texture2D: prop = new Texture2DShaderProperty(); break; case ConcreteSlotValueType.Texture2DArray: prop = new Texture2DArrayShaderProperty(); break; case ConcreteSlotValueType.Texture3D: prop = new Texture3DShaderProperty(); break; case ConcreteSlotValueType.Cubemap: prop = new CubemapShaderProperty(); break; case ConcreteSlotValueType.Vector4: prop = new Vector4ShaderProperty(); break; case ConcreteSlotValueType.Vector3: prop = new Vector3ShaderProperty(); break; case ConcreteSlotValueType.Vector2: prop = new Vector2ShaderProperty(); break; case ConcreteSlotValueType.Vector1: prop = new Vector1ShaderProperty(); break; case ConcreteSlotValueType.Boolean: prop = new BooleanShaderProperty(); break; case ConcreteSlotValueType.Matrix2: prop = new Matrix2ShaderProperty(); break; case ConcreteSlotValueType.Matrix3: prop = new Matrix3ShaderProperty(); break; case ConcreteSlotValueType.Matrix4: prop = new Matrix4ShaderProperty(); break; case ConcreteSlotValueType.SamplerState: prop = new SamplerStateShaderProperty(); break; case ConcreteSlotValueType.Gradient: prop = new GradientShaderProperty(); break; case ConcreteSlotValueType.VirtualTexture: prop = new VirtualTextureShaderProperty() { // also copy the VT settings over from the original property (if there is one) value = (fromProperty as VirtualTextureShaderProperty)?.value ?? new SerializableVirtualTexture() }; break; default: throw new ArgumentOutOfRangeException(); } var propName = fromProperty != null ? fromProperty.displayName : fromSlot.concreteValueType.ToString(); prop.SetDisplayNameAndSanitizeForGraph(subGraph, propName); if (fromProperty?.useCustomSlotLabel ?? false) { prop.useCustomSlotLabel = true; prop.customSlotLabel = fromProperty.customSlotLabel; } subGraph.AddGraphInput(prop); if (fromProperty != null) { passedInProperties.Add(fromProperty, prop); } } var propNode = new PropertyNode(); { var drawState = propNode.drawState; drawState.position = new Rect(new Vector2(bounds.xMin - 300f, 0f) + propPos, drawState.position.size); propPos += new Vector2(0, height); propNode.drawState = drawState; } subGraph.AddNode(propNode); propNode.property = prop; Vector2 avg = Vector2.zero; foreach (var edge in group.edges) { if (passthroughSlots.Contains(edge) && !passthroughSlotRefLookup.ContainsKey(sr)) { passthroughSlotRefLookup.Add(sr, new SlotReference(propNode, PropertyNode.OutputSlotId)); } else { subGraph.Connect( new SlotReference(propNode, PropertyNode.OutputSlotId), edge.inputSlot); int i; var inputs = edge.inputSlot.node.GetInputSlots().ToList(); for (i = 0; i < inputs.Count; ++i) { if (inputs[i].slotReference.slotId == edge.inputSlot.slotId) { break; } } avg += new Vector2(edge.inputSlot.node.drawState.position.xMin, edge.inputSlot.node.drawState.position.center.y + 30f * i); } //we collapse input properties so dont add edges that are already being added if (!externalInputNeedingConnection.Any(x => x.Key.outputSlot.slot == edge.outputSlot.slot && x.Value == prop)) { externalInputNeedingConnection.Add(new KeyValuePair(edge, prop)); } } avg /= group.edges.Count; var pos = avg - new Vector2(150f, 0f); propNode.drawState = new DrawState() { position = new Rect(pos, propNode.drawState.position.size), expanded = propNode.drawState.expanded }; } var uniqueOutgoingEdges = externalInputSlots.GroupBy( edge => edge.outputSlot, edge => edge, (key, edges) => new { slot = key, edges = edges.ToList() }); var externalOutputsNeedingConnection = new List>(); foreach (var group in uniqueOutgoingEdges) { var outputNode = subGraph.outputNode as SubGraphOutputNode; AbstractMaterialNode node = group.edges[0].outputSlot.node; MaterialSlot slot = node.FindSlot(group.edges[0].outputSlot.slotId); var slotId = outputNode.AddSlot(slot.concreteValueType); var inputSlotRef = new SlotReference(outputNode, slotId); foreach (var edge in group.edges) { var newEdge = subGraph.Connect(passthroughSlotRefLookup.TryGetValue(edge.outputSlot, out SlotReference remap) ? remap : edge.outputSlot, inputSlotRef); externalOutputsNeedingConnection.Add(new KeyValuePair(edge, newEdge)); } } if (FileUtilities.WriteShaderGraphToDisk(path, subGraph) != null) AssetDatabase.ImportAsset(path); // Store path for next time if (!pathToOriginSG.Equals(Path.GetDirectoryName(path))) { SessionState.SetString(k_PrevSubGraphPathKey, Path.GetDirectoryName(path)); } else { // Or continue to make it so that next time it will open up in the converted-from SG's directory SessionState.EraseString(k_PrevSubGraphPathKey); } var loadedSubGraph = AssetDatabase.LoadAssetAtPath(path, typeof(SubGraphAsset)) as SubGraphAsset; if (loadedSubGraph == null) return; var subGraphNode = new SubGraphNode(); var ds = subGraphNode.drawState; ds.position = new Rect(middle - new Vector2(100f, 150f), Vector2.zero); subGraphNode.drawState = ds; // Add the subgraph into the group if the nodes was all in the same group group var firstNode = copyPasteGraph.GetNodes().FirstOrDefault(); if (firstNode != null && copyPasteGraph.GetNodes().All(x => x.group == firstNode.group)) { subGraphNode.group = firstNode.group; } subGraphNode.asset = loadedSubGraph; graphObject.graph.AddNode(subGraphNode); foreach (var edgeMap in externalInputNeedingConnection) { graphObject.graph.Connect(edgeMap.Key.outputSlot, new SlotReference(subGraphNode, edgeMap.Value.guid.GetHashCode())); } foreach (var edgeMap in externalOutputsNeedingConnection) { graphObject.graph.Connect(new SlotReference(subGraphNode, edgeMap.Value.inputSlot.slotId), edgeMap.Key.inputSlot); } graphObject.graph.RemoveElements( graphView.selection.OfType().Select(x => x.node).Where(x => !(x is PropertyNode || x is SubGraphOutputNode) && x.allowedInSubGraph).ToArray(), new IEdge[] { }, new GroupData[] { }, graphView.selection.OfType().Select(x => x.userData).ToArray()); List moved = new List(); foreach (var nodeView in graphView.selection.OfType()) { var node = nodeView.node; if (graphView.graph.removedNodes.Contains(node) || node is SubGraphOutputNode) { continue; } var edges = graphView.graph.GetEdges(node); int numEdges = edges.Count(); if (numEdges == 0) { graphView.graph.RemoveNode(node); } else if (numEdges == 1 && edges.First().inputSlot.node != node) //its an output edge { var edge = edges.First(); int i; var inputs = edge.inputSlot.node.GetInputSlots().ToList(); for (i = 0; i < inputs.Count; ++i) { if (inputs[i].slotReference.slotId == edge.inputSlot.slotId) { break; } } node.drawState = new DrawState() { position = new Rect(new Vector2(edge.inputSlot.node.drawState.position.xMin, edge.inputSlot.node.drawState.position.center.y) - new Vector2(150f, -30f * i), node.drawState.position.size), expanded = node.drawState.expanded }; (nodeView as GraphElement).SetPosition(node.drawState.position); } } graphObject.graph.ValidateGraph(); } public void Initialize(MaterialGraphEditWindow other) { // create a new window that copies the entire editor state of an existing window // this function is used to "reopen" an editor window that is closing, but where the user has canceled the close // for example, if the graph of a closing window was dirty, but could not be saved try { selectedGuid = other.selectedGuid; graphObject = CreateInstance(); graphObject.hideFlags = HideFlags.HideAndDontSave; graphObject.graph = other.graphObject.graph; graphObject.graph.messageManager = this.messageManager; UpdateTitle(); Repaint(); } catch (Exception e) { Debug.LogException(e); m_HasError = true; m_GraphEditorView = null; graphObject = null; throw; } } private static readonly ProfilerMarker GraphLoadMarker = new ProfilerMarker("GraphLoad"); private static readonly ProfilerMarker CreateGraphEditorViewMarker = new ProfilerMarker("CreateGraphEditorView"); public void Initialize(string assetGuid) { try { m_ColorSpace = PlayerSettings.colorSpace; m_RenderPipelineAsset = GraphicsSettings.currentRenderPipeline; var asset = AssetDatabase.LoadAssetAtPath(AssetDatabase.GUIDToAssetPath(assetGuid)); if (asset == null) return; if (!EditorUtility.IsPersistent(asset)) return; if (selectedGuid == assetGuid) return; var path = AssetDatabase.GetAssetPath(asset); var extension = Path.GetExtension(path); if (extension == null) return; // Path.GetExtension returns the extension prefixed with ".", so we remove it. We force lower case such that // the comparison will be case-insensitive. extension = extension.Substring(1).ToLowerInvariant(); bool isSubGraph; switch (extension) { case ShaderGraphImporter.Extension: isSubGraph = false; break; case ShaderSubGraphImporter.Extension: isSubGraph = true; break; default: return; } selectedGuid = assetGuid; string graphName = Path.GetFileNameWithoutExtension(path); using (GraphLoadMarker.Auto()) { m_LastSerializedFileContents = File.ReadAllText(path, Encoding.UTF8); graphObject = CreateInstance(); graphObject.hideFlags = HideFlags.HideAndDontSave; graphObject.graph = new GraphData { assetGuid = assetGuid, isSubGraph = isSubGraph, messageManager = messageManager }; MultiJson.Deserialize(graphObject.graph, m_LastSerializedFileContents); graphObject.graph.OnEnable(); graphObject.graph.ValidateGraph(); } using (CreateGraphEditorViewMarker.Auto()) { graphEditorView = new GraphEditorView(this, m_GraphObject.graph, messageManager, graphName) { viewDataKey = selectedGuid, }; } UpdateTitle(); Repaint(); } catch (Exception) { m_HasError = true; m_GraphEditorView = null; graphObject = null; throw; } } // returns contents of the asset file, or null if any exception occurred private string ReadAssetFile() { var filePath = AssetDatabase.GUIDToAssetPath(selectedGuid); return FileUtilities.SafeReadAllText(filePath); } // returns true when the user is OK with closing the window or application (either they've saved dirty content, or are ok with losing it) // returns false when the user wants to cancel closing the window or application internal bool PromptSaveIfDirtyOnQuit() { // only bother unless we've actually got data to preserve if (graphObject?.graph != null) { // if the asset has been deleted, ask the user what to do if (!AssetFileExists()) return DisplayDeletedFromDiskDialog(false); // If there are unsaved modifications, ask the user what to do. // If the editor has already handled this check we'll no longer have unsaved changes // (either they saved or they discarded, both of which will set hasUnsavedChanges to false). if (hasUnsavedChanges) { int option = EditorUtility.DisplayDialogComplex( "Shader Graph Has Been Modified", GetSaveChangesMessage(), "Save", "Cancel", "Discard Changes"); if (option == 0) // save { return SaveAsset(); } else if (option == 1) // cancel (or escape/close dialog) { return false; } else if (option == 2) // discard { return true; } } } return true; } private string GetSaveChangesMessage() { return "Do you want to save the changes you made in the Shader Graph?\n\n" + AssetDatabase.GUIDToAssetPath(selectedGuid) + "\n\nYour changes will be lost if you don't save them."; } public override void SaveChanges() { base.SaveChanges(); SaveAsset(); } void OnGeometryChanged(GeometryChangedEvent evt) { if (graphEditorView == null) return; // this callback is only so we can run post-layout behaviors after the graph loads for the first time // we immediately unregister it so it doesn't get called again graphEditorView.UnregisterCallback(OnGeometryChanged); if (m_FrameAllAfterLayout) graphEditorView.graphView.FrameAll(); m_FrameAllAfterLayout = false; } private void OnBecameVisible() { isVisible = true; } private void OnBecameInvisible() { isVisible = false; } } }