Editor window tool that bulk-converts image folders into material (.vmat) files. It provides a UI to pick a source folder, read image layers by filename suffixes, construct material text with references and write material files to disk.
using System;
using System.Collections.Generic;
using System.ComponentModel.DataAnnotations;
using System.IO;
using System.Linq;
using System.Numerics;
using System.Reflection;
using System.Reflection.Emit;
using System.Runtime.CompilerServices;
using System.Security.Cryptography.X509Certificates;
using Editor;
using Editor.ShaderGraph;
using Editor.ShaderGraph.Nodes;
using Editor.Widgets;
using Sandbox;
using Sandbox.Engine.Shaders;
using Sandbox.UI;
using Sandbox.Utility;
using static Sandbox.Material;
[EditorApp( "Image to Material", "transform", "Tool for converting images to materials in bulk." )]
public class ImagetoMaterial : BaseWindow
{
public string GenerationPath { get; set; } = Editor.FileSystem.Content.GetFullPath( "" ) + "\\ImageToMaterial\\";
public string GenerationPathInput { get; set; } = Editor.FileSystem.Content.GetFullPath( "" ) + "\\ImageToMaterial\\Input";
public string GenerationPathOutput { get; set; } = Editor.FileSystem.Content.GetFullPath( "" ) + "\\ImageToMaterial\\Output";
public string GenerationLocalPath { get; set; } = "\\ImageToMaterial\\";
public string ExportPath { get; set; } = Project.Current.RootDirectory + "\\Assets\\";
TestClass instance;
private static readonly HashSet<string> ImageFileExtensions = new HashSet<string>
{
".psd", ".tga", ".tif", ".pkm", ".mks", ".png", ".jpg", ".exr"
};
private static readonly HashSet<string> ShaderLayerSuffix = new HashSet<string>
{
"_color", "_normal", "_rough", "_ao", "_trans", "_refl", "_gloss", "_metal"
};
public class TestClass
{
[ResourceType( "shader" )]
public string Shader { get; set; } = "shaders/generic.shader";
public bool Color = true;
public bool Normal = false;
public bool Roughness = false;
public bool AmbientOcclusion = false;
[Category( "Advanced" )] public bool Translucent = false;
public string OutputFilePrefix = "";
public string OutputFileSuffix = "";
[Display( Name = "Output Folder" )]
public OutputLocation Output = OutputLocation.SameFolder;
}
public enum OutputLocation
{
[Display( Name = "Same folder" )]
SameFolder,
[Display( Name = "One folder up" )]
ParentFolder
}
public ImagetoMaterial()
{
WindowTitle = "Image to Material";
SetWindowIcon( "transform" );
Size = new Vector2( 800,650 );
Layout = Layout.Column();
///
/// Create Directories
///
/*Directory.CreateDirectory( GenerationPath );
Directory.CreateDirectory( GenerationPathInput );
Directory.CreateDirectory( GenerationPathOutput );*/
///
/// Header
///
var Header = Layout.Column();
Header.Spacing = 0;
Header.Margin = 20;
var Title = new Editor.Label.Subtitle( "Image to Material" );
Header.Add( Title, 0 );
var SubTitle = new Editor.Label.Body( "Tool for converting images to materials in bulk." );
Header.Add( SubTitle, 0 );
///
/// Body
///
var Body = Layout.Column();
Body.Margin = 20;
var BodySections = Layout.Row();
Body.Add(BodySections, 0 );
var BodySectionsLeft = Layout.Column();
var BodySectionsCenter = Layout.Column();
BodySectionsLeft.Add( new Editor.Label.Subtitle( "Source" ) );
BodySectionsLeft.Alignment = Sandbox.TextFlag.CenterHorizontally;
var BodySectionsLeftCanvas = new Widget( null );
BodySectionsLeftCanvas.Layout = Layout.Row();
BodySectionsLeftCanvas.Layout.Spacing = 32;
var BodySectionsLeftView = new TreeView( BodySectionsLeftCanvas );
BodySectionsLeftView.HorizontalSizeMode = SizeMode.CanGrow;
BodySectionsLeftView.SmoothScrolling = true;
BodySectionsLeftView.MultiSelect = false;
string sourceRoot = Project.Current.GetAssetsPath();
var BodySectionsLeftFolder = BodySectionsLeftView.AddItem( new FilesystemTreeNode( sourceRoot ) );
BodySectionsLeftView.Open( BodySectionsLeftFolder );
BodySectionsLeft.Add( BodySectionsLeftView, 0 );
var SourceWarning = new WarningBox( "Please select source" );
BodySectionsLeft.Add( SourceWarning );
BodySectionsCenter.Add( new Editor.Label.Subtitle( "Material Settings" ) );
BodySectionsCenter.Alignment = Sandbox.TextFlag.CenterHorizontally;
instance = new TestClass();
var so = instance.GetSerialized();
// Default: Generic //////////////////
instance.Color = true;
instance.Normal = true;
instance.Roughness = false;
instance.AmbientOcclusion = false;
//instance.Metalness = false;
instance.Translucent = false;
//////////////////////////////////////
so.OnPropertyChanged += _ =>
{
// Clear any shader-driven layer defaults the user might still see
};
var property = new Editor.ControlSheet();
property.AddObject( so, x => x.Name is not "Output" and not "OutputFilePrefix" and not "OutputFileSuffix" );
BodySectionsCenter.Add( property,1 );
var OutputSection = Layout.Column();
OutputSection.Margin = new Sandbox.UI.Margin( 0, 16, 0, 0 );
OutputSection.Add( new Editor.Label.Subtitle( "Output" ) );
var outputProperty = new Editor.ControlSheet();
outputProperty.AddObject( so, x => x.Name is "Output" or "OutputFilePrefix" or "OutputFileSuffix" );
OutputSection.Add( outputProperty, 1 );
BodySectionsCenter.Add( OutputSection, 1 );
BodySections.Add( BodySectionsLeft, 1 );
BodySections.Add( BodySectionsCenter, 1 );
///
/// Actions
///
var Actions = Layout.Column();
Actions.Margin = 20;
var ConvertButton = new Editor.Button.Primary( "Convert", "autorenew" );
ConvertButton.Enabled = false;
void UpdateConvertButtonState()
{
ConvertButton.Enabled = BodySectionsLeftView.Selection.Count != 0;
}
void UpdateSourceError()
{
SourceWarning.Visible = BodySectionsLeftView.Selection.Count == 0;
}
BodySectionsLeftView.Selection.OnItemAdded += _ => { UpdateConvertButtonState(); UpdateSourceError(); };
BodySectionsLeftView.Selection.OnItemRemoved += _ => { UpdateConvertButtonState(); UpdateSourceError(); };
ConvertButton.Clicked += () =>
{
ConvertFiles( BodySectionsLeftView.Selection );
var PopUp = new PopupWindow( "Export Complete", "Materials exported to the folder selected in Material Settings.", "Okay" );
PopUp.Show();
};
Actions.Add( ConvertButton );
var helpdocs = new WebWidget( null );
helpdocs.Surface.Url = "https://sbox.game/sturnus";
var HelpButton = new Editor.Button( "Documentation" );
HelpButton.Tint = "#8c7ae6";
HelpButton.Clicked = () =>
{
var PopUp = new PopupWindow( "Help", $"This tool allows you to bulk convert images to materials.\n" +
$"\nPrep: Create folders for each material you want to create, once the folders are created, rename each image to it's appropriate layer ie(_color.png, _normal.png, etc...)\n" +
$"\nSource: Select the parent folder where all of your material folders live\n" +
$"\nOutput: In Material Settings, choose if the material file is saved in the same folder as the source images, or one folder up\n" +
$"", "Okay"
);
PopUp.Show();
};
///
/// Footer
///
var Footer = Layout.Row();
Footer.Margin = 5;
Footer.Add( new Editor.Label.Body( "🔨 with ❤️ by <a href=\"https://sbox.game/u/AustinDotCodes\">Austin</a>" ) );
Footer.Add( HelpButton );
Layout.Add( Header, 0 );
Layout.Add( Body, 0 );
Layout.Add( Actions, 0 );
Layout.Add( Footer, 0 );
}
public HashSet<string> GetValidImageFiles( IEnumerable<object> selection )
{
var imageFiles = new HashSet<string>();
foreach ( var obj in selection )
{
if ( obj is FilesystemTreeNode node )
{
if ( node.Info is DirectoryInfo directory )
{
foreach ( var file in directory.GetFiles( "*", SearchOption.AllDirectories ) )
{
var relativePath = file.FullName.Replace( Project.Current.RootDirectory.ToString(), "" ).TrimStart( Path.DirectorySeparatorChar );
if ( ImageFileExtensions.Contains( file.Extension.ToLower() ) )
{
imageFiles.Add( relativePath );
}
}
}
else if ( node.Info is FileInfo file )
{
var relativePath = file.FullName.Replace( Project.Current.RootDirectory.ToString(), "" ).TrimStart( Path.DirectorySeparatorChar );
if ( ImageFileExtensions.Contains( file.Extension.ToLower() ) )
{
imageFiles.Add( relativePath );
}
}
}
}
return imageFiles;
}
public void ConvertFiles( IEnumerable<object> source )
{
if ( instance == null )
{
Log.Error( "Instance is null. Ensure it is initialized before using." );
return;
}
if ( source == null || !source.Any() )
{
Log.Warning( "Source selection is empty or null." );
return;
}
var selectedNode = source.OfType<FilesystemTreeNode>().FirstOrDefault();
if ( selectedNode == null || selectedNode.Info == null )
{
Log.Warning( "Source selection is not a valid directory." );
return;
}
// Allow selecting a single image file - use its containing folder
DirectoryInfo selectedDir;
if ( selectedNode.Info is DirectoryInfo dirInfo )
{
selectedDir = dirInfo;
}
else if ( selectedNode.Info is FileInfo fileInfo && fileInfo.Directory != null )
{
selectedDir = fileInfo.Directory;
}
else
{
Log.Warning( "Source selection is not a valid directory." );
return;
}
string assetsRoot = Project.Current.GetAssetsPath()?.TrimEnd( '\\' ) ?? Path.Combine( Project.Current.GetRootPath(), "Assets" );
var materialFolders = GetMaterialFolders( selectedDir );
foreach ( var folder in materialFolders )
{
var imageFiles = folder.GetFiles()
.Where( f => ImageFileExtensions.Contains( f.Extension.ToLower() ) )
.ToList();
if ( !imageFiles.Any() )
{
Log.Warning( $"No valid image files found in {folder.FullName}" );
continue;
}
string relativeFolderPath = folder.FullName.Replace( assetsRoot + "\\", "" ).Replace( '\\', '/' );
string materialName = folder.Name;
string fileExtension = imageFiles[0].Extension;
foreach ( var imageFile in imageFiles )
{
string relativeTexturePath = imageFile.FullName.Replace( assetsRoot + "\\", "" ).Replace( '\\', '/' );
if ( !File.Exists( imageFile.FullName ) || Texture.Load( relativeTexturePath ) == null )
{
Log.Warning( $"Failed to load texture: {imageFile.FullName}" );
continue;
}
}
string outputPath = instance.Output == OutputLocation.ParentFolder
? folder.Parent?.FullName ?? folder.FullName
: folder.FullName;
SaveMaterial( materialName, relativeFolderPath, folder.FullName, fileExtension, outputPath );
}
}
IEnumerable<DirectoryInfo> GetMaterialFolders( DirectoryInfo directory )
{
foreach ( var subDirectory in directory.GetDirectories() )
{
foreach ( var folder in GetMaterialFolders( subDirectory ) )
{
yield return folder;
}
}
if ( directory.GetFiles().Any( f => ImageFileExtensions.Contains( f.Extension.ToLower() ) ) )
{
yield return directory;
}
}
public void SaveMaterial( string materialName, string relativeFolderPath, string folderPath, string destinationExtension, string destinationPath )
{
string TextureRef( string suffix )
{
string fullFile = Path.Combine( folderPath, suffix + destinationExtension );
return File.Exists( fullFile ) ? $"{relativeFolderPath}/{suffix}{destinationExtension}" : null;
}
string materialContent = $@"
Layer0
{{
""shader"" ""{instance.Shader}""
{(instance.Color ? (TextureRef( "_color" ) != null ? $"\t\"TextureColor\" \"{TextureRef( "_color" )}\"" : $"\t\"TextureColor\" \"materials/default/default_color.tga\"") : "")}
{(instance.Normal ? (TextureRef( "_normal" ) != null ? $"\t\"TextureNormal\" \"{TextureRef( "_normal" )}\"" : $"\t\"TextureNormal\" \"materials/default/default_normal.tga\"") : "")}
{(instance.Roughness ? (TextureRef( "_rough" ) != null ? $"\t\"TextureRoughness\" \"{TextureRef( "_rough" )}\"" : $"\t\"TextureRoughness\" \"materials/default/default_rough.tga\"") : "")}
{(instance.AmbientOcclusion ? (TextureRef( "_ao" ) != null ? $"\t\"TextureAmbientOcclusion\" \"{TextureRef( "_ao" )}\"" : $"\t\"TextureAmbientOcclusion\" \"materials/default/default_ao.tga\"") : "")}
{(instance.Translucent ? $"//---- Translucent ----" : "")}
{(instance.Translucent ? $"F_TRANSLUCENT 1" : "")}
{(instance.Translucent ? $"g_flOpacityScale \"1.000\"" : "")}
{(instance.Translucent ? (TextureRef( "_trans" ) != null ? $"\t\"TextureTranslucency\" \"{TextureRef( "_trans" )}\"" : $"\t\"TextureTranslucency\" \"materials/default/default_trans.tga\"") : "")}
}}
";
Directory.CreateDirectory( destinationPath );
string savePath = Path.Combine( destinationPath, instance.OutputFilePrefix+materialName + instance.OutputFileSuffix+".vmat" );
File.WriteAllText( savePath, materialContent );
}
class FilesystemTreeNode : TreeNode
{
public System.IO.FileSystemInfo Info;
bool IsFolder => Info is System.IO.DirectoryInfo;
public FilesystemTreeNode( string path )
{
if ( System.IO.Directory.Exists( path ) ) Info = new System.IO.DirectoryInfo( path );
else if ( System.IO.File.Exists( path ) ) Info = new System.IO.FileInfo( path );
else throw new Exception( "Invalid path" );
}
public override void OnPaint( VirtualWidget item )
{
PaintSelection( item );
Paint.SetPen( IsFolder ? Theme.Yellow : Theme.Text );
Paint.DrawIcon( item.Rect, IsFolder ? "folder" : "description", 18, TextFlag.LeftCenter );
Paint.SetPen( Theme.Text );
Paint.DrawText( item.Rect.Shrink( 24, 0, 0, 0 ), $"{Info.Name}", TextFlag.LeftCenter );
}
public int Order => Info is System.IO.DirectoryInfo ? 0 : 1;
protected override void BuildChildren()
{
if ( Info is not System.IO.DirectoryInfo dirInfo )
return;
Clear();
var infos = dirInfo.GetFileSystemInfos().Select( x => new FilesystemTreeNode( x.FullName ) );
infos = infos.OrderBy( x => x.Order ).ThenBy( x => x.Info.Name );
AddItems( infos );
}
}
}