commit a861b4680b7b9d9de0b4f0a9e27490b22e59cef2 Author: Eragon-Brisingr <450614754@qq.com> Date: Tue Jun 30 15:36:02 2026 +0800 Init repo diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..26a55c3 --- /dev/null +++ b/.gitignore @@ -0,0 +1,86 @@ +# Visual Studio 2015 user specific files +.vs/ + +# Rider +.idea/ + +# Visual Studio 2015 database file +*.VC.db + +# Compiled Object files +*.slo +*.lo +*.o +*.obj + +# Precompiled Headers +*.gch +*.pch + +# Compiled Dynamic libraries +*.so +*.dylib +*.dll + +# Fortran module files +*.mod + +# Compiled Static libraries +*.lai +*.la +*.a +*.lib + +# Executables +*.out +*.app +*.ipa + +# These project files can be generated by the engine +*.xcodeproj +*.xcworkspace +*.sln +*.suo +*.opensdf +*.sdf +*.VC.db +*.VC.opendb + +# Precompiled Assets +SourceArt/**/*.png +SourceArt/**/*.tga + +# Binary Files +Binaries/ +Plugins/*/Binaries/* + +# Builds +Build/* + +# Whitelist PakBlacklist-.txt files +!Build/*/ +Build/*/** +!Build/*/PakBlacklist*.txt + +# Don't ignore icon files in Build +!Build/**/*.ico + +# Built data for maps +*_BuiltData.uasset + +# Configuration files generated by the Editor +Saved/* + +# Compiled source files for the engine to use +Intermediate/* +Plugins/*/Intermediate/* + +# Cache files for the editor to use +DerivedDataCache/* + +# python +*.pyc + +# temp file +enc_temp_folder/* +*.TMP diff --git a/Shaders/Private/ShaderLabCommon.ush b/Shaders/Private/ShaderLabCommon.ush new file mode 100644 index 0000000..89f9e79 --- /dev/null +++ b/Shaders/Private/ShaderLabCommon.ush @@ -0,0 +1,114 @@ +// Copyright FlecsProj. All Rights Reserved. +// Shared struct definitions for ShaderLab-generated Custom HLSL nodes. +// Included by every generated UMaterialExpressionCustom node via IncludeFilePaths. + +#pragma once + +// Per-pixel/vertex inputs are read through `UE::NodeName(...)` intrinsics (resolved by the graph +// builder to real material expression nodes), not through a context struct. + +// Surface description filled by the Surface(...) body. Defaults mirror the +// UMaterialExpressionSubstrateSlabBSDF pin defaults so that fields the body does not +// touch keep Substrate's native behavior. +struct FShaderLabSurface +{ + float3 DiffuseAlbedo; + float3 F0; + float3 F90; + float Roughness; + float Anisotropy; + float3 Normal; + float3 Tangent; + float3 SSSMFP; + float SSSMFPScale; + float SSSPhaseAnisotropy; + float3 EmissiveColor; + float SecondRoughness; + float SecondRoughnessWeight; + float FuzzRoughness; + float FuzzAmount; + float3 FuzzColor; + float GlintValue; + float2 GlintUV; + float Opacity; + float OpacityMask; +}; + +// Output for the PostProcess(...) entry (Domain = PostProcess). Color feeds the material EmissiveColor. +struct FShaderLabPostProcess +{ + float3 Color; + float Opacity; +}; + +// Output for the UI(...) entry (Domain = UI). Color feeds EmissiveColor, Opacity the material Opacity. +struct FShaderLabUI +{ + float3 Color; + float Opacity; +}; + +// Vertex-stage outputs filled by the optional Vertex(...) body. +struct FShaderLabVertex +{ + float3 WorldPositionOffset; + float Displacement; + float2 CustomizedUV0; + float2 CustomizedUV1; + float2 CustomizedUV2; + float2 CustomizedUV3; +}; + +FShaderLabSurface ShaderLabDefaultSurface() +{ + FShaderLabSurface S; + S.DiffuseAlbedo = float3(0.18, 0.18, 0.18); + S.F0 = float3(0.04, 0.04, 0.04); + S.F90 = float3(1, 1, 1); + S.Roughness = 0.5; + S.Anisotropy = 0; + S.Normal = float3(0, 0, 1); + S.Tangent = float3(1, 0, 0); + S.SSSMFP = float3(0, 0, 0); + S.SSSMFPScale = 1; + S.SSSPhaseAnisotropy = 1; + S.EmissiveColor = float3(0, 0, 0); + S.SecondRoughness = 0.5; + S.SecondRoughnessWeight = 0; + S.FuzzRoughness = 0.5; + S.FuzzAmount = 0; + S.FuzzColor = float3(0, 0, 0); + S.GlintValue = 1; + S.GlintUV = float2(0, 0); + S.Opacity = 1; + S.OpacityMask = 1; + return S; +} + +FShaderLabPostProcess ShaderLabDefaultPostProcess() +{ + FShaderLabPostProcess P; + P.Color = float3(0, 0, 0); + P.Opacity = 1; + return P; +} + +FShaderLabUI ShaderLabDefaultUI() +{ + FShaderLabUI U; + U.Color = float3(0, 0, 0); + U.Opacity = 1; + return U; +} + +FShaderLabVertex ShaderLabDefaultVertex() +{ + FShaderLabVertex V; + V.WorldPositionOffset = float3(0, 0, 0); + V.Displacement = 0; + V.CustomizedUV0 = float2(0, 0); + V.CustomizedUV1 = float2(0, 0); + V.CustomizedUV2 = float2(0, 0); + V.CustomizedUV3 = float2(0, 0); + return V; +} diff --git a/Source/UShaderLab/Private/ShaderLabDiscovery.cpp b/Source/UShaderLab/Private/ShaderLabDiscovery.cpp new file mode 100644 index 0000000..55a0bb8 --- /dev/null +++ b/Source/UShaderLab/Private/ShaderLabDiscovery.cpp @@ -0,0 +1,37 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabDiscovery.h" + +#include "HAL/FileManager.h" +#include "Interfaces/IPluginManager.h" +#include "Misc/Paths.h" + +TArray FShaderLabDiscovery::GetSearchRoots() +{ + // `.usl` files live in `Shaders` directories (project + plugins) — the same place .ush library + // files go and which is mapped to a virtual shader path (so `Includes { "/Project/..." }` resolves). + TArray Roots; + Roots.Add(FPaths::Combine(FPaths::ProjectDir(), TEXT("Shaders"))); + + for (const TSharedRef& Plugin : IPluginManager::Get().GetEnabledPlugins()) + { + const FString Candidate = FPaths::Combine(Plugin->GetBaseDir(), TEXT("Shaders")); + Roots.AddUnique(Candidate); + } + return Roots; +} + +TArray FShaderLabDiscovery::FindShaderLabFiles() +{ + TArray Files; + for (const FString& Root : GetSearchRoots()) + { + if (IFileManager::Get().DirectoryExists(*Root)) + { + TArray Found; + IFileManager::Get().FindFilesRecursive(Found, *Root, TEXT("*.usl"), true, false, false); + Files.Append(Found); + } + } + return Files; +} diff --git a/Source/UShaderLab/Private/ShaderLabMaterialInstanceConstant.cpp b/Source/UShaderLab/Private/ShaderLabMaterialInstanceConstant.cpp new file mode 100644 index 0000000..114615b --- /dev/null +++ b/Source/UShaderLab/Private/ShaderLabMaterialInstanceConstant.cpp @@ -0,0 +1,27 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabMaterialInstanceConstant.h" + +#include "Materials/Material.h" +#include "ShaderLabMaterialRegistry.h" +#include "UObject/Package.h" + +bool UShaderLabMaterialInstanceConstant::HasOverridenBaseProperties() const +{ + // Force a self-contained permutation only at the ROOT — where our immediate parent is a ShaderLab + // in-memory /Script base material (which has no serialized shader map). For child instances (parent + // is another instance) fall through to stock behavior so they DEFER to and SHARE the root's shader + // map — this is what bounds the shader-map count (no combinatorial explosion across variants). + // + // The condition compares the parent's package against the ShaderLab /Script package POINTER (not a + // string name): a non-instance parent in that exact package is one of our bases. + if (const UMaterial* BaseMaterial = Cast(Parent)) + { + if (BaseMaterial->GetOutermost() == FShaderLabMaterialRegistry::Get().GetPackage()) + { + return true; + } + } + + return Super::HasOverridenBaseProperties(); +} diff --git a/Source/UShaderLab/Private/ShaderLabMaterialRegistry.cpp b/Source/UShaderLab/Private/ShaderLabMaterialRegistry.cpp new file mode 100644 index 0000000..52db649 --- /dev/null +++ b/Source/UShaderLab/Private/ShaderLabMaterialRegistry.cpp @@ -0,0 +1,111 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabMaterialRegistry.h" + +#include "Materials/Material.h" +#include "Misc/Paths.h" +#include "ShaderLabModel.h" +#include "Serialization/AsyncLoadingEvents.h" +#include "UObject/Package.h" +#include "UObject/UObjectGlobals.h" + +DEFINE_LOG_CATEGORY_STATIC(LogShaderLabRegistry, Log, All); + +namespace +{ + const TCHAR* GShaderLabPackageName = TEXT("/Script/UShaderLab"); +} + +FShaderLabMaterialRegistry& FShaderLabMaterialRegistry::Get() +{ + static FShaderLabMaterialRegistry Instance; + return Instance; +} + +UPackage* FShaderLabMaterialRegistry::GetPackage() +{ + if (!Package) + { + // Mirror the engine's native-package convention (and AngelScript literal assets): + // a compiled-in /Script package whose objects resolve via FindObject, never disk. + Package = NewObject(nullptr, GShaderLabPackageName, RF_Public | RF_Standalone | RF_MarkAsRootSet); + Package->SetPackageFlags(PKG_CompiledIn); + Package->AddToRoot(); + } + return Package; +} + +FString FShaderLabMaterialRegistry::MakeObjectName(const FString& ShaderName) +{ + FString Out; + Out.Reserve(ShaderName.Len()); + for (const TCHAR C : ShaderName) + { + Out.AppendChar((FChar::IsAlnum(C) || C == TEXT('_')) ? C : TEXT('_')); + } + if (Out.IsEmpty()) + { + Out = TEXT("Unnamed"); + } + return Out; +} + +FString FShaderLabMaterialRegistry::MakeObjectPath(const FString& ShaderName) +{ + return FString::Printf(TEXT("%s.%s"), GShaderLabPackageName, *MakeObjectName(ShaderName)); +} + +UMaterial* FShaderLabMaterialRegistry::FindMaterial(const FString& ShaderName) const +{ + if (!Package) + { + return nullptr; + } + return FindObject(Package, *MakeObjectName(ShaderName)); +} + +UMaterial* FShaderLabMaterialRegistry::RegisterFromModel(const FShaderLabModel& Model) +{ + UPackage* Pkg = GetPackage(); + const FString ObjName = MakeObjectName(Model.ShaderName); + + // Contract: a material's identity is its file name. Two different source files that sanitize to the + // same object name are a hard error (would otherwise silently clobber each other / break MIC refs). + if (const FShaderLabModel* Existing = RegisteredModels.Find(ObjName)) + { + if (!FPaths::IsSamePath(Existing->SourceFilePath, Model.SourceFilePath)) + { + UE_LOG(LogShaderLabRegistry, Error, + TEXT("ShaderLab: duplicate material name '%s' from '%s' and '%s' — rename one file."), + *ObjName, *Existing->SourceFilePath, *Model.SourceFilePath); + return nullptr; + } + } + + UMaterial* Material = FindObject(Pkg, *ObjName); + const bool bNewlyCreated = (Material == nullptr); + if (bNewlyCreated) + { + // RF_MarkAsRootSet keeps the material alive across GC. Without it the object is collected + // (RF_Standalone alone is not a GC root in a cooked runtime), which removes its entry from + // the IoStore script-object table (RemoveUnreachableScriptObjects) and makes cooked Material + // Instances that reference it resolve their Parent to null -> fall back to the default material. + Material = NewObject(Pkg, FName(*ObjName), RF_Public | RF_Standalone | RF_MarkAsRootSet); + } + + RegisteredModels.FindOrAdd(ObjName) = Model; + + // Register with the loading system so imports to "/Script/UShaderLab." from other + // packages resolve to this in-memory object even if requested mid async-load. + NotifyRegistrationEvent( + FName(GShaderLabPackageName), + FName(*ObjName), + ENotifyRegistrationType::NRT_NoExportObject, + ENotifyRegistrationPhase::NRP_Finished, + nullptr, + /*bDynamic*/ false, + Material); + + BuildMaterialDelegate.Broadcast(*Material, Model); + return Material; +} diff --git a/Source/UShaderLab/Private/ShaderLabModel.cpp b/Source/UShaderLab/Private/ShaderLabModel.cpp new file mode 100644 index 0000000..271e024 --- /dev/null +++ b/Source/UShaderLab/Private/ShaderLabModel.cpp @@ -0,0 +1,14 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabModel.h" + +const FShaderLabProperty* FShaderLabModel::FindProperty(FName InName) const +{ + return Properties.FindByPredicate([InName](const FShaderLabProperty& P) { return P.Name == InName; }); +} + +bool FShaderLabModel::HasStaticSwitches() const +{ + return Properties.ContainsByPredicate( + [](const FShaderLabProperty& P) { return P.Type == EShaderLabPropertyType::StaticBool; }); +} diff --git a/Source/UShaderLab/Private/ShaderLabModule.cpp b/Source/UShaderLab/Private/ShaderLabModule.cpp new file mode 100644 index 0000000..c0c5849 --- /dev/null +++ b/Source/UShaderLab/Private/ShaderLabModule.cpp @@ -0,0 +1,34 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabModule.h" + +#include "Modules/ModuleManager.h" + +#if !WITH_EDITOR +#include "Materials/Material.h" +#include "ShaderLabMaterialRegistry.h" +#include "ShaderLabModel.h" +#include "ShaderLabRuntimeBuilder.h" +#endif + +DEFINE_LOG_CATEGORY_STATIC(LogShaderLab, Log, All); + +void FShaderLabModule::StartupModule() +{ +#if !WITH_EDITOR + // In cooked builds there is no editor graph builder; apply material settings when the registry + // builds a base shell. The shell carries no shader map — instances carry their own. + FShaderLabMaterialRegistry::Get().OnBuildMaterial().AddLambda( + [](UMaterial& Material, const FShaderLabModel& Model) + { + FShaderLabRuntimeBuilder::ApplySettings(Material, Model); + }); +#endif + UE_LOG(LogShaderLab, Log, TEXT("ShaderLab runtime module started.")); +} + +void FShaderLabModule::ShutdownModule() +{ +} + +IMPLEMENT_MODULE(FShaderLabModule, UShaderLab) diff --git a/Source/UShaderLab/Private/ShaderLabParser.cpp b/Source/UShaderLab/Private/ShaderLabParser.cpp new file mode 100644 index 0000000..33aa4be --- /dev/null +++ b/Source/UShaderLab/Private/ShaderLabParser.cpp @@ -0,0 +1,1070 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabParser.h" + +#include "Misc/Paths.h" +#include "ShaderLabModel.h" + +namespace ShaderLabParser_Private +{ + /** Character scanner with line/column tracking and comment-aware trivia skipping. */ + struct FScanner + { + const TCHAR* Ptr = nullptr; + const TCHAR* End = nullptr; + int32 Line = 1; + int32 Column = 1; + TArray& Errors; + bool bFailed = false; + + FScanner(const FString& Source, TArray& InErrors) + : Ptr(*Source), End(*Source + Source.Len()), Errors(InErrors) + { + } + + bool IsEnd() const { return Ptr >= End; } + TCHAR Peek(int32 Ahead = 0) const { return (Ptr + Ahead < End) ? Ptr[Ahead] : TEXT('\0'); } + + TCHAR Advance() + { + if (IsEnd()) + { + return TEXT('\0'); + } + const TCHAR C = *Ptr++; + if (C == TEXT('\n')) + { + ++Line; + Column = 1; + } + else + { + ++Column; + } + return C; + } + + void Error(const FString& Message, int32 L = -1, int32 C = -1) + { + if (bFailed) + { + return; // Report only the first error to keep diagnostics focused. + } + bFailed = true; + FShaderLabParseError E; + E.Line = (L >= 0) ? L : Line; + E.Column = (C >= 0) ? C : Column; + E.Message = Message; + Errors.Add(MoveTemp(E)); + } + + // Skip whitespace and line/block comments. + void SkipTrivia() + { + while (!IsEnd()) + { + const TCHAR C = Peek(); + if (FChar::IsWhitespace(C)) + { + Advance(); + } + else if (C == TEXT('/') && Peek(1) == TEXT('/')) + { + while (!IsEnd() && Peek() != TEXT('\n')) + { + Advance(); + } + } + else if (C == TEXT('/') && Peek(1) == TEXT('*')) + { + Advance(); + Advance(); + while (!IsEnd() && !(Peek() == TEXT('*') && Peek(1) == TEXT('/'))) + { + Advance(); + } + Advance(); // * + Advance(); // / + } + else + { + break; + } + } + } + + bool Match(TCHAR Expected) + { + SkipTrivia(); + if (Peek() == Expected) + { + Advance(); + return true; + } + return false; + } + + bool Expect(TCHAR Expected, const TCHAR* Context) + { + if (!Match(Expected)) + { + Error(FString::Printf(TEXT("Expected '%c' %s, found '%c'"), Expected, Context, + IsEnd() ? TEXT('\0') : Peek())); + return false; + } + return true; + } + + static bool IsIdentStart(TCHAR C) { return FChar::IsAlpha(C) || C == TEXT('_'); } + static bool IsIdentChar(TCHAR C) { return FChar::IsAlnum(C) || C == TEXT('_'); } + + bool ReadIdentifier(FString& Out) + { + SkipTrivia(); + if (!IsIdentStart(Peek())) + { + return false; + } + const TCHAR* Start = Ptr; + while (!IsEnd() && IsIdentChar(Peek())) + { + Advance(); + } + Out.Reset(); + Out.AppendChars(Start, static_cast(Ptr - Start)); + return true; + } + + bool ReadString(FString& Out) + { + SkipTrivia(); + if (Peek() != TEXT('"')) + { + Error(TEXT("Expected a quoted string")); + return false; + } + Advance(); // opening quote + FString Result; + while (!IsEnd() && Peek() != TEXT('"')) + { + const TCHAR C = Advance(); + if (C == TEXT('\\') && !IsEnd()) + { + Result.AppendChar(Advance()); + } + else + { + Result.AppendChar(C); + } + } + if (!Match(TEXT('"'))) + { + Error(TEXT("Unterminated string literal")); + return false; + } + Out = MoveTemp(Result); + return true; + } + + /** + * Capture verbatim text between matching `Open`/`Close`, respecting nested + * delimiters, string/char literals and comments. The next non-trivia char must be + * `Open`. The returned text excludes the outermost delimiters. + */ + bool ReadBalanced(TCHAR Open, TCHAR Close, FString& OutInner, int32* OutStartLine = nullptr) + { + const int32 OpenLine = Line; + const int32 OpenCol = Column; + if (!Expect(Open, TEXT("to open block"))) + { + return false; + } + if (OutStartLine) + { + // Line of the first character after the opening delimiter (used for #line mapping). + *OutStartLine = Line; + } + + FString Inner; + int32 Depth = 1; + while (!IsEnd()) + { + const TCHAR C = Peek(); + + if (C == TEXT('"') || C == TEXT('\'')) + { + const TCHAR Quote = C; + Inner.AppendChar(Advance()); + while (!IsEnd() && Peek() != Quote) + { + const TCHAR S = Advance(); + Inner.AppendChar(S); + if (S == TEXT('\\') && !IsEnd()) + { + Inner.AppendChar(Advance()); + } + } + if (!IsEnd()) + { + Inner.AppendChar(Advance()); // closing quote + } + continue; + } + if (C == TEXT('/') && Peek(1) == TEXT('/')) + { + while (!IsEnd() && Peek() != TEXT('\n')) + { + Inner.AppendChar(Advance()); + } + continue; + } + if (C == TEXT('/') && Peek(1) == TEXT('*')) + { + Inner.AppendChar(Advance()); + Inner.AppendChar(Advance()); + while (!IsEnd() && !(Peek() == TEXT('*') && Peek(1) == TEXT('/'))) + { + Inner.AppendChar(Advance()); + } + if (!IsEnd()) + { + Inner.AppendChar(Advance()); // * + Inner.AppendChar(Advance()); // / + } + continue; + } + + if (C == Open) + { + ++Depth; + } + else if (C == Close) + { + --Depth; + if (Depth == 0) + { + Advance(); // consume closing delimiter + OutInner = MoveTemp(Inner); + return true; + } + } + Inner.AppendChar(Advance()); + } + + Error(FString::Printf(TEXT("Unterminated block opened with '%c'"), Open), OpenLine, OpenCol); + return false; + } + }; + + /** + * Remove `//` line and block comments, preserving quoted strings. Used on the structured blocks + * (Settings/Usage/Properties) before splitting them into statements — HLSL bodies keep their + * comments verbatim and must NOT pass through here. + */ + static FString StripComments(const FString& In) + { + FString Out; + Out.Reserve(In.Len()); + const int32 N = In.Len(); + int32 i = 0; + while (i < N) + { + const TCHAR C = In[i]; + if (C == TEXT('"') || C == TEXT('\'')) + { + const TCHAR Quote = C; + Out.AppendChar(C); + ++i; + while (i < N) + { + if (In[i] == TEXT('\\') && i + 1 < N) + { + Out.AppendChar(In[i]); + Out.AppendChar(In[i + 1]); + i += 2; + continue; + } + Out.AppendChar(In[i]); + const bool bClose = (In[i] == Quote); + ++i; + if (bClose) { break; } + } + continue; + } + if (C == TEXT('/') && i + 1 < N && In[i + 1] == TEXT('/')) + { + while (i < N && In[i] != TEXT('\n')) { ++i; } + continue; + } + if (C == TEXT('/') && i + 1 < N && In[i + 1] == TEXT('*')) + { + i += 2; + while (i + 1 < N && !(In[i] == TEXT('*') && In[i + 1] == TEXT('/'))) { ++i; } + i += 2; + continue; + } + Out.AppendChar(C); + ++i; + } + return Out; + } + + /** Split `Inner` on top-level occurrences of `Delim`, respecting (), [] and quotes. */ + static TArray SplitTopLevel(const FString& Inner, TCHAR Delim) + { + TArray Parts; + FString Current; + int32 Paren = 0; + int32 Bracket = 0; + bool bInString = false; + TCHAR Quote = TEXT('\0'); + + for (int32 i = 0; i < Inner.Len(); ++i) + { + const TCHAR C = Inner[i]; + if (bInString) + { + Current.AppendChar(C); + if (C == Quote) + { + bInString = false; + } + continue; + } + switch (C) + { + case TEXT('"'): + case TEXT('\''): + bInString = true; + Quote = C; + Current.AppendChar(C); + break; + case TEXT('('): ++Paren; Current.AppendChar(C); break; + case TEXT(')'): --Paren; Current.AppendChar(C); break; + case TEXT('['): ++Bracket; Current.AppendChar(C); break; + case TEXT(']'): --Bracket; Current.AppendChar(C); break; + default: + if (C == Delim && Paren == 0 && Bracket == 0) + { + Parts.Add(Current.TrimStartAndEnd()); + Current.Reset(); + } + else + { + Current.AppendChar(C); + } + break; + } + } + Current = Current.TrimStartAndEnd(); + if (!Current.IsEmpty() || Parts.Num() > 0) + { + Parts.Add(Current); + } + return Parts; + } + + static bool ParsePropertyType(const FString& Token, EShaderLabPropertyType& Out) + { + if (Token == TEXT("Scalar")) { Out = EShaderLabPropertyType::Scalar; return true; } + if (Token == TEXT("Color")) { Out = EShaderLabPropertyType::Color; return true; } + if (Token == TEXT("Vector")) { Out = EShaderLabPropertyType::Vector; return true; } + if (Token == TEXT("Texture2D")) { Out = EShaderLabPropertyType::Texture2D; return true; } + if (Token == TEXT("TextureCube")) { Out = EShaderLabPropertyType::TextureCube; return true; } + if (Token == TEXT("StaticBool")) { Out = EShaderLabPropertyType::StaticBool; return true; } + return false; + } + + static bool ParseDomain(const FString& Token, EShaderLabDomain& Out) + { + if (Token == TEXT("Surface")) { Out = EShaderLabDomain::Surface; return true; } + if (Token == TEXT("PostProcess")) { Out = EShaderLabDomain::PostProcess; return true; } + if (Token == TEXT("UI")) { Out = EShaderLabDomain::UI; return true; } + if (Token == TEXT("Decal")) { Out = EShaderLabDomain::Decal; return true; } + return false; + } + + static bool ParseBlendMode(const FString& Token, EShaderLabBlendMode& Out) + { + if (Token == TEXT("Opaque")) { Out = EShaderLabBlendMode::Opaque; return true; } + if (Token == TEXT("Masked")) { Out = EShaderLabBlendMode::Masked; return true; } + if (Token == TEXT("Translucent")) { Out = EShaderLabBlendMode::Translucent; return true; } + if (Token == TEXT("Additive")) { Out = EShaderLabBlendMode::Additive; return true; } + if (Token == TEXT("Modulate")) { Out = EShaderLabBlendMode::Modulate; return true; } + return false; + } + + static bool ParseBool(const FString& Token, bool& Out) + { + if (Token == TEXT("true")) { Out = true; return true; } + if (Token == TEXT("false")) { Out = false; return true; } + return false; + } + + /** Parse a parenthesized float list "(r, g, b[, a])" into a color (alpha defaults to 1). */ + static bool ParseColorLiteral(const FString& InRaw, FLinearColor& Out) + { + FString Raw = InRaw.TrimStartAndEnd(); + if (!Raw.StartsWith(TEXT("(")) || !Raw.EndsWith(TEXT(")"))) + { + return false; + } + Raw = Raw.Mid(1, Raw.Len() - 2); + const TArray Comps = SplitTopLevel(Raw, TEXT(',')); + if (Comps.Num() < 3 || Comps.Num() > 4) + { + return false; + } + Out = FLinearColor( + FCString::Atof(*Comps[0]), + FCString::Atof(*Comps[1]), + FCString::Atof(*Comps[2]), + Comps.Num() == 4 ? FCString::Atof(*Comps[3]) : 1.0f); + return true; + } + + /** Parse the contents of an entry-point signature `(...)` into typed params. */ + static bool ParseEntryParams(const FString& Inner, TArray& Out) + { + const TArray Args = SplitTopLevel(Inner, TEXT(',')); + for (const FString& Arg : Args) + { + if (Arg.IsEmpty()) + { + continue; + } + TArray Tokens; + Arg.ParseIntoArrayWS(Tokens); + if (Tokens.Num() < 2) + { + return false; + } + FShaderLabEntryParam Param; + int32 TypeIdx = 0; + if (Tokens[0] == TEXT("inout") || Tokens[0] == TEXT("out") || Tokens[0] == TEXT("in")) + { + Param.bInout = (Tokens[0] == TEXT("inout") || Tokens[0] == TEXT("out")); + TypeIdx = 1; + } + if (TypeIdx + 1 >= Tokens.Num()) + { + return false; + } + Param.Type = Tokens[TypeIdx]; + Param.Name = Tokens[TypeIdx + 1]; + Out.Add(MoveTemp(Param)); + } + return true; + } +} + +using namespace ShaderLabParser_Private; + +namespace +{ + void ParseSettings(FScanner& S, FShaderLabModel& Model) + { + FShaderLabSettings& Out = Model.Settings; + FString Inner; + if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Inner)) + { + return; + } + // Settings are simple `Key = Value;` pairs; tolerate trailing items. + for (const FString& StmtRaw : SplitTopLevel(StripComments(Inner), TEXT(';'))) + { + const FString Stmt = StmtRaw.TrimStartAndEnd(); + if (Stmt.IsEmpty()) + { + continue; + } + FString Key, Value; + if (!Stmt.Split(TEXT("="), &Key, &Value)) + { + S.Error(FString::Printf(TEXT("Malformed setting '%s' (expected Key = Value)"), *Stmt)); + return; + } + Key = Key.TrimStartAndEnd(); + Value = Value.TrimStartAndEnd(); + + if (Key == TEXT("Domain")) + { + if (!ParseDomain(Value, Out.Domain)) + { + S.Error(FString::Printf(TEXT("Unknown Domain '%s'"), *Value)); + return; + } + } + else if (Key == TEXT("BlendMode")) + { + if (!ParseBlendMode(Value, Out.BlendMode)) + { + S.Error(FString::Printf(TEXT("Unknown BlendMode '%s'"), *Value)); + return; + } + } + else if (Key == TEXT("TwoSided")) + { + if (!ParseBool(Value, Out.bTwoSided)) + { + S.Error(FString::Printf(TEXT("TwoSided expects true/false, found '%s'"), *Value)); + return; + } + } + else + { + // Long-tail setting: stored verbatim. Allowlist + type validation happens in + // FShaderLabSettingsApplier at build time (the parser is Engine-reflection-free). + Model.RawSettings.Emplace(Key, Value); + } + } + } + + void ParseOnePropertyDecl(FScanner& S, const FString& Stmt, FShaderLabModel& Model) + { + // Form: Type Name ( "Disp", "Group" [, Range(a,b)] ) = default + FString TypeName; + int32 Cursor = 0; + const FString Trimmed = Stmt.TrimStartAndEnd(); + + // Read type token. + while (Cursor < Trimmed.Len() && !FChar::IsWhitespace(Trimmed[Cursor])) + { + ++Cursor; + } + TypeName = Trimmed.Left(Cursor).TrimStartAndEnd(); + + EShaderLabPropertyType Type; + if (!ParsePropertyType(TypeName, Type)) + { + S.Error(FString::Printf(TEXT("Unknown property type '%s'"), *TypeName)); + return; + } + + // Read name token up to '('. + const int32 ParenOpen = Trimmed.Find(TEXT("("), ESearchCase::CaseSensitive, ESearchDir::FromStart, Cursor); + if (ParenOpen == INDEX_NONE) + { + S.Error(FString::Printf(TEXT("Property '%s' missing '(' metadata block"), *TypeName)); + return; + } + const FString NameToken = Trimmed.Mid(Cursor, ParenOpen - Cursor).TrimStartAndEnd(); + if (NameToken.IsEmpty()) + { + S.Error(TEXT("Property is missing a name")); + return; + } + + // Find matching ')' for the metadata block (no nested parens except Range(...)). + int32 Depth = 0; + int32 ParenClose = INDEX_NONE; + for (int32 i = ParenOpen; i < Trimmed.Len(); ++i) + { + if (Trimmed[i] == TEXT('(')) { ++Depth; } + else if (Trimmed[i] == TEXT(')')) { --Depth; if (Depth == 0) { ParenClose = i; break; } } + } + if (ParenClose == INDEX_NONE) + { + S.Error(FString::Printf(TEXT("Property '%s' has an unterminated '(' block"), *NameToken)); + return; + } + + FShaderLabProperty Prop; + Prop.Name = FName(*NameToken); + Prop.Type = Type; + + const FString MetaInner = Trimmed.Mid(ParenOpen + 1, ParenClose - ParenOpen - 1); + const TArray MetaArgs = SplitTopLevel(MetaInner, TEXT(',')); + int32 StringArgIndex = 0; + for (const FString& ArgRaw : MetaArgs) + { + const FString Arg = ArgRaw.TrimStartAndEnd(); + if (Arg.IsEmpty()) + { + continue; + } + if (Arg.StartsWith(TEXT("\""))) + { + const FString Unquoted = Arg.TrimQuotes(); + if (StringArgIndex == 0) { Prop.DisplayName = Unquoted; } + else if (StringArgIndex == 1) { Prop.Group = Unquoted; } + ++StringArgIndex; + } + else if (Arg.StartsWith(TEXT("Range"))) + { + FString RangeInner; + const int32 RO = Arg.Find(TEXT("(")); + const int32 RC = Arg.Find(TEXT(")"), ESearchCase::CaseSensitive, ESearchDir::FromEnd); + if (RO == INDEX_NONE || RC == INDEX_NONE || RC <= RO) + { + S.Error(FString::Printf(TEXT("Property '%s' has malformed Range(...)"), *NameToken)); + return; + } + RangeInner = Arg.Mid(RO + 1, RC - RO - 1); + const TArray RangeComps = SplitTopLevel(RangeInner, TEXT(',')); + if (RangeComps.Num() != 2) + { + S.Error(FString::Printf(TEXT("Property '%s' Range expects (min,max)"), *NameToken)); + return; + } + Prop.bHasRange = true; + Prop.RangeMin = FCString::Atof(*RangeComps[0]); + Prop.RangeMax = FCString::Atof(*RangeComps[1]); + } + else + { + S.Error(FString::Printf(TEXT("Property '%s' has unknown metadata '%s'"), *NameToken, *Arg)); + return; + } + } + if (Prop.DisplayName.IsEmpty()) + { + Prop.DisplayName = NameToken; + } + + // Default value after '='. + const FString AfterParen = Trimmed.Mid(ParenClose + 1).TrimStartAndEnd(); + if (!AfterParen.StartsWith(TEXT("="))) + { + S.Error(FString::Printf(TEXT("Property '%s' is missing '= default'"), *NameToken)); + return; + } + const FString DefaultRaw = AfterParen.Mid(1).TrimStartAndEnd(); + + switch (Type) + { + case EShaderLabPropertyType::Scalar: + Prop.ScalarDefault = FCString::Atof(*DefaultRaw); + break; + case EShaderLabPropertyType::Color: + case EShaderLabPropertyType::Vector: + if (!ParseColorLiteral(DefaultRaw, Prop.VectorDefault)) + { + S.Error(FString::Printf(TEXT("Property '%s' default must be (r,g,b[,a])"), *NameToken)); + return; + } + break; + case EShaderLabPropertyType::Texture2D: + case EShaderLabPropertyType::TextureCube: + if (!DefaultRaw.StartsWith(TEXT("\""))) + { + S.Error(FString::Printf(TEXT("Property '%s' default must be a quoted texture token"), *NameToken)); + return; + } + Prop.TextureDefault = DefaultRaw.TrimQuotes(); + break; + case EShaderLabPropertyType::StaticBool: + if (!ParseBool(DefaultRaw, Prop.bStaticBoolDefault)) + { + S.Error(FString::Printf(TEXT("Property '%s' default must be true/false"), *NameToken)); + return; + } + break; + } + + Prop.SortPriority = Model.Properties.Num(); + if (Model.FindProperty(Prop.Name) != nullptr) + { + S.Error(FString::Printf(TEXT("Duplicate property name '%s'"), *NameToken)); + return; + } + Model.Properties.Add(MoveTemp(Prop)); + } + + void ParseProperties(FScanner& S, FShaderLabModel& Model) + { + FString Inner; + if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Inner)) + { + return; + } + for (const FString& StmtRaw : SplitTopLevel(StripComments(Inner), TEXT(';'))) + { + const FString Stmt = StmtRaw.TrimStartAndEnd(); + if (Stmt.IsEmpty()) + { + continue; + } + ParseOnePropertyDecl(S, Stmt, Model); + if (S.bFailed) + { + return; + } + } + } + + void ParseIncludes(FScanner& S, FShaderLabModel& Model) + { + FString Inner; + if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Inner)) + { + return; + } + // Accept whitespace/comma/semicolon separated quoted strings. + FScanner Sub(Inner, S.Errors); + while (true) + { + Sub.SkipTrivia(); + if (Sub.IsEnd()) + { + break; + } + if (Sub.Peek() == TEXT(',') || Sub.Peek() == TEXT(';')) + { + Sub.Advance(); + continue; + } + FString Path; + if (!Sub.ReadString(Path)) + { + S.Error(TEXT("Includes block expects quoted paths")); + return; + } + Model.Includes.Add(Path); + } + } + + void ParseSlab(FScanner& S, FShaderLabModel& Model) + { + FString Name; + if (!S.ReadIdentifier(Name)) + { + S.Error(TEXT("Slab must be followed by a name")); + return; + } + FString SigInner; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), SigInner)) + { + return; + } + TArray Params; + if (!ParseEntryParams(SigInner, Params) || Params.Num() < 1) + { + S.Error(FString::Printf(TEXT("Slab '%s' must take an (inout FShaderLabSurface) parameter"), *Name)); + return; + } + FString Body; + int32 BodyLine = 0; + if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Body, &BodyLine)) + { + return; + } + FShaderLabSlab Slab; + Slab.Name = FName(*Name); + Slab.OutParamName = Params.Last().Name; + Slab.Body = MoveTemp(Body); + Slab.BodyLine = BodyLine; + Model.Slabs.Add(MoveTemp(Slab)); + } + + void ParseValue(FScanner& S, FShaderLabModel& Model) + { + FString Name; + if (!S.ReadIdentifier(Name)) + { + S.Error(TEXT("Value must be followed by a name")); + return; + } + FString SigInner; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), SigInner)) // signature is empty: Value Name() { ... } + { + return; + } + FString Body; + int32 BodyLine = 0; + if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Body, &BodyLine)) + { + return; + } + FShaderLabValue Value; + Value.Name = FName(*Name); + Value.Body = MoveTemp(Body); + Value.BodyLine = BodyLine; + Model.Values.Add(MoveTemp(Value)); + } + + /** Parse a scalar mix factor: a float literal, or a name (Value block / Scalar property, resolved later). */ + bool ParseTopoFactor(FScanner& S, FShaderLabFactor& Out) + { + S.SkipTrivia(); + const TCHAR C = S.Peek(); + if (FChar::IsDigit(C) || C == TEXT('.') || C == TEXT('-') || C == TEXT('+')) + { + FString Num; + while (!S.IsEnd()) + { + const TCHAR P = S.Peek(); + if (FChar::IsWhitespace(P) || P == TEXT(',') || P == TEXT(')')) + { + break; + } + Num.AppendChar(S.Advance()); + } + Out.Kind = FShaderLabFactor::EKind::Literal; + Out.Literal = FCString::Atof(*Num); + return true; + } + FString Name; + if (!S.ReadIdentifier(Name)) + { + S.Error(TEXT("Expected a mix factor (number, Scalar property, or Value name)")); + return false; + } + Out.Kind = FShaderLabFactor::EKind::Named; + Out.Name = FName(*Name); + return true; + } + + /** Recursively parse a FrontMaterial topology expression; returns the node index (INDEX_NONE on error). */ + int32 ParseTopoExpr(FScanner& S, FShaderLabModel& Model) + { + FString Name; + if (!S.ReadIdentifier(Name)) + { + S.Error(TEXT("Expected a slab name or topology operator")); + return INDEX_NONE; + } + S.SkipTrivia(); + if (S.Peek() != TEXT('(')) + { + // Bare identifier => slab reference (leaf). + FShaderLabTopoNode Leaf; + Leaf.Op = EShaderLabOp::SlabRef; + Leaf.SlabRef = FName(*Name); + return Model.Topology.Add(Leaf); + } + + EShaderLabOp Op = EShaderLabOp::SlabRef; + int32 NumExprChildren = 0; + bool bFactor = false; + if (Name == TEXT("VerticalLayer")) { Op = EShaderLabOp::VerticalLayer; NumExprChildren = 2; bFactor = true; } + else if (Name == TEXT("HorizontalMix")) { Op = EShaderLabOp::HorizontalMix; NumExprChildren = 2; bFactor = true; } + else if (Name == TEXT("Add")) { Op = EShaderLabOp::Add; NumExprChildren = 2; bFactor = false; } + else if (Name == TEXT("Weight")) { Op = EShaderLabOp::Weight; NumExprChildren = 1; bFactor = true; } + else if (Name == TEXT("Select")) { Op = EShaderLabOp::Select; NumExprChildren = 2; bFactor = true; } + else + { + S.Error(FString::Printf(TEXT("Unknown topology operator '%s'"), *Name)); + return INDEX_NONE; + } + + if (!S.Expect(TEXT('('), TEXT("after topology operator"))) + { + return INDEX_NONE; + } + FShaderLabTopoNode Node; + Node.Op = Op; + Node.ChildA = ParseTopoExpr(S, Model); + if (S.bFailed) { return INDEX_NONE; } + if (NumExprChildren >= 2) + { + if (!S.Expect(TEXT(','), TEXT("between topology operands"))) { return INDEX_NONE; } + Node.ChildB = ParseTopoExpr(S, Model); + if (S.bFailed) { return INDEX_NONE; } + } + if (bFactor) + { + if (!S.Expect(TEXT(','), TEXT("before topology mix factor"))) { return INDEX_NONE; } + Node.bHasFactor = true; + if (!ParseTopoFactor(S, Node.Factor)) { return INDEX_NONE; } + } + if (!S.Expect(TEXT(')'), TEXT("to close topology operator"))) { return INDEX_NONE; } + return Model.Topology.Add(Node); + } + + void ParseFrontMaterial(FScanner& S, FShaderLabModel& Model) + { + if (!S.Expect(TEXT('='), TEXT("after FrontMaterial"))) + { + return; + } + Model.TopologyRoot = ParseTopoExpr(S, Model); + if (S.bFailed) + { + return; + } + S.Expect(TEXT(';'), TEXT("after FrontMaterial expression")); + } + + /** Parse `Opacity = ;` / `OpacityMask = ;` (material-level outputs). */ + void ParseMaterialOutput(FScanner& S, FName& OutValueName, const TCHAR* What) + { + if (!S.Expect(TEXT('='), What)) + { + return; + } + FString Name; + if (!S.ReadIdentifier(Name)) + { + S.Error(FString::Printf(TEXT("%s expects a Value block name"), What)); + return; + } + OutValueName = FName(*Name); + S.Expect(TEXT(';'), What); + } +} + +bool FShaderLabParser::Parse( + const FString& Source, + const FString& SourceName, + FShaderLabModel& OutModel, + TArray& OutErrors) +{ + OutModel = FShaderLabModel(); + OutModel.SourceFilePath = SourceName; + // Identity + display name come from the file name (e.g. "Basic.usl" -> "Basic"); the file IS the + // shader, so there is no top-level `shader "Name" { }` wrapper — sections are parsed flat to EOF. + OutModel.ShaderName = FPaths::GetBaseFilename(SourceName); + + FScanner S(Source, OutErrors); + + while (!S.bFailed) + { + S.SkipTrivia(); + if (S.IsEnd()) + { + break; // End of file: all sections parsed. + } + + FString Section; + if (!S.ReadIdentifier(Section)) + { + S.Error(FString::Printf(TEXT("Expected a section keyword, found '%c'"), S.Peek())); + return false; + } + + if (Section == TEXT("Settings")) + { + ParseSettings(S, OutModel); + } + else if (Section == TEXT("Properties")) + { + ParseProperties(S, OutModel); + } + else if (Section == TEXT("Includes")) + { + ParseIncludes(S, OutModel); + } + else if (Section == TEXT("Slab")) + { + ParseSlab(S, OutModel); + } + else if (Section == TEXT("Value")) + { + ParseValue(S, OutModel); + } + else if (Section == TEXT("FrontMaterial")) + { + ParseFrontMaterial(S, OutModel); + } + else if (Section == TEXT("Opacity")) + { + ParseMaterialOutput(S, OutModel.OpacityValueName, TEXT("Opacity")); + } + else if (Section == TEXT("OpacityMask")) + { + ParseMaterialOutput(S, OutModel.OpacityMaskValueName, TEXT("OpacityMask")); + } + else if (Section == TEXT("Surface") || Section == TEXT("PostProcess") || Section == TEXT("UI") || Section == TEXT("Vertex")) + { + const bool bIsSurface = (Section != TEXT("Vertex")); + FString SigInner; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), SigInner)) + { + return false; + } + TArray Params; + if (!ParseEntryParams(SigInner, Params)) + { + S.Error(FString::Printf(TEXT("%s has a malformed parameter signature"), *Section)); + return false; + } + FString Body; + int32 BodyLine = 0; + if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Body, &BodyLine)) + { + return false; + } + if (bIsSurface) + { + if (OutModel.bHasSurface) + { + S.Error(TEXT("A shader may declare only one pixel entry (Surface/PostProcess/UI)")); + return false; + } + OutModel.SurfaceParams = MoveTemp(Params); + OutModel.SurfaceBody = MoveTemp(Body); + OutModel.SurfaceBodyLine = BodyLine; + OutModel.bHasSurface = true; + OutModel.SurfaceEntry = (Section == TEXT("PostProcess")) ? EShaderLabEntry::PostProcess + : (Section == TEXT("UI")) ? EShaderLabEntry::UI + : EShaderLabEntry::Surface; + } + else + { + OutModel.VertexParams = MoveTemp(Params); + OutModel.VertexBody = MoveTemp(Body); + OutModel.VertexBodyLine = BodyLine; + OutModel.bHasVertex = true; + } + } + else + { + S.Error(FString::Printf(TEXT("Unknown section '%s'"), *Section)); + return false; + } + } + + if (S.bFailed) + { + return false; + } + + // Pixel stage: exactly one of { single Surface } or { named Slabs + FrontMaterial }. + const bool bHasMultiSlab = OutModel.Slabs.Num() > 0 || OutModel.TopologyRoot != INDEX_NONE; + if (OutModel.bHasSurface && bHasMultiSlab) + { + S.Error(TEXT("Use either a single Surface(...) or named Slab blocks with FrontMaterial, not both")); + return false; + } + if (!OutModel.bHasSurface && !bHasMultiSlab) + { + S.Error(TEXT("Shader must declare a Surface(...) entry point, or Slab blocks with a FrontMaterial expression")); + return false; + } + if (bHasMultiSlab) + { + if (OutModel.Slabs.Num() == 0) + { + S.Error(TEXT("FrontMaterial requires at least one Slab block")); + return false; + } + if (OutModel.TopologyRoot == INDEX_NONE) + { + S.Error(TEXT("Slab blocks require a 'FrontMaterial = ...;' topology expression")); + return false; + } + } + + // Domain must match the declared pixel entry. PostProcess/UI domains use their dedicated single + // entries; Surface/Decal use Surface or multi-slab (both Substrate paths). + const EShaderLabDomain Domain = OutModel.Settings.Domain; + if (Domain == EShaderLabDomain::PostProcess && !(OutModel.bHasSurface && OutModel.SurfaceEntry == EShaderLabEntry::PostProcess)) + { + S.Error(TEXT("Domain = PostProcess requires a PostProcess(inout FShaderLabPostProcess) entry")); + return false; + } + if (Domain == EShaderLabDomain::UI && !(OutModel.bHasSurface && OutModel.SurfaceEntry == EShaderLabEntry::UI)) + { + S.Error(TEXT("Domain = UI requires a UI(inout FShaderLabUI) entry")); + return false; + } + if ((Domain == EShaderLabDomain::Surface || Domain == EShaderLabDomain::Decal) && OutModel.bHasSurface + && OutModel.SurfaceEntry != EShaderLabEntry::Surface) + { + S.Error(TEXT("Surface/Decal domains require a Surface entry (or Slab blocks), not PostProcess/UI")); + return false; + } + + return true; +} diff --git a/Source/UShaderLab/Private/ShaderLabRuntimeBuilder.cpp b/Source/UShaderLab/Private/ShaderLabRuntimeBuilder.cpp new file mode 100644 index 0000000..fde1ade --- /dev/null +++ b/Source/UShaderLab/Private/ShaderLabRuntimeBuilder.cpp @@ -0,0 +1,45 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabRuntimeBuilder.h" + +#include "Engine/EngineTypes.h" +#include "MaterialDomain.h" +#include "Materials/Material.h" +#include "ShaderLabModel.h" +#include "ShaderLabSettingsApplier.h" + +DEFINE_LOG_CATEGORY_STATIC(LogShaderLabRuntime, Log, All); + +void FShaderLabRuntimeBuilder::ApplySettings(UMaterial& Material, const FShaderLabModel& Model) +{ + switch (Model.Settings.Domain) + { + case EShaderLabDomain::PostProcess: Material.MaterialDomain = MD_PostProcess; break; + case EShaderLabDomain::UI: Material.MaterialDomain = MD_UI; break; + case EShaderLabDomain::Decal: Material.MaterialDomain = MD_DeferredDecal; break; + case EShaderLabDomain::Surface: + default: Material.MaterialDomain = MD_Surface; break; + } + + switch (Model.Settings.BlendMode) + { + case EShaderLabBlendMode::Masked: Material.BlendMode = BLEND_Masked; break; + case EShaderLabBlendMode::Translucent: Material.BlendMode = BLEND_Translucent; break; + case EShaderLabBlendMode::Additive: Material.BlendMode = BLEND_Additive; break; + case EShaderLabBlendMode::Modulate: Material.BlendMode = BLEND_Modulate; break; + case EShaderLabBlendMode::Opaque: + default: Material.BlendMode = BLEND_Opaque; break; + } + + Material.TwoSided = Model.Settings.bTwoSided ? 1 : 0; + + // Reflected long-tail settings. A failure here means a malformed .usl shipped in the build, so + // log it loudly. (No usage flags: the base shell is a never-rendered template; usage lives on + // the instances.) + TArray Errors; + FShaderLabSettingsApplier::ApplyReflectedSettings(Material, Model.RawSettings, Errors); + for (const FString& Error : Errors) + { + UE_LOG(LogShaderLabRuntime, Error, TEXT("ShaderLab runtime settings '%s': %s"), *Model.ShaderName, *Error); + } +} diff --git a/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp b/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp new file mode 100644 index 0000000..af87491 --- /dev/null +++ b/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp @@ -0,0 +1,141 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabSettingsApplier.h" + +#include "Materials/Material.h" +#include "Materials/MaterialInterface.h" +#include "UObject/Class.h" +#include "UObject/EnumProperty.h" +#include "UObject/UnrealType.h" + +namespace ShaderLabSettings_Private +{ + // UMaterial properties a `.usl` Settings block may set by reflection. Names only — adding a + // setting is a one-line edit here. Every entry must be a non-editor-only property so the cooked + // runtime shell can apply the identical value. + const TSet& AllowedSettingNames() + { + static const TSet Names = { + FName(TEXT("OpacityMaskClipValue")), + FName(TEXT("TranslucencyLightingMode")), + FName(TEXT("TranslucencyPass")), + FName(TEXT("bCastDynamicShadowAsMasked")), + FName(TEXT("bEnableResponsiveAA")), + FName(TEXT("bScreenSpaceReflections")), + FName(TEXT("bContactShadows")), + FName(TEXT("bIsThinSurface")), + FName(TEXT("DitheredLODTransition")), + }; + return Names; + } + + bool ParseBool(const FString& In, bool& Out) + { + const FString V = In.TrimStartAndEnd(); + if (V == TEXT("true") || V == TEXT("1")) { Out = true; return true; } + if (V == TEXT("false") || V == TEXT("0")) { Out = false; return true; } + return false; + } + + // Coerce `Value` to `Prop`'s type and write it into `Material`. Returns false with a reason on + // type mismatch / unresolved enum token. + bool CoerceAndSet(UMaterial& Material, FProperty* Prop, const FString& Value, FString& OutReason) + { + void* ValuePtr = Prop->ContainerPtrToValuePtr(&Material); + + if (FBoolProperty* BoolProp = CastField(Prop)) + { + bool bVal = false; + if (!ParseBool(Value, bVal)) + { + OutReason = TEXT("expected true/false"); + return false; + } + BoolProp->SetPropertyValue_InContainer(&Material, bVal); + return true; + } + if (FFloatProperty* FloatProp = CastField(Prop)) + { + FloatProp->SetPropertyValue(ValuePtr, FCString::Atof(*Value)); + return true; + } + if (FDoubleProperty* DoubleProp = CastField(Prop)) + { + DoubleProp->SetPropertyValue(ValuePtr, FCString::Atod(*Value)); + return true; + } + if (FIntProperty* IntProp = CastField(Prop)) + { + IntProp->SetPropertyValue(ValuePtr, FCString::Atoi(*Value)); + return true; + } + // TEnumAsByte reflects as FByteProperty with ->Enum set; resolve the bare token by name. + if (FByteProperty* ByteProp = CastField(Prop)) + { + if (UEnum* Enum = ByteProp->Enum) + { + const int64 EnumVal = Enum->GetValueByNameString(Value); + if (EnumVal == INDEX_NONE) + { + OutReason = FString::Printf(TEXT("unknown enum value '%s' for %s"), *Value, *Enum->GetName()); + return false; + } + ByteProp->SetPropertyValue(ValuePtr, static_cast(EnumVal)); + return true; + } + ByteProp->SetPropertyValue(ValuePtr, static_cast(FCString::Atoi(*Value))); + return true; + } + // enum class reflects as FEnumProperty. + if (FEnumProperty* EnumProp = CastField(Prop)) + { + UEnum* Enum = EnumProp->GetEnum(); + const int64 EnumVal = Enum ? Enum->GetValueByNameString(Value) : INDEX_NONE; + if (EnumVal == INDEX_NONE) + { + OutReason = FString::Printf(TEXT("unknown enum value '%s'"), *Value); + return false; + } + EnumProp->GetUnderlyingProperty()->SetIntPropertyValue(ValuePtr, EnumVal); + return true; + } + + OutReason = FString::Printf(TEXT("unsupported property type '%s'"), *Prop->GetClass()->GetName()); + return false; + } +} + +bool FShaderLabSettingsApplier::ApplyReflectedSettings( + UMaterial& Material, + const TArray>& RawSettings, + TArray& OutErrors) +{ + using namespace ShaderLabSettings_Private; + + bool bOk = true; + for (const TPair& Pair : RawSettings) + { + const FName Key(*Pair.Key); + if (!AllowedSettingNames().Contains(Key)) + { + OutErrors.Add(FString::Printf(TEXT("Setting '%s' is not in the ShaderLab allowlist"), *Pair.Key)); + bOk = false; + continue; + } + FProperty* Prop = UMaterial::StaticClass()->FindPropertyByName(Key); + if (!Prop) + { + OutErrors.Add(FString::Printf(TEXT("UMaterial has no property '%s'"), *Pair.Key)); + bOk = false; + continue; + } + FString Reason; + if (!CoerceAndSet(Material, Prop, Pair.Value, Reason)) + { + OutErrors.Add(FString::Printf(TEXT("Setting '%s': %s"), *Pair.Key, *Reason)); + bOk = false; + } + } + return bOk; +} + diff --git a/Source/UShaderLab/Private/ShaderLabSubsystem.cpp b/Source/UShaderLab/Private/ShaderLabSubsystem.cpp new file mode 100644 index 0000000..dc8c61f --- /dev/null +++ b/Source/UShaderLab/Private/ShaderLabSubsystem.cpp @@ -0,0 +1,81 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabSubsystem.h" + +#include "Misc/CoreDelegates.h" +#include "Misc/FileHelper.h" +#include "ShaderLabDiscovery.h" +#include "ShaderLabMaterialRegistry.h" +#include "ShaderLabModel.h" +#include "ShaderLabParser.h" + +DEFINE_LOG_CATEGORY_STATIC(LogShaderLabSubsystem, Log, All); + +void UShaderLabSubsystem::Initialize(FSubsystemCollectionBase& Collection) +{ + Super::Initialize(Collection); + + // Defer the scan until the engine (and material/shader systems) are fully up. + PostEngineInitHandle = FCoreDelegates::GetOnPostEngineInit().AddWeakLambda(this, [this]() + { + DiscoverAndRegisterAll(); + }); +} + +void UShaderLabSubsystem::Deinitialize() +{ + if (PostEngineInitHandle.IsValid()) + { + FCoreDelegates::GetOnPostEngineInit().Remove(PostEngineInitHandle); + PostEngineInitHandle.Reset(); + } + Super::Deinitialize(); +} + +void UShaderLabSubsystem::DiscoverAndRegisterAll() +{ + if (bScanned) + { + return; + } + bScanned = true; + + // Both editor and cooked runtime rebuild from the SAME .usl sources — there is no serialized + // side data. The .usl files are staged into packaged builds (a wildcard RuntimeDependency in + // ShaderLab.Build.cs) and the parser/discovery are runtime-safe, so a cooked build re-parses + // them exactly as the editor does. In the editor the bound builder compiles a graph; in a cooked + // build it only applies settings to a never-rendered base shell (the instances carry the shaders). + const TArray Files = FShaderLabDiscovery::FindShaderLabFiles(); + int32 NumOk = 0; + for (const FString& File : Files) + { + if (RebuildFromFile(File)) + { + ++NumOk; + } + } + UE_LOG(LogShaderLabSubsystem, Log, TEXT("ShaderLab: registered %d/%d shader file(s)."), NumOk, Files.Num()); +} + +UMaterial* UShaderLabSubsystem::RebuildFromFile(const FString& FilePath) +{ + FString Source; + if (!FFileHelper::LoadFileToString(Source, *FilePath)) + { + UE_LOG(LogShaderLabSubsystem, Warning, TEXT("ShaderLab: failed to read '%s'"), *FilePath); + return nullptr; + } + + FShaderLabModel Model; + TArray Errors; + if (!FShaderLabParser::Parse(Source, FilePath, Model, Errors)) + { + for (const FShaderLabParseError& E : Errors) + { + UE_LOG(LogShaderLabSubsystem, Error, TEXT("ShaderLab parse error in %s %s"), *FilePath, *E.ToString()); + } + return nullptr; + } + + return FShaderLabMaterialRegistry::Get().RegisterFromModel(Model); +} diff --git a/Source/UShaderLab/Public/ShaderLabDiscovery.h b/Source/UShaderLab/Public/ShaderLabDiscovery.h new file mode 100644 index 0000000..ca20695 --- /dev/null +++ b/Source/UShaderLab/Public/ShaderLabDiscovery.h @@ -0,0 +1,15 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" + +/** Locates `.usl` source files under the project and enabled plugins. */ +struct USHADERLAB_API FShaderLabDiscovery +{ + /** Directories scanned for `*.usl` files: /ShaderLab and /ShaderLab. */ + static TArray GetSearchRoots(); + + /** Absolute paths of every discovered `.usl` file. */ + static TArray FindShaderLabFiles(); +}; diff --git a/Source/UShaderLab/Public/ShaderLabMaterialInstanceConstant.h b/Source/UShaderLab/Public/ShaderLabMaterialInstanceConstant.h new file mode 100644 index 0000000..86d8414 --- /dev/null +++ b/Source/UShaderLab/Public/ShaderLabMaterialInstanceConstant.h @@ -0,0 +1,34 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Materials/MaterialInstanceConstant.h" +#include "ShaderLabMaterialInstanceConstant.generated.h" + +/** + * A Material Instance Constant parented (directly or transitively) to a ShaderLab in-memory + * /Script base material. + * + * ShaderLab base materials live as pure in-memory /Script objects with NO serialized shader map. + * To render an instance of one without serializing the base's shader map (and the engine changes + * that would require), this subclass forces a *self-contained* static permutation — i.e. flips + * UMaterialInstance::bHasStaticPermutationResource to true so the instance compiles + cooks its OWN + * shader map through the standard pipeline — but ONLY when its immediate parent is one of those + * /Script base materials (a "root" instance). + * + * When the parent is another material instance (a "child"), it falls through to stock behavior so + * the child DEFERS to and SHARES the root's shader map. That keeps the shader-map count bounded: + * many color/parameter variants off one root share a single compiled map instead of each compiling + * its own (no combinatorial explosion). See ShaderLabMICShaderMapSpikeTest for the proof. + */ +UCLASS() +class USHADERLAB_API UShaderLabMaterialInstanceConstant : public UMaterialInstanceConstant +{ + GENERATED_BODY() + +public: + //~ UMaterialInstance interface + virtual bool HasOverridenBaseProperties() const override; + //~ End UMaterialInstance interface +}; diff --git a/Source/UShaderLab/Public/ShaderLabMaterialRegistry.h b/Source/UShaderLab/Public/ShaderLabMaterialRegistry.h new file mode 100644 index 0000000..fa94065 --- /dev/null +++ b/Source/UShaderLab/Public/ShaderLabMaterialRegistry.h @@ -0,0 +1,57 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "ShaderLabModel.h" + +class UPackage; +class UMaterial; + +/** + * Broadcast when a base material shell has been created/located and needs its contents filled: + * in the editor the graph builder rebuilds the expression graph; at cooked runtime the shell + * builder applies settings + attaches the precompiled shader map. + */ +DECLARE_MULTICAST_DELEGATE_TwoParams(FOnShaderLabBuildMaterial, UMaterial& /*Material*/, const FShaderLabModel& /*Model*/); + +/** + * Owns the in-memory base materials. They live in a compiled-in package named "/Script/UShaderLab" + * so that saved assets (MICs) can hard-reference them by object path and have that import resolve + * via FindObject in editor, cook AND cooked runtime without any .uasset on disk — the same trick + * the engine uses for all native /Script packages. Materials must be created before any referencing + * asset is resolved (cook start / early runtime). + */ +class USHADERLAB_API FShaderLabMaterialRegistry +{ +public: + static FShaderLabMaterialRegistry& Get(); + + /** The compiled-in "/Script/UShaderLab" package (created on first use, kept rooted). */ + UPackage* GetPackage(); + + /** Deterministic, UObject-safe object name for a shader name like "ShaderLab/RustyMetal". */ + static FString MakeObjectName(const FString& ShaderName); + + /** Full object path, e.g. "/Script/UShaderLab.ShaderLab_RustyMetal". */ + static FString MakeObjectPath(const FString& ShaderName); + + /** Find an already-created base material by shader name (nullptr if absent). */ + UMaterial* FindMaterial(const FString& ShaderName) const; + + /** + * Find-or-create the base material for `Model`, register it for path resolution, and + * broadcast OnBuildMaterial so a bound builder fills it in. Idempotent per shader name. + */ + UMaterial* RegisterFromModel(const FShaderLabModel& Model); + + FOnShaderLabBuildMaterial& OnBuildMaterial() { return BuildMaterialDelegate; } + + /** Models registered so far, keyed by object name (used by the instance factory picker and the example/validation commandlets). */ + const TMap& GetRegisteredModels() const { return RegisteredModels; } + +private: + UPackage* Package = nullptr; + FOnShaderLabBuildMaterial BuildMaterialDelegate; + TMap RegisteredModels; +}; diff --git a/Source/UShaderLab/Public/ShaderLabModel.h b/Source/UShaderLab/Public/ShaderLabModel.h new file mode 100644 index 0000000..b6d6264 --- /dev/null +++ b/Source/UShaderLab/Public/ShaderLabModel.h @@ -0,0 +1,196 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" + +/** + * Pure-data model parsed from a `.usl` DSL file. + * + * This layer has NO Engine/GPU/UObject dependency on purpose: it is the single source of truth + * shared by both the editor graph builder (which turns it into a UMaterialExpression graph) and the + * cooked-runtime shell builder (which applies the settings to a never-rendered base shell). The same + * .usl is re-parsed at runtime from the packaged build. Keeping it pure also makes it trivially + * unit-testable without an editor or shader compiler. + */ + +enum class EShaderLabPropertyType : uint8 +{ + Scalar, + Color, + Vector, + Texture2D, + TextureCube, + StaticBool, +}; + +enum class EShaderLabDomain : uint8 +{ + Surface, + PostProcess, + UI, + Decal, +}; + +/** Which single pixel-entry a shader declared (when bHasSurface). Selects the output struct + wiring. */ +enum class EShaderLabEntry : uint8 +{ + Surface, // FShaderLabSurface -> Substrate slab + PostProcess, // FShaderLabPostProcess -> EmissiveColor/Opacity + UI, // FShaderLabUI -> EmissiveColor/Opacity +}; + +enum class EShaderLabBlendMode : uint8 +{ + Opaque, + Masked, + Translucent, + Additive, + Modulate, +}; + +/** A single declared shader property -> becomes a standard UE material parameter. */ +struct USHADERLAB_API FShaderLabProperty +{ + FName Name; + EShaderLabPropertyType Type = EShaderLabPropertyType::Scalar; + + FString DisplayName; + FString Group; + int32 SortPriority = 0; + + /** Range(min,max) meta — only meaningful for Scalar. */ + bool bHasRange = false; + float RangeMin = 0.0f; + float RangeMax = 1.0f; + + // Defaults (only the field matching Type is meaningful). + float ScalarDefault = 0.0f; + FLinearColor VectorDefault = FLinearColor::Black; + /** Built-in texture token ("white"/"black"/"normal"/"grey") or an asset path. */ + FString TextureDefault; + bool bStaticBoolDefault = false; +}; + +/** A parameter in a `Surface(...)`/`Vertex(...)` entry-point signature. */ +struct USHADERLAB_API FShaderLabEntryParam +{ + FString Type; + FString Name; + bool bInout = false; +}; + +/** A named `Slab (inout FShaderLabSurface S){...}` block in a multi-slab shader. */ +struct USHADERLAB_API FShaderLabSlab +{ + FName Name; + /** Name of the inout output-struct parameter (e.g. "S"). */ + FString OutParamName; + FString Body; + int32 BodyLine = 0; +}; + +/** A named `Value (){ return ; }` block, used as a topology mix factor. */ +struct USHADERLAB_API FShaderLabValue +{ + FName Name; + FString Body; + int32 BodyLine = 0; +}; + +/** Substrate topology operators (short DSL aliases mapping to engine Substrate expression nodes). */ +enum class EShaderLabOp : uint8 +{ + SlabRef, // leaf: references a named Slab + VerticalLayer, // VerticalLayer(Top, Base, Thickness) + HorizontalMix, // HorizontalMix(Background, Foreground, Mix) + Add, // Add(A, B) + Weight, // Weight(A, Weight) + Select, // Select(A, B, Threshold) +}; + +/** + * A scalar argument to a topology operator. Either a float literal or a named reference; the named + * case is resolved by the graph builder to a Value block (preferred) or a Scalar property, error if + * neither exists (the parser cannot classify it because Value blocks may be declared later). + */ +struct USHADERLAB_API FShaderLabFactor +{ + enum class EKind : uint8 { Literal, Named }; + EKind Kind = EKind::Literal; + float Literal = 0.0f; + FName Name; // value-block or scalar-property name (when Kind == Named) +}; + +/** One node of the FrontMaterial topology tree. Children index into FShaderLabModel::Topology. */ +struct USHADERLAB_API FShaderLabTopoNode +{ + EShaderLabOp Op = EShaderLabOp::SlabRef; + FName SlabRef; // for SlabRef + int32 ChildA = INDEX_NONE; // first material input + int32 ChildB = INDEX_NONE; // second material input (binary ops) + bool bHasFactor = false; + FShaderLabFactor Factor; // for VerticalLayer/HorizontalMix/Weight/Select +}; + +struct USHADERLAB_API FShaderLabSettings +{ + EShaderLabDomain Domain = EShaderLabDomain::Surface; + EShaderLabBlendMode BlendMode = EShaderLabBlendMode::Opaque; + bool bTwoSided = false; +}; + +struct USHADERLAB_API FShaderLabModel +{ + /** Display/identity name = source file base name (e.g. "Basic" from "Basic.usl"). Drives the object name. */ + FString ShaderName; + + /** Source file this model was parsed from (for diagnostics / hashing context). */ + FString SourceFilePath; + + FShaderLabSettings Settings; + + /** + * Long-tail material settings applied via reflection onto UMaterial by name (the typed + * Domain/BlendMode/TwoSided above stay first-class because the graph builder branches on them). + * Stored as raw key/value strings to keep this model Core-only; the allowlist check and value + * coercion happen in FShaderLabSettingsApplier at build/runtime, where errors are surfaced. + */ + TArray> RawSettings; + + TArray Properties; + TArray Includes; + + // Pixel stage. Either a single `Surface(...)` (sugar: one anonymous slab straight to FrontMaterial), + // OR one-or-more named `Slab` blocks + a `FrontMaterial = ` expression. The two are + // mutually exclusive (enforced by the parser). + TArray SurfaceParams; + FString SurfaceBody; + /** 1-based source line of the first character of SurfaceBody (for #line mapping of compile errors). */ + int32 SurfaceBodyLine = 0; + /** True when a single pixel-entry (`Surface`/`PostProcess`/`UI`) was used (vs named Slabs + FrontMaterial). */ + bool bHasSurface = false; + /** Which single pixel-entry was declared (valid when bHasSurface). */ + EShaderLabEntry SurfaceEntry = EShaderLabEntry::Surface; + + // Multi-slab topology (used when !bHasSurface). + TArray Slabs; + TArray Values; + TArray Topology; + int32 TopologyRoot = INDEX_NONE; + /** Optional material-level outputs (multi-slab): names of Value blocks feeding Opacity/OpacityMask. */ + FName OpacityValueName; + FName OpacityMaskValueName; + + // Vertex stage (optional). + bool bHasVertex = false; + TArray VertexParams; + FString VertexBody; + int32 VertexBodyLine = 0; + + /** Find a property by name (nullptr if absent). */ + const FShaderLabProperty* FindProperty(FName InName) const; + + /** True if any property is a StaticBool (drives shader-map permutation count). */ + bool HasStaticSwitches() const; +}; diff --git a/Source/UShaderLab/Public/ShaderLabModule.h b/Source/UShaderLab/Public/ShaderLabModule.h new file mode 100644 index 0000000..84d76a3 --- /dev/null +++ b/Source/UShaderLab/Public/ShaderLabModule.h @@ -0,0 +1,13 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Modules/ModuleInterface.h" + +class FShaderLabModule : public IModuleInterface +{ +public: + virtual void StartupModule() override; + virtual void ShutdownModule() override; +}; diff --git a/Source/UShaderLab/Public/ShaderLabParser.h b/Source/UShaderLab/Public/ShaderLabParser.h new file mode 100644 index 0000000..8ae3eca --- /dev/null +++ b/Source/UShaderLab/Public/ShaderLabParser.h @@ -0,0 +1,41 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" + +struct FShaderLabModel; + +/** A single parse diagnostic with 1-based source location. */ +struct USHADERLAB_API FShaderLabParseError +{ + int32 Line = 0; + int32 Column = 0; + FString Message; + + FString ToString() const + { + return FString::Printf(TEXT("(%d:%d) %s"), Line, Column, *Message); + } +}; + +/** + * Recursive-descent parser for the `.usl` DSL. + * + * Input is untrusted text, so the parser collects diagnostics and returns false on failure + * rather than asserting (contract-style `check` is reserved for framework invariants, not + * external input validation). + */ +class USHADERLAB_API FShaderLabParser +{ +public: + /** + * Parse `Source` into `OutModel`. `SourceName` is recorded into the model and used in + * diagnostics. Returns true on success; on failure `OutErrors` is non-empty. + */ + static bool Parse( + const FString& Source, + const FString& SourceName, + FShaderLabModel& OutModel, + TArray& OutErrors); +}; diff --git a/Source/UShaderLab/Public/ShaderLabRuntimeBuilder.h b/Source/UShaderLab/Public/ShaderLabRuntimeBuilder.h new file mode 100644 index 0000000..54864ac --- /dev/null +++ b/Source/UShaderLab/Public/ShaderLabRuntimeBuilder.h @@ -0,0 +1,21 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" + +class UMaterial; +struct FShaderLabModel; + +/** + * Runtime (cooked) counterpart to the editor graph builder. There is no expression graph at runtime, + * so this only applies the material settings (domain / blend mode / two-sided + reflected settings) + * onto the base shell. The shell carries no shader map and is never rendered directly — it exists so + * that cooked UShaderLabMaterialInstanceConstant instances (which carry their own shaders) resolve + * their Parent and read parameter/setting metadata. + */ +struct USHADERLAB_API FShaderLabRuntimeBuilder +{ + /** Apply the material settings (domain / blend / two-sided) + the reflected long-tail settings onto the shell. */ + static void ApplySettings(UMaterial& Material, const FShaderLabModel& Model); +}; diff --git a/Source/UShaderLab/Public/ShaderLabSettingsApplier.h b/Source/UShaderLab/Public/ShaderLabSettingsApplier.h new file mode 100644 index 0000000..0dc71b7 --- /dev/null +++ b/Source/UShaderLab/Public/ShaderLabSettingsApplier.h @@ -0,0 +1,32 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" + +class UMaterial; + +/** + * Applies the long-tail (non-typed) ShaderLab settings onto a UMaterial, shared by the editor graph + * builder and the cooked runtime shell builder so the same flags drive shader compilation + * everywhere. + * + * Reflected settings: arbitrary `Key = Value` pairs resolved against an allowlist of UMaterial + * properties and coerced by reflection (float/int/bool-bitfield/enum-by-name). + * + * (Material usage is intentionally NOT a ShaderLab concern: base materials are templates with no + * usage flags. Usage is established per-instance on the UShaderLabMaterialInstanceConstant — the + * editor auto-sets it on mesh assignment, or the author ticks it in the instance's Usage Flag + * Overrides — so only the vertex-factory permutations actually used get compiled.) + * + * Contract: an unknown/disallowed key or an unresolvable value is collected into OutErrors (a hard + * build failure / cook abort) — never silently skipped. + */ +struct USHADERLAB_API FShaderLabSettingsApplier +{ + /** Apply reflected settings. Returns false (and appends to OutErrors) on any rejected entry. */ + static bool ApplyReflectedSettings( + UMaterial& Material, + const TArray>& RawSettings, + TArray& OutErrors); +}; diff --git a/Source/UShaderLab/Public/ShaderLabSubsystem.h b/Source/UShaderLab/Public/ShaderLabSubsystem.h new file mode 100644 index 0000000..ed70ffd --- /dev/null +++ b/Source/UShaderLab/Public/ShaderLabSubsystem.h @@ -0,0 +1,34 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Subsystems/EngineSubsystem.h" +#include "ShaderLabSubsystem.generated.h" + +class UMaterial; + +/** + * Discovers `.usl` files at startup and registers an in-memory base material for each. + * Runs the scan on post-engine-init so the material system is ready. Also drives hot-reload + * rebuilds of a single file in the editor. + */ +UCLASS() +class USHADERLAB_API UShaderLabSubsystem : public UEngineSubsystem +{ + GENERATED_BODY() + +public: + virtual void Initialize(FSubsystemCollectionBase& Collection) override; + virtual void Deinitialize() override; + + /** Scan every search root and (re)register all discovered shaders. */ + void DiscoverAndRegisterAll(); + + /** Parse a single `.usl` file and (re)register its material. Returns null on parse error. */ + UMaterial* RebuildFromFile(const FString& FilePath); + +private: + bool bScanned = false; + FDelegateHandle PostEngineInitHandle; +}; diff --git a/Source/UShaderLab/UShaderLab.Build.cs b/Source/UShaderLab/UShaderLab.Build.cs new file mode 100644 index 0000000..7b43237 --- /dev/null +++ b/Source/UShaderLab/UShaderLab.Build.cs @@ -0,0 +1,33 @@ +// Copyright FlecsProj. All Rights Reserved. + +using UnrealBuildTool; + +public class UShaderLab : ModuleRules +{ + public UShaderLab(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + bUseUnity = false; + + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "Engine", + }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + "RenderCore", + "RHI", + "Projects", + }); + + // Stage the .usl sources into packaged builds so the cooked runtime can rebuild base-material + // shells by re-parsing them (no serialized side data, no cook-time hook). A wildcard works here + // because .usl are source files that exist at build time (when RuntimeDependencies are globbed), + // unlike a cook-generated file. "..." matches recursively, including the top level. + RuntimeDependencies.Add("$(ProjectDir)/Shaders/.../*.usl", StagedFileType.UFS); + } +} diff --git a/Source/UShaderLabEditor/Private/MaterialExpressionShaderLabParameterAnchor.cpp b/Source/UShaderLabEditor/Private/MaterialExpressionShaderLabParameterAnchor.cpp new file mode 100644 index 0000000..908c200 --- /dev/null +++ b/Source/UShaderLabEditor/Private/MaterialExpressionShaderLabParameterAnchor.cpp @@ -0,0 +1,57 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "MaterialExpressionShaderLabParameterAnchor.h" + +#include "MaterialCompiler.h" + +UMaterialExpressionShaderLabParameterAnchor::UMaterialExpressionShaderLabParameterAnchor(const FObjectInitializer& ObjectInitializer) + : Super(ObjectInitializer) +{ +} + +#if WITH_EDITOR +TArrayView UMaterialExpressionShaderLabParameterAnchor::GetInputsView() +{ + CachedInputs.Reset(Inputs.Num()); + for (FExpressionInput& Input : Inputs) + { + CachedInputs.Add(&Input); + } + return CachedInputs; +} + +FExpressionInput* UMaterialExpressionShaderLabParameterAnchor::GetInput(int32 InputIndex) +{ + return Inputs.IsValidIndex(InputIndex) ? &Inputs[InputIndex] : nullptr; +} + +FName UMaterialExpressionShaderLabParameterAnchor::GetInputName(int32 InputIndex) const +{ + return FName(*FString::Printf(TEXT("Param%d"), InputIndex)); +} + +int32 UMaterialExpressionShaderLabParameterAnchor::Compile(FMaterialCompiler* Compiler, int32 OutputIndex) +{ + // Compiled in the before-attributes pass. Compiling each input forces its static-switch selector + // (and the selected `#define`-emitting Custom node) to compile here — emitting `#define 0/1` + // for the current permutation ahead of every body's `#if`. The returned value is unused. + int32 Last = INDEX_NONE; + for (FExpressionInput& Input : Inputs) + { + if (Input.GetTracedInput().Expression) + { + const int32 Code = Input.Compile(Compiler); + if (Code != INDEX_NONE) + { + Last = Code; + } + } + } + return Last != INDEX_NONE ? Last : Compiler->Constant(0.0f); +} + +void UMaterialExpressionShaderLabParameterAnchor::GetCaption(TArray& OutCaptions) const +{ + OutCaptions.Add(TEXT("ShaderLab Static-Switch Defines / Parameter Anchor")); +} +#endif // WITH_EDITOR diff --git a/Source/UShaderLabEditor/Private/MaterialExpressionShaderLabParameterAnchor.h b/Source/UShaderLabEditor/Private/MaterialExpressionShaderLabParameterAnchor.h new file mode 100644 index 0000000..af5d666 --- /dev/null +++ b/Source/UShaderLabEditor/Private/MaterialExpressionShaderLabParameterAnchor.h @@ -0,0 +1,63 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "MaterialValueType.h" +#include "Materials/MaterialExpressionCustomOutput.h" +#include "MaterialExpressionShaderLabParameterAnchor.generated.h" + +/** + * Internal ShaderLab node with two jobs, both keeping workaround machinery off the user's body nodes: + * + * 1. Anchors parameters so the Material Instance editor lists them. StaticBool parameters are consumed + * only inside HLSL `#if`, so they would otherwise be unconnected; the instance editor's visibility + * walk only reaches parameters connected to a material output or a CustomOutput. This is a + * CustomOutput, so it is always traversed; each anchored parameter reaches one of its inputs (via + * its static-switch selector). + * + * 2. Injects per-permutation static-switch `#define`s with ZERO engine modifications. Each input is a + * StaticSwitch over two `#define 1` / `#define 0` Custom nodes. This node returns + * ShouldCompileBeforeAttributes()==true, so FHLSLMaterialTranslator compiles it BEFORE the material + * attributes (FrontMaterial / body). Its Compile() compiles each input, so the selected `#define` + * (only the taken StaticSwitch branch is compiled) is emitted ahead of every body's `#if`. The + * `#define` leaks forward (preprocessor is global) and the body's `#if ` sees the current + * permutation's value — including a Material Instance's static override. + * + * It only ever lives in the editor graph (never serialized into a cooked package). + */ +UCLASS(MinimalAPI, collapsecategories, hidecategories = Object) +class UMaterialExpressionShaderLabParameterAnchor : public UMaterialExpressionCustomOutput +{ + GENERATED_UCLASS_BODY() + + /** One input per static-switch selector (a StaticSwitch over two `#define`-emitting Custom nodes). */ + UPROPERTY() + TArray Inputs; + + //~ Begin UMaterialExpressionCustomOutput Interface + // One output so the translator actually compiles us (a zero-output CustomOutput is skipped). The + // output value is unused; compiling us is purely to emit our inputs' `#define`s before attributes. + virtual int32 GetNumOutputs() const override { return 1; } + virtual FString GetFunctionName() const override { return TEXT("ShaderLabParameterAnchor"); } +#if WITH_EDITOR + virtual bool NeedsCustomOutputDefines() override { return false; } // don't emit NUM_MATERIAL_OUTPUTS_* + virtual bool ShouldCompileBeforeAttributes() override { return true; } // emit #defines ahead of body #if +#endif + //~ End UMaterialExpressionCustomOutput Interface + +#if WITH_EDITOR + //~ Begin UMaterialExpression Interface + virtual TArrayView GetInputsView() override; + virtual FExpressionInput* GetInput(int32 InputIndex) override; + virtual FName GetInputName(int32 InputIndex) const override; + virtual EMaterialValueType GetInputValueType(int32 InputIndex) override { return MCT_Float1; } + virtual int32 Compile(class FMaterialCompiler* Compiler, int32 OutputIndex) override; + virtual void GetCaption(TArray& OutCaptions) const override; + //~ End UMaterialExpression Interface +#endif + +private: + /** Scratch pointer view rebuilt by GetInputsView(). */ + TArray CachedInputs; +}; diff --git a/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp b/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp new file mode 100644 index 0000000..7175bce --- /dev/null +++ b/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp @@ -0,0 +1,168 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "DirectoryWatcherModule.h" +#include "Engine/Engine.h" +#include "IDirectoryWatcher.h" +#include "Interfaces/IPluginManager.h" +#include "Materials/Material.h" +#include "Misc/Paths.h" +#include "Modules/ModuleManager.h" +#include "ShaderCore.h" +#include "ShaderLabDiscovery.h" +#include "ShaderLabGraphBuilder.h" +#include "ShaderLabMaterialRegistry.h" +#include "ShaderLabModel.h" +#include "ShaderLabSubsystem.h" +#include "UObject/Package.h" + +DEFINE_LOG_CATEGORY_STATIC(LogShaderLabEditor, Log, All); + +namespace +{ + /** Editor-side reaction to a base material needing build: rebuild graph + trigger compile. */ + void BuildAndCompile(UMaterial& Material, const FShaderLabModel& Model) + { + TArray Errors; + if (!FShaderLabGraphBuilder::BuildInto(Material, Model, Errors)) + { + for (const FString& E : Errors) + { + UE_LOG(LogShaderLabEditor, Error, TEXT("ShaderLab build '%s': %s"), *Model.ShaderName, *E); + } + return; + } + // Recompile for rendering / editor preview (async). + Material.PostEditChange(); + } +} + +class FShaderLabEditorModule : public IModuleInterface +{ +public: + virtual void StartupModule() override + { + // Map the plugin's Shaders/ directory so generated Custom nodes can #include + // "/Plugin/ShaderLab/Private/ShaderLabCommon.ush" at shader-compile time. + if (const TSharedPtr Plugin = IPluginManager::Get().FindPlugin(TEXT("UShaderLab"))) + { + const FString ShaderDir = FPaths::Combine(Plugin->GetBaseDir(), TEXT("Shaders")); + if (!AllShaderSourceDirectoryMappings().Contains(TEXT("/Plugin/ShaderLab"))) + { + AddShaderSourceDirectoryMapping(TEXT("/Plugin/ShaderLab"), ShaderDir); + } + } + + // Map the project's Shaders/ directory to the virtual root "/Project" so user .ush library + // files placed there (alongside .usl) can be #included from a shader's Includes { } block. + { + const FString ProjectShaderDir = FPaths::Combine(FPaths::ProjectDir(), TEXT("Shaders")); + if (FPaths::DirectoryExists(ProjectShaderDir) && !AllShaderSourceDirectoryMappings().Contains(TEXT("/Project"))) + { + AddShaderSourceDirectoryMapping(TEXT("/Project"), ProjectShaderDir); + } + } + + // Fill in / recompile base materials whenever the registry asks (startup + hot reload). + BuildHandle = FShaderLabMaterialRegistry::Get().OnBuildMaterial().AddStatic(&BuildAndCompile); + + StartWatchingSources(); + + // Note: ShaderLab base materials are deliberately NOT surfaced to the Content Browser — they are + // hidden templates. Users create UShaderLabMaterialInstanceConstant instances via the factory + // (Content Browser ▸ Material ▸ ShaderLab Material Instance), which picks a base directly from + // the registry. The instances are the only assets users interact with. + // + // There is NO cook-time hook: the .usl sources are staged into packaged builds (a wildcard + // RuntimeDependency in ShaderLab.Build.cs) and the cooked runtime re-parses them directly, so + // nothing special needs to be written at cook. + + UE_LOG(LogShaderLabEditor, Log, TEXT("ShaderLabEditor module started.")); + } + + virtual void ShutdownModule() override + { + StopWatchingSources(); + if (BuildHandle.IsValid()) + { + FShaderLabMaterialRegistry::Get().OnBuildMaterial().Remove(BuildHandle); + BuildHandle.Reset(); + } + } + +private: + void StartWatchingSources() + { + FDirectoryWatcherModule& Module = + FModuleManager::LoadModuleChecked(TEXT("DirectoryWatcher")); + IDirectoryWatcher* Watcher = Module.Get(); + if (!Watcher) + { + return; + } + for (const FString& Root : FShaderLabDiscovery::GetSearchRoots()) + { + if (!FPaths::DirectoryExists(Root)) + { + continue; + } + FDelegateHandle Handle; + Watcher->RegisterDirectoryChangedCallback_Handle( + Root, + IDirectoryWatcher::FDirectoryChanged::CreateStatic(&FShaderLabEditorModule::OnDirectoryChanged), + Handle, + IDirectoryWatcher::WatchOptions::IncludeDirectoryChanges); + WatchedRoots.Add(Root, Handle); + } + } + + void StopWatchingSources() + { + if (FModuleManager::Get().IsModuleLoaded(TEXT("DirectoryWatcher"))) + { + FDirectoryWatcherModule& Module = + FModuleManager::GetModuleChecked(TEXT("DirectoryWatcher")); + if (IDirectoryWatcher* Watcher = Module.Get()) + { + for (const TPair& Pair : WatchedRoots) + { + Watcher->UnregisterDirectoryChangedCallback_Handle(Pair.Key, Pair.Value); + } + } + } + WatchedRoots.Reset(); + } + + static void OnDirectoryChanged(const TArray& Changes) + { + UShaderLabSubsystem* Subsystem = GEngine ? GEngine->GetEngineSubsystem() : nullptr; + if (!Subsystem) + { + return; + } + TSet Rebuilt; + for (const FFileChangeData& Change : Changes) + { + if (!Change.Filename.EndsWith(TEXT(".usl"))) + { + continue; + } + if (Change.Action == FFileChangeData::FCA_Removed) + { + continue; // Leave the existing in-memory material in place on delete. + } + const FString Normalized = FPaths::ConvertRelativePathToFull(Change.Filename); + if (Rebuilt.Contains(Normalized)) + { + continue; + } + Rebuilt.Add(Normalized); + Subsystem->RebuildFromFile(Normalized); + UE_LOG(LogShaderLabEditor, Log, TEXT("ShaderLab hot-reloaded '%s'"), *Normalized); + } + } + + FDelegateHandle BuildHandle; + TMap WatchedRoots; +}; + +IMPLEMENT_MODULE(FShaderLabEditorModule, UShaderLabEditor) diff --git a/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp b/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp new file mode 100644 index 0000000..8cef48b --- /dev/null +++ b/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp @@ -0,0 +1,1111 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabGraphBuilder.h" + +#include "ShaderLabModel.h" + +#include "MaterialDomain.h" +#include "Engine/EngineTypes.h" +#include "Misc/Paths.h" +#include "Engine/Texture.h" +#include "Engine/Texture2D.h" +#include "Materials/Material.h" +#include "Materials/MaterialExpressionConstant.h" +#include "Materials/MaterialExpressionCustom.h" +#include "Materials/MaterialExpressionScalarParameter.h" +#include "Materials/MaterialExpressionStaticBoolParameter.h" +#include "Materials/MaterialExpressionStaticSwitch.h" +#include "Materials/MaterialExpressionSubstrate.h" +#include "Materials/MaterialExpressionTextureObjectParameter.h" +#include "Materials/MaterialExpressionVectorParameter.h" +#include "MaterialExpressionShaderLabParameterAnchor.h" +#include "ShaderLabIntrinsicRegistry.h" +#include "ShaderLabRuntimeBuilder.h" +#include "ShaderLabSettingsApplier.h" +#include "UObject/UObjectGlobals.h" + +#define SHADERLAB_COMMON_INCLUDE TEXT("/Plugin/ShaderLab/Private/ShaderLabCommon.ush") + +namespace ShaderLabGraph +{ + // --- Surface fields that map to Substrate Slab pins (in deterministic order). --- + struct FSlabFieldDef + { + const TCHAR* Field; + ECustomMaterialOutputType OutType; + }; + + static const FSlabFieldDef GSlabFields[] = { + { TEXT("DiffuseAlbedo"), CMOT_Float3 }, + { TEXT("F0"), CMOT_Float3 }, + { TEXT("F90"), CMOT_Float3 }, + { TEXT("Roughness"), CMOT_Float1 }, + { TEXT("Anisotropy"), CMOT_Float1 }, + { TEXT("Normal"), CMOT_Float3 }, + { TEXT("Tangent"), CMOT_Float3 }, + { TEXT("SSSMFP"), CMOT_Float3 }, + { TEXT("SSSMFPScale"), CMOT_Float1 }, + { TEXT("SSSPhaseAnisotropy"), CMOT_Float1 }, + { TEXT("EmissiveColor"), CMOT_Float3 }, + { TEXT("SecondRoughness"), CMOT_Float1 }, + { TEXT("SecondRoughnessWeight"), CMOT_Float1 }, + { TEXT("FuzzRoughness"), CMOT_Float1 }, + { TEXT("FuzzAmount"), CMOT_Float1 }, + { TEXT("FuzzColor"), CMOT_Float3 }, + { TEXT("GlintValue"), CMOT_Float1 }, + { TEXT("GlintUV"), CMOT_Float2 }, + }; + + static FExpressionInput* GetSlabPin(UMaterialExpressionSubstrateSlabBSDF* Slab, const FString& Field) + { + if (Field == TEXT("DiffuseAlbedo")) return &Slab->DiffuseAlbedo; + if (Field == TEXT("F0")) return &Slab->F0; + if (Field == TEXT("F90")) return &Slab->F90; + if (Field == TEXT("Roughness")) return &Slab->Roughness; + if (Field == TEXT("Anisotropy")) return &Slab->Anisotropy; + if (Field == TEXT("Normal")) return &Slab->Normal; + if (Field == TEXT("Tangent")) return &Slab->Tangent; + if (Field == TEXT("SSSMFP")) return &Slab->SSSMFP; + if (Field == TEXT("SSSMFPScale")) return &Slab->SSSMFPScale; + if (Field == TEXT("SSSPhaseAnisotropy")) return &Slab->SSSPhaseAnisotropy; + if (Field == TEXT("EmissiveColor")) return &Slab->EmissiveColor; + if (Field == TEXT("SecondRoughness")) return &Slab->SecondRoughness; + if (Field == TEXT("SecondRoughnessWeight")) return &Slab->SecondRoughnessWeight; + if (Field == TEXT("FuzzRoughness")) return &Slab->FuzzRoughness; + if (Field == TEXT("FuzzAmount")) return &Slab->FuzzAmount; + if (Field == TEXT("FuzzColor")) return &Slab->FuzzColor; + if (Field == TEXT("GlintValue")) return &Slab->GlintValue; + if (Field == TEXT("GlintUV")) return &Slab->GlintUV; + return nullptr; + } + + // --- Vertex-stage output fields. --- + struct FVertexFieldDef + { + const TCHAR* Field; + ECustomMaterialOutputType OutType; + }; + static const FVertexFieldDef GVertexFields[] = { + { TEXT("WorldPositionOffset"), CMOT_Float3 }, + { TEXT("Displacement"), CMOT_Float1 }, + { TEXT("CustomizedUV0"), CMOT_Float2 }, + { TEXT("CustomizedUV1"), CMOT_Float2 }, + { TEXT("CustomizedUV2"), CMOT_Float2 }, + { TEXT("CustomizedUV3"), CMOT_Float2 }, + }; + + /** Absolute, forward-slashed path for use inside an HLSL `#line N "path"` directive. */ + static FString MakeLineDirectivePath(const FString& SourceFilePath) + { + FString Full = FPaths::ConvertRelativePathToFull(SourceFilePath); + Full.ReplaceInline(TEXT("\\"), TEXT("/")); + return Full; + } + + /** + * Wrap a user HLSL body so shader-compiler errors map back to the .usl source: a `#line` + * directive sets the file+line to the body's origin, and a trailing directive points past it to a + * sentinel so errors in our generated epilogue are not mis-attributed to the user's file. + */ + static FString WrapBodyWithLineMapping(const FString& Body, int32 BodyLine, const FString& SrcPath) + { + // BodyLine is the source line of the char right after '{' (usually the newline ending that + // line); the body's real content starts on the next line. Empirically the compiler reports + // content one line high relative to `#line BodyLine`, so map with BodyLine-1. + const int32 MappedLine = FMath::Max(BodyLine - 1, 1); + return FString::Printf(TEXT("#line %d \"%s\"\n%s\n#line 1 \"ShaderLabGenerated.ush\"\n"), + MappedLine, *SrcPath, *Body); + } + + /** True if `Token` appears in `Body` delimited by non-identifier characters. */ + static bool ReferencesToken(const FString& Body, const FString& Token) + { + auto IsIdent = [](TCHAR C) { return FChar::IsAlnum(C) || C == TEXT('_'); }; + int32 From = 0; + while (true) + { + const int32 Idx = Body.Find(Token, ESearchCase::CaseSensitive, ESearchDir::FromStart, From); + if (Idx == INDEX_NONE) + { + return false; + } + const TCHAR Before = (Idx > 0) ? Body[Idx - 1] : TEXT(' '); + const int32 AfterIdx = Idx + Token.Len(); + const TCHAR After = (AfterIdx < Body.Len()) ? Body[AfterIdx] : TEXT(' '); + if (!IsIdent(Before) && !IsIdent(After)) + { + return true; + } + From = Idx + Token.Len(); + } + } + + static UTexture* ResolveDefaultTexture(const FString& Token, bool& bOutIsNormal) + { + bOutIsNormal = (Token == TEXT("normal")); + const TCHAR* Path = nullptr; + if (Token == TEXT("white")) { Path = TEXT("/Engine/EngineResources/WhiteSquareTexture.WhiteSquareTexture"); } + else if (Token == TEXT("black")) { Path = TEXT("/Engine/EngineResources/Black.Black"); } + else if (Token == TEXT("grey") || Token == TEXT("gray")) { Path = TEXT("/Engine/EngineResources/GreyTexture.GreyTexture"); } + else if (Token == TEXT("normal")) { Path = TEXT("/Engine/EngineMaterials/DefaultNormal.DefaultNormal"); } + + UTexture* Tex = nullptr; + if (Path) + { + Tex = LoadObject(nullptr, Path); + } + else if (!Token.IsEmpty()) + { + Tex = LoadObject(nullptr, *Token); + } + if (!Tex) + { + Tex = LoadObject(nullptr, TEXT("/Engine/EngineResources/WhiteSquareTexture.WhiteSquareTexture")); + } + return Tex; + } + + static EMaterialDomain MapDomain(EShaderLabDomain D) + { + switch (D) + { + case EShaderLabDomain::PostProcess: return MD_PostProcess; + case EShaderLabDomain::UI: return MD_UI; + case EShaderLabDomain::Decal: return MD_DeferredDecal; + case EShaderLabDomain::Surface: + default: return MD_Surface; + } + } + + static EBlendMode MapBlend(EShaderLabBlendMode B) + { + switch (B) + { + case EShaderLabBlendMode::Masked: return BLEND_Masked; + case EShaderLabBlendMode::Translucent: return BLEND_Translucent; + case EShaderLabBlendMode::Additive: return BLEND_Additive; + case EShaderLabBlendMode::Modulate: return BLEND_Modulate; + case EShaderLabBlendMode::Opaque: + default: return BLEND_Opaque; + } + } + + template + static T* NewExpr(UMaterial& Material, int32& IoY, int32 Column) + { + T* Expr = NewObject(&Material); + Material.GetExpressionCollection().AddExpression(Expr); + Expr->MaterialExpressionEditorX = Column; + Expr->MaterialExpressionEditorY = IoY; + IoY += 120; + return Expr; + } + + /** One intrinsic call resolved to a Custom-node input wired from an engine expression node. */ + struct FIntrinsicWire + { + FName InputName; + UMaterialExpression* Expr = nullptr; + int32 OutputIndex = 0; + }; + + /** Split a call's argument text into trimmed, top-level comma-separated literals. */ + static TArray SplitArgs(const FString& ArgsRaw) + { + TArray Out; + if (ArgsRaw.TrimStartAndEnd().IsEmpty()) + { + return Out; + } + ArgsRaw.ParseIntoArray(Out, TEXT(","), /*CullEmpty*/ false); + for (FString& A : Out) + { + A.TrimStartAndEndInline(); + } + return Out; + } + + /** A stable, identifier-safe suffix encoding a call's literal args (e.g. "0, 2.0" -> "0_2_0"). */ + static FString MakeArgSig(const FString& ArgsRaw) + { + FString Sig; + for (const TCHAR C : ArgsRaw) + { + if (FChar::IsAlnum(C)) { Sig.AppendChar(C); } + else if (!FChar::IsWhitespace(C)) { Sig.AppendChar(TEXT('_')); } + } + return Sig; + } + + /** + * Scan a body for `UE::Name(args)` intrinsic calls, create the backing expression node for each + * unique (name,args), collect the resulting Custom-node inputs, and rewrite the body so each call + * becomes its input variable — space-padded to the original call's length so line/column layout is + * preserved (keeps `#line` compile-error mapping accurate). Returns false (and fills OutErrors) on + * an unknown intrinsic, a stage/usage violation, or a bad argument. + */ + static bool EmitIntrinsics( + UMaterial& Material, + EShaderLabIntrinsicFrequency Stage, + FString& InOutBody, + TArray& OutWires, + TArray& OutErrors) + { + const FShaderLabIntrinsicRegistry& Registry = FShaderLabIntrinsicRegistry::Get(); + const FString& Body = InOutBody; + const int32 Len = Body.Len(); + + FString Result; + Result.Reserve(Len); + TMap InputByKey; // (Name + argsig) -> already-created input name (dedup) + bool bOk = true; + + auto IsIdent = [](TCHAR C) { return FChar::IsAlnum(C) || C == TEXT('_'); }; + + int32 i = 0; + while (i < Len) + { + const bool bBoundary = (i == 0) || !IsIdent(Body[i - 1]); + if (bBoundary && i + 4 <= Len && + Body[i] == TEXT('U') && Body[i + 1] == TEXT('E') && Body[i + 2] == TEXT(':') && Body[i + 3] == TEXT(':')) + { + int32 j = i + 4; + while (j < Len && IsIdent(Body[j])) { ++j; } + const FString Name = Body.Mid(i + 4, j - (i + 4)); + int32 k = j; + while (k < Len && FChar::IsWhitespace(Body[k])) { ++k; } + if (!Name.IsEmpty() && k < Len && Body[k] == TEXT('(')) + { + // Read balanced (...) for the argument list. + int32 Depth = 0; + int32 m = k; + for (; m < Len; ++m) + { + if (Body[m] == TEXT('(')) { ++Depth; } + else if (Body[m] == TEXT(')')) { if (--Depth == 0) { break; } } + } + if (m < Len) + { + const FString ArgsRaw = Body.Mid(k + 1, m - (k + 1)); + const int32 CallLen = (m + 1) - i; + const FString ArgSig = MakeArgSig(ArgsRaw); + const FString Key = Name + TEXT("|") + ArgSig; + + FName InputName; + if (const FName* Existing = InputByKey.Find(Key)) + { + InputName = *Existing; + } + else + { + const FShaderLabIntrinsicDesc* Desc = Registry.Find(FName(*Name)); + if (!Desc) + { + OutErrors.Add(FString::Printf(TEXT("Unknown intrinsic 'UE::%s'"), *Name)); + bOk = false; + } + else if (Desc->Frequency == EShaderLabIntrinsicFrequency::PixelOnly && Stage == EShaderLabIntrinsicFrequency::VertexOnly) + { + OutErrors.Add(FString::Printf(TEXT("Intrinsic 'UE::%s' is pixel-only and cannot be used in a Vertex body"), *Name)); + bOk = false; + } + else if (Desc->Frequency == EShaderLabIntrinsicFrequency::VertexOnly && Stage == EShaderLabIntrinsicFrequency::PixelOnly) + { + OutErrors.Add(FString::Printf(TEXT("Intrinsic 'UE::%s' is vertex-only and cannot be used in a pixel body"), *Name)); + bOk = false; + } + else + { + // Usage is per-instance, so intrinsics emit their node with no base-level usage gate. + FString MakeError; + UMaterialExpression* Expr = Desc->MakeNode(Material, SplitArgs(ArgsRaw), MakeError); + if (!Expr) + { + OutErrors.Add(FString::Printf(TEXT("Intrinsic 'UE::%s': %s"), *Name, + MakeError.IsEmpty() ? TEXT("failed to create node") : *MakeError)); + bOk = false; + } + else + { + InputName = FName(*(FString(TEXT("SLI_")) + Name + (ArgSig.IsEmpty() ? TEXT("") : (FString(TEXT("_")) + ArgSig)))); + InputByKey.Add(Key, InputName); + OutWires.Add(FIntrinsicWire{ InputName, Expr, Desc->OutputIndex }); + } + } + } + + // Substitute the call with its input variable, space-padded to keep columns stable. + FString Replacement = InputName.IsNone() ? FString() : InputName.ToString(); + while (Replacement.Len() < CallLen) { Replacement.AppendChar(TEXT(' ')); } + Result += Replacement; + i = m + 1; + continue; + } + } + } + Result.AppendChar(Body[i]); + ++i; + } + + InOutBody = MoveTemp(Result); + return bOk; + } + + /** A created property parameter node (shared across all slabs/values that reference it). */ + struct FParamNode + { + UMaterialExpression* Expr = nullptr; + bool bIsTexture = false; + }; + + /** Add the shared struct include plus any user `Includes { }` paths to a generated Custom node. */ + static void AddIncludes(UMaterialExpressionCustom& Custom, const FShaderLabModel& Model) + { + Custom.IncludeFilePaths.Add(SHADERLAB_COMMON_INCLUDE); + for (const FString& Include : Model.Includes) + { + if (!Include.IsEmpty()) + { + Custom.IncludeFilePaths.AddUnique(Include); + } + } + } + + /** + * Emit the Custom node for a pixel-stage body that writes FShaderLabSurface fields and wire it into + * a fresh Substrate Slab BSDF. Returns the slab (nullptr only on error). When bAllowMaterialOutputs, + * S.Opacity / S.OpacityMask are wired to the material-level pins (single-Surface sugar path only). + */ + static UMaterialExpressionSubstrateSlabBSDF* BuildSlab( + UMaterial& Material, UMaterialEditorOnlyData& EditorOnly, const FString& OutParamName, + const FString& InBody, int32 BodyLine, const FShaderLabModel& Model, + const TMap& PropertyNodes, + bool bAllowMaterialOutputs, int32& IoY, TArray& OutErrors) + { + UMaterialExpressionSubstrateSlabBSDF* Slab = NewExpr(Material, IoY, 0); + + TArray UsedSlab; + for (const FSlabFieldDef& F : GSlabFields) + { + if (ReferencesToken(InBody, OutParamName + TEXT(".") + F.Field)) + { + UsedSlab.Add(&F); + } + } + const bool bUsesOpacity = bAllowMaterialOutputs && ReferencesToken(InBody, OutParamName + TEXT(".Opacity")); + const bool bUsesOpacityMask = bAllowMaterialOutputs && ReferencesToken(InBody, OutParamName + TEXT(".OpacityMask")); + + if (UsedSlab.Num() == 0 && !bUsesOpacity && !bUsesOpacityMask) + { + return Slab; // Empty body: a default Substrate slab. + } + + UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); + Custom->Description = TEXT("ShaderLab Surface"); + Custom->OutputType = CMOT_Float1; + AddIncludes(*Custom, Model); + + FString Body = InBody; + TArray Wires; + if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, Wires, OutErrors)) + { + return nullptr; + } + for (const FIntrinsicWire& Wire : Wires) + { + FCustomInput In; + In.InputName = Wire.InputName; + In.Input.Connect(Wire.OutputIndex, Wire.Expr); + Custom->Inputs.Add(In); + } + for (const FShaderLabProperty& Prop : Model.Properties) + { + if (Prop.Type == EShaderLabPropertyType::StaticBool || !ReferencesToken(InBody, Prop.Name.ToString())) + { + continue; + } + const FParamNode* Node = PropertyNodes.Find(Prop.Name); + if (Node && Node->Expr) + { + FCustomInput In; + In.InputName = Prop.Name; + In.Input.Connect(0, Node->Expr); + Custom->Inputs.Add(In); + } + } + + const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); + FString Code = FString::Printf(TEXT("FShaderLabSurface %s = ShaderLabDefaultSurface();\n{\n%s}\n"), + *OutParamName, *WrapBodyWithLineMapping(Body, BodyLine, SrcPath)); + + int32 OutputIndex = 1; // index 0 is the (unused) main return + TArray> SlabOutputs; + for (const FSlabFieldDef* F : UsedSlab) + { + FCustomOutput Out; + Out.OutputName = FName(*(FString(TEXT("SLO_")) + F->Field)); + Out.OutputType = F->OutType; + Custom->AdditionalOutputs.Add(Out); + Code += FString::Printf(TEXT("SLO_%s = %s.%s;\n"), F->Field, *OutParamName, F->Field); + SlabOutputs.Add(TPair(F, OutputIndex)); + ++OutputIndex; + } + int32 OpacityOutIdx = INDEX_NONE; + int32 OpacityMaskOutIdx = INDEX_NONE; + if (bUsesOpacity) + { + FCustomOutput Out; Out.OutputName = TEXT("SLO_Opacity"); Out.OutputType = CMOT_Float1; + Custom->AdditionalOutputs.Add(Out); + Code += FString::Printf(TEXT("SLO_Opacity = %s.Opacity;\n"), *OutParamName); + OpacityOutIdx = OutputIndex++; + } + if (bUsesOpacityMask) + { + FCustomOutput Out; Out.OutputName = TEXT("SLO_OpacityMask"); Out.OutputType = CMOT_Float1; + Custom->AdditionalOutputs.Add(Out); + Code += FString::Printf(TEXT("SLO_OpacityMask = %s.OpacityMask;\n"), *OutParamName); + OpacityMaskOutIdx = OutputIndex++; + } + Code += TEXT("return 0.0f;\n"); + Custom->Code = Code; + Custom->RebuildOutputs(); + + for (const TPair& Pair : SlabOutputs) + { + if (FExpressionInput* Pin = GetSlabPin(Slab, Pair.Key->Field)) + { + Pin->Connect(Pair.Value, Custom); + } + } + if (OpacityOutIdx != INDEX_NONE) { EditorOnly.Opacity.Connect(OpacityOutIdx, Custom); } + if (OpacityMaskOutIdx != INDEX_NONE) { EditorOnly.OpacityMask.Connect(OpacityMaskOutIdx, Custom); } + return Slab; + } + + /** + * Build the Custom node for a PostProcess/UI entry (Domain = PostProcess/UI). The output struct has + * Color (-> material EmissiveColor) and Opacity (-> material Opacity); there is no Substrate slab. + */ + static bool BuildEmissiveEntry( + UMaterial& Material, UMaterialEditorOnlyData& EditorOnly, const TCHAR* StructName, const TCHAR* DefaultFn, + const FString& OutParamName, const FString& InBody, int32 BodyLine, const FShaderLabModel& Model, + const TMap& PropertyNodes, + int32& IoY, TArray& OutErrors) + { + UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); + Custom->Description = TEXT("ShaderLab Emissive Entry"); + Custom->OutputType = CMOT_Float1; + AddIncludes(*Custom, Model); + + FString Body = InBody; + TArray Wires; + if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, Wires, OutErrors)) + { + return false; + } + for (const FIntrinsicWire& Wire : Wires) + { + FCustomInput In; + In.InputName = Wire.InputName; + In.Input.Connect(Wire.OutputIndex, Wire.Expr); + Custom->Inputs.Add(In); + } + for (const FShaderLabProperty& Prop : Model.Properties) + { + if (Prop.Type == EShaderLabPropertyType::StaticBool || !ReferencesToken(InBody, Prop.Name.ToString())) + { + continue; + } + const FParamNode* Node = PropertyNodes.Find(Prop.Name); + if (Node && Node->Expr) + { + FCustomInput In; + In.InputName = Prop.Name; + In.Input.Connect(0, Node->Expr); + Custom->Inputs.Add(In); + } + } + + const bool bUsesColor = ReferencesToken(InBody, OutParamName + TEXT(".Color")); + const bool bUsesOpacity = ReferencesToken(InBody, OutParamName + TEXT(".Opacity")); + + const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); + FString Code = FString::Printf(TEXT("%s %s = %s();\n{\n%s}\n"), + StructName, *OutParamName, DefaultFn, *WrapBodyWithLineMapping(Body, BodyLine, SrcPath)); + + int32 OutputIndex = 1; + int32 ColorOutIdx = INDEX_NONE; + int32 OpacityOutIdx = INDEX_NONE; + if (bUsesColor) + { + FCustomOutput Out; Out.OutputName = TEXT("SLO_Color"); Out.OutputType = CMOT_Float3; + Custom->AdditionalOutputs.Add(Out); + Code += FString::Printf(TEXT("SLO_Color = %s.Color;\n"), *OutParamName); + ColorOutIdx = OutputIndex++; + } + if (bUsesOpacity) + { + FCustomOutput Out; Out.OutputName = TEXT("SLO_Opacity"); Out.OutputType = CMOT_Float1; + Custom->AdditionalOutputs.Add(Out); + Code += FString::Printf(TEXT("SLO_Opacity = %s.Opacity;\n"), *OutParamName); + OpacityOutIdx = OutputIndex++; + } + Code += TEXT("return 0.0f;\n"); + Custom->Code = Code; + Custom->RebuildOutputs(); + + if (ColorOutIdx != INDEX_NONE) { EditorOnly.EmissiveColor.Connect(ColorOutIdx, Custom); } + if (OpacityOutIdx != INDEX_NONE) { EditorOnly.Opacity.Connect(OpacityOutIdx, Custom); } + return true; + } + + /** Build a Custom node whose return value is the scalar Value-block body. Output 0 is the scalar. */ + static UMaterialExpressionCustom* BuildValueNode( + UMaterial& Material, const FShaderLabValue& Value, const FShaderLabModel& Model, + const TMap& PropertyNodes, + int32& IoY, TArray& OutErrors) + { + UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); + Custom->Description = FString::Printf(TEXT("ShaderLab Value %s"), *Value.Name.ToString()); + Custom->OutputType = CMOT_Float1; + AddIncludes(*Custom, Model); + + FString Body = Value.Body; + TArray Wires; + if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, Wires, OutErrors)) + { + return nullptr; + } + for (const FIntrinsicWire& Wire : Wires) + { + FCustomInput In; + In.InputName = Wire.InputName; + In.Input.Connect(Wire.OutputIndex, Wire.Expr); + Custom->Inputs.Add(In); + } + for (const FShaderLabProperty& Prop : Model.Properties) + { + if (Prop.Type == EShaderLabPropertyType::StaticBool || !ReferencesToken(Value.Body, Prop.Name.ToString())) + { + continue; + } + const FParamNode* Node = PropertyNodes.Find(Prop.Name); + if (Node && Node->Expr) + { + FCustomInput In; + In.InputName = Prop.Name; + In.Input.Connect(0, Node->Expr); + Custom->Inputs.Add(In); + } + } + + // The body itself contains `return ;`, so it is the Custom function's body directly. + const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); + Custom->Code = WrapBodyWithLineMapping(Body, Value.BodyLine, SrcPath); + Custom->RebuildOutputs(); + return Custom; + } + + /** Connect a topology mix factor (literal / Value block / Scalar property) to an operator scalar pin. */ + static bool ConnectFactor( + UMaterial& Material, FExpressionInput& Target, const FShaderLabFactor& Factor, + const TMap& ValueByName, + const TMap& PropertyNodes, int32& IoY, TArray& OutErrors) + { + if (Factor.Kind == FShaderLabFactor::EKind::Literal) + { + UMaterialExpressionConstant* Const = NewExpr(Material, IoY, -300); + Const->R = Factor.Literal; + Target.Connect(0, Const); + return true; + } + if (UMaterialExpressionCustom* const* ValueNode = ValueByName.Find(Factor.Name)) + { + Target.Connect(0, *ValueNode); + return true; + } + if (const FParamNode* Node = PropertyNodes.Find(Factor.Name)) + { + if (Node->Expr && !Node->bIsTexture) + { + Target.Connect(0, Node->Expr); + return true; + } + } + OutErrors.Add(FString::Printf( + TEXT("Topology factor '%s' is neither a Value block nor a Scalar property"), *Factor.Name.ToString())); + return false; + } + + /** Recursively build the Substrate expression for topology node `Index`. Returns nullptr on error. */ + static UMaterialExpression* BuildTopologyNode( + UMaterial& Material, int32 Index, const FShaderLabModel& Model, + const TMap& SlabByName, + const TMap& ValueByName, + const TMap& PropertyNodes, int32& IoY, TArray& OutErrors) + { + if (!Model.Topology.IsValidIndex(Index)) + { + OutErrors.Add(TEXT("Invalid topology node index")); + return nullptr; + } + const FShaderLabTopoNode& Node = Model.Topology[Index]; + + if (Node.Op == EShaderLabOp::SlabRef) + { + if (UMaterialExpressionSubstrateSlabBSDF* const* Found = SlabByName.Find(Node.SlabRef)) + { + return *Found; + } + OutErrors.Add(FString::Printf(TEXT("FrontMaterial references unknown Slab '%s'"), *Node.SlabRef.ToString())); + return nullptr; + } + + UMaterialExpression* ChildA = BuildTopologyNode(Material, Node.ChildA, Model, SlabByName, ValueByName, PropertyNodes, IoY, OutErrors); + UMaterialExpression* ChildB = (Node.ChildB != INDEX_NONE) + ? BuildTopologyNode(Material, Node.ChildB, Model, SlabByName, ValueByName, PropertyNodes, IoY, OutErrors) + : nullptr; + if (!ChildA || (Node.ChildB != INDEX_NONE && !ChildB)) + { + return nullptr; + } + + switch (Node.Op) + { + case EShaderLabOp::VerticalLayer: + { + UMaterialExpressionSubstrateVerticalLayering* N = NewExpr(Material, IoY, -150); + N->Top.Connect(0, ChildA); + N->Base.Connect(0, ChildB); + return ConnectFactor(Material, N->Thickness, Node.Factor, ValueByName, PropertyNodes, IoY, OutErrors) ? N : nullptr; + } + case EShaderLabOp::HorizontalMix: + { + UMaterialExpressionSubstrateHorizontalMixing* N = NewExpr(Material, IoY, -150); + N->Background.Connect(0, ChildA); + N->Foreground.Connect(0, ChildB); + return ConnectFactor(Material, N->Mix, Node.Factor, ValueByName, PropertyNodes, IoY, OutErrors) ? N : nullptr; + } + case EShaderLabOp::Add: + { + UMaterialExpressionSubstrateAdd* N = NewExpr(Material, IoY, -150); + N->A.Connect(0, ChildA); + N->B.Connect(0, ChildB); + return N; + } + case EShaderLabOp::Weight: + { + UMaterialExpressionSubstrateWeight* N = NewExpr(Material, IoY, -150); + N->A.Connect(0, ChildA); + return ConnectFactor(Material, N->Weight, Node.Factor, ValueByName, PropertyNodes, IoY, OutErrors) ? N : nullptr; + } + case EShaderLabOp::Select: + { + UMaterialExpressionSubstrateSelect* N = NewExpr(Material, IoY, -150); + N->A.Connect(0, ChildA); + N->B.Connect(0, ChildB); + return ConnectFactor(Material, N->SelectValue, Node.Factor, ValueByName, PropertyNodes, IoY, OutErrors) ? N : nullptr; + } + default: + OutErrors.Add(TEXT("Unhandled topology operator")); + return nullptr; + } + } +} + +bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabModel& Model, TArray& OutErrors) +{ + using namespace ShaderLabGraph; + + // Reset to a clean graph + apply material settings. + Material.AssignExpressionCollection(FMaterialExpressionCollection()); + Material.MaterialDomain = MapDomain(Model.Settings.Domain); + Material.BlendMode = MapBlend(Model.Settings.BlendMode); + Material.TwoSided = Model.Settings.bTwoSided ? 1 : 0; + Material.bUseMaterialAttributes = false; + + // Reflected long-tail settings (no usage: the base is a template; usage is set per-instance). + // Identical to the runtime shell (same model -> same FShaderLabSettingsApplier). Bad settings are + // a hard build failure (contract style). + if (!FShaderLabSettingsApplier::ApplyReflectedSettings(Material, Model.RawSettings, OutErrors)) + { + return false; + } + + UMaterialEditorOnlyData* EditorOnly = Material.GetEditorOnlyData(); + if (!EditorOnly) + { + OutErrors.Add(TEXT("Material has no editor-only data")); + return false; + } + + // Per-pixel context is read via UE::* intrinsics, so the Surface entry takes just the output + // struct: `Surface(inout FShaderLabSurface S)`. For multi-slab there is no Surface param. + const FShaderLabEntryParam* SurfaceOutParam = Model.bHasSurface && Model.SurfaceParams.Num() > 0 + ? &Model.SurfaceParams.Last() : nullptr; + if (Model.bHasSurface && !SurfaceOutParam) + { + OutErrors.Add(TEXT("Surface(...) must take an (inout FShaderLabSurface) parameter")); + return false; + } + + int32 ParamY = -400; + + // True if a property is referenced by any body (Surface / Slabs / Values / Vertex) OR used directly + // as a topology mix factor (e.g. `VerticalLayer(Coat, Base, Thickness)` with Thickness a Scalar). + auto IsPropertyReferenced = [&Model](const FName PropName, const FString& NameStr) -> bool + { + if (Model.bHasSurface && ReferencesToken(Model.SurfaceBody, NameStr)) { return true; } + for (const FShaderLabSlab& Slab : Model.Slabs) { if (ReferencesToken(Slab.Body, NameStr)) { return true; } } + for (const FShaderLabValue& Value : Model.Values) { if (ReferencesToken(Value.Body, NameStr)) { return true; } } + if (Model.bHasVertex && ReferencesToken(Model.VertexBody, NameStr)) { return true; } + for (const FShaderLabTopoNode& Node : Model.Topology) + { + if (Node.bHasFactor && Node.Factor.Kind == FShaderLabFactor::EKind::Named && Node.Factor.Name == PropName) + { + return true; + } + } + return false; + }; + + // 1) Create a parameter node per property referenced by any stage. + TMap PropertyNodes; + // Static-switch selectors funneled into the ParameterAnchor: each is a StaticSwitch over two + // `#define 1` / `#define 0` Custom nodes, driven by the switch parameter. The anchor + // is compiled before the material attributes and compiles these, so the selected per-permutation + // `#define` is emitted ahead of every body's `#if` — per-permutation static switches with zero + // engine changes, and nothing wired onto the user's body nodes. + TArray AnchorInputs; + + for (const FShaderLabProperty& Prop : Model.Properties) + { + const FString NameStr = Prop.Name.ToString(); + + if (Prop.Type == EShaderLabPropertyType::StaticBool) + { + if (IsPropertyReferenced(Prop.Name, NameStr)) + { + // Real static-switch parameter so Material Instances can override it (shown in the MIC editor). + // Reached for visibility via the selector below (which the anchor connects). + UMaterialExpressionStaticBoolParameter* E = NewExpr(Material, ParamY, -1000); + E->ParameterName = Prop.Name; + E->DefaultValue = Prop.bStaticBoolDefault ? 1 : 0; + E->Group = FName(*Prop.Group); + E->SortPriority = Prop.SortPriority; + + // Two trivial Custom nodes emit `#define Name 1` / `#define Name 0`; a StaticSwitch driven by + // the parameter selects one. The translator compiles ONLY the selected branch, so exactly one + // `#define` is produced per shader permutation (incl. the MIC's static override). + auto MakeDefiner = [&](bool bValue) -> UMaterialExpressionCustom* + { + UMaterialExpressionCustom* D = NewExpr(Material, ParamY, -1300); + D->Description = TEXT("ShaderLab StaticSwitch Define"); + D->OutputType = CMOT_Float1; + D->Code = TEXT("return 0;"); + FCustomDefine DD; + DD.DefineName = NameStr; + DD.DefineValue = bValue ? TEXT("1") : TEXT("0"); + D->AdditionalDefines.Add(DD); + return D; + }; + UMaterialExpressionStaticSwitch* Selector = NewExpr(Material, ParamY, -1150); + Selector->A.Connect(0, MakeDefiner(true)); // selected when the switch is TRUE + Selector->B.Connect(0, MakeDefiner(false)); // selected when FALSE + Selector->Value.Connect(0, E); + Selector->DefaultValue = Prop.bStaticBoolDefault; + AnchorInputs.Add(Selector); + } + continue; + } + + if (!IsPropertyReferenced(Prop.Name, NameStr)) + { + continue; // Unused value property: skip (keeps the graph minimal and deterministic). + } + + FParamNode Node; + switch (Prop.Type) + { + case EShaderLabPropertyType::Scalar: + { + UMaterialExpressionScalarParameter* E = NewExpr(Material, ParamY, -1000); + E->ParameterName = Prop.Name; + E->DefaultValue = Prop.ScalarDefault; + E->Group = FName(*Prop.Group); + E->SortPriority = Prop.SortPriority; + if (Prop.bHasRange) + { + E->SliderMin = Prop.RangeMin; + E->SliderMax = Prop.RangeMax; + } + Node.Expr = E; + break; + } + case EShaderLabPropertyType::Color: + case EShaderLabPropertyType::Vector: + { + UMaterialExpressionVectorParameter* E = NewExpr(Material, ParamY, -1000); + E->ParameterName = Prop.Name; + E->DefaultValue = Prop.VectorDefault; + E->Group = FName(*Prop.Group); + E->SortPriority = Prop.SortPriority; + Node.Expr = E; + break; + } + case EShaderLabPropertyType::Texture2D: + case EShaderLabPropertyType::TextureCube: + { + UMaterialExpressionTextureObjectParameter* E = NewExpr(Material, ParamY, -1000); + E->ParameterName = Prop.Name; + E->Group = FName(*Prop.Group); + E->SortPriority = Prop.SortPriority; + bool bIsNormal = false; + E->Texture = ResolveDefaultTexture(Prop.TextureDefault, bIsNormal); + E->SamplerType = bIsNormal ? SAMPLERTYPE_Normal : SAMPLERTYPE_Color; + Node.Expr = E; + Node.bIsTexture = true; + break; + } + default: + break; + } + if (Node.Expr) + { + PropertyNodes.Add(Prop.Name, Node); + } + } + + // 2) Build the pixel stage and connect it to FrontMaterial (what makes it a Substrate material). + if (Model.bHasSurface && Model.SurfaceEntry == EShaderLabEntry::PostProcess) + { + // PostProcess domain: Color -> EmissiveColor, Opacity -> Opacity (no Substrate slab). + if (!BuildEmissiveEntry(Material, *EditorOnly, TEXT("FShaderLabPostProcess"), TEXT("ShaderLabDefaultPostProcess"), + SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, Model, PropertyNodes, ParamY, OutErrors)) + { + return false; + } + } + else if (Model.bHasSurface && Model.SurfaceEntry == EShaderLabEntry::UI) + { + if (!BuildEmissiveEntry(Material, *EditorOnly, TEXT("FShaderLabUI"), TEXT("ShaderLabDefaultUI"), + SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, Model, PropertyNodes, ParamY, OutErrors)) + { + return false; + } + } + else if (Model.bHasSurface) + { + // Single-Surface sugar: one slab straight to FrontMaterial, with S.Opacity/S.OpacityMask + // allowed as material-level outputs. + UMaterialExpressionSubstrateSlabBSDF* Slab = BuildSlab( + Material, *EditorOnly, SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, + Model, PropertyNodes, /*bAllowMaterialOutputs*/ true, ParamY, OutErrors); + if (!Slab) + { + return false; + } + EditorOnly->FrontMaterial.Connect(0, Slab); + } + else + { + // Multi-slab: each named Slab -> its own slab node; Value blocks -> scalar Custom nodes; the + // FrontMaterial topology tree mixes them; Opacity/OpacityMask come from named Value blocks. + TMap SlabByName; + for (const FShaderLabSlab& SlabDecl : Model.Slabs) + { + if (SlabByName.Contains(SlabDecl.Name)) + { + OutErrors.Add(FString::Printf(TEXT("Duplicate Slab name '%s'"), *SlabDecl.Name.ToString())); + return false; + } + UMaterialExpressionSubstrateSlabBSDF* Slab = BuildSlab( + Material, *EditorOnly, SlabDecl.OutParamName, SlabDecl.Body, SlabDecl.BodyLine, + Model, PropertyNodes, /*bAllowMaterialOutputs*/ false, ParamY, OutErrors); + if (!Slab) + { + return false; + } + SlabByName.Add(SlabDecl.Name, Slab); + } + + TMap ValueByName; + for (const FShaderLabValue& ValueDecl : Model.Values) + { + if (ValueByName.Contains(ValueDecl.Name)) + { + OutErrors.Add(FString::Printf(TEXT("Duplicate Value name '%s'"), *ValueDecl.Name.ToString())); + return false; + } + UMaterialExpressionCustom* ValueNode = BuildValueNode( + Material, ValueDecl, Model, PropertyNodes, ParamY, OutErrors); + if (!ValueNode) + { + return false; + } + ValueByName.Add(ValueDecl.Name, ValueNode); + } + + // Every declared Slab must be reachable from FrontMaterial (contract: no dead slabs). + TSet ReferencedSlabs; + for (const FShaderLabTopoNode& Node : Model.Topology) + { + if (Node.Op == EShaderLabOp::SlabRef) { ReferencedSlabs.Add(Node.SlabRef); } + } + for (const FShaderLabSlab& SlabDecl : Model.Slabs) + { + if (!ReferencedSlabs.Contains(SlabDecl.Name)) + { + OutErrors.Add(FString::Printf(TEXT("Slab '%s' is declared but never used in FrontMaterial"), *SlabDecl.Name.ToString())); + return false; + } + } + + UMaterialExpression* Root = BuildTopologyNode( + Material, Model.TopologyRoot, Model, SlabByName, ValueByName, PropertyNodes, ParamY, OutErrors); + if (!Root) + { + return false; + } + EditorOnly->FrontMaterial.Connect(0, Root); + + // Material-level Opacity / OpacityMask from named Value blocks. + auto ConnectMaterialOutput = [&](FExpressionInput& Pin, FName ValueName, const TCHAR* What) -> bool + { + if (ValueName.IsNone()) { return true; } + UMaterialExpressionCustom* const* ValueNode = ValueByName.Find(ValueName); + if (!ValueNode) + { + OutErrors.Add(FString::Printf(TEXT("%s references unknown Value '%s'"), What, *ValueName.ToString())); + return false; + } + Pin.Connect(0, *ValueNode); + return true; + }; + if (!ConnectMaterialOutput(EditorOnly->Opacity, Model.OpacityValueName, TEXT("Opacity"))) { return false; } + if (!ConnectMaterialOutput(EditorOnly->OpacityMask, Model.OpacityMaskValueName, TEXT("OpacityMask"))) { return false; } + } + + // 3) Optional Vertex stage. Per-pixel/vertex context is read via UE::* intrinsics, so the entry + // takes just the output struct: `Vertex(inout FShaderLabVertex V)`. + if (Model.bHasVertex && Model.VertexParams.Num() >= 1) + { + const FShaderLabEntryParam& VtxOut = Model.VertexParams.Last(); + + TArray UsedVtx; + for (const FVertexFieldDef& F : GVertexFields) + { + if (ReferencesToken(Model.VertexBody, VtxOut.Name + TEXT(".") + F.Field)) + { + UsedVtx.Add(&F); + } + } + + if (UsedVtx.Num() > 0) + { + UMaterialExpressionCustom* VCustom = NewExpr(Material, ParamY, -300); + VCustom->Description = TEXT("ShaderLab Vertex"); + VCustom->OutputType = CMOT_Float1; + AddIncludes(*VCustom, Model); + + FString Code; + + // Intrinsics (Stage = vertex). + FString VertexBody = Model.VertexBody; + TArray VtxIntrinsicWires; + if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::VertexOnly, VertexBody, VtxIntrinsicWires, OutErrors)) + { + return false; + } + for (const FIntrinsicWire& Wire : VtxIntrinsicWires) + { + FCustomInput In; + In.InputName = Wire.InputName; + In.Input.Connect(Wire.OutputIndex, Wire.Expr); + VCustom->Inputs.Add(In); + } + + for (const FShaderLabProperty& Prop : Model.Properties) + { + if (Prop.Type == EShaderLabPropertyType::StaticBool) + { + continue; + } + if (!ReferencesToken(Model.VertexBody, Prop.Name.ToString())) + { + continue; + } + const FParamNode* Node = PropertyNodes.Find(Prop.Name); + if (!Node || !Node->Expr) + { + continue; + } + FCustomInput In; + In.InputName = Prop.Name; + In.Input.Connect(0, Node->Expr); + VCustom->Inputs.Add(In); + } + + const FString VSrcPath = MakeLineDirectivePath(Model.SourceFilePath); + Code += FString::Printf(TEXT("FShaderLabVertex %s = ShaderLabDefaultVertex();\n{\n%s}\n"), + *VtxOut.Name, *WrapBodyWithLineMapping(VertexBody, Model.VertexBodyLine, VSrcPath)); + + int32 VOutputIndex = 1; + TArray> VtxOutputs; + for (const FVertexFieldDef* F : UsedVtx) + { + FCustomOutput Out; + Out.OutputName = FName(*(FString(TEXT("SLO_")) + F->Field)); + Out.OutputType = F->OutType; + VCustom->AdditionalOutputs.Add(Out); + Code += FString::Printf(TEXT("SLO_%s = %s.%s;\n"), F->Field, *VtxOut.Name, F->Field); + VtxOutputs.Add(TPair(F->Field, VOutputIndex)); + ++VOutputIndex; + } + Code += TEXT("return 0.0f;\n"); + + VCustom->Code = Code; + VCustom->RebuildOutputs(); + + for (const TPair& Pair : VtxOutputs) + { + if (Pair.Key == TEXT("WorldPositionOffset")) + { + EditorOnly->WorldPositionOffset.Connect(Pair.Value, VCustom); + } + else if (Pair.Key == TEXT("Displacement")) + { + EditorOnly->Displacement.Connect(Pair.Value, VCustom); + } + else if (Pair.Key.StartsWith(TEXT("CustomizedUV"))) + { + const int32 UvIndex = FCString::Atoi(*Pair.Key.Mid(12)); + if (UvIndex >= 0 && UvIndex < 8) + { + EditorOnly->CustomizedUVs[UvIndex].Connect(Pair.Value, VCustom); + } + } + } + } + } + + // Funnel every static-switch selector into the ParameterAnchor. The anchor is a CustomOutput compiled + // BEFORE the material attributes (ShouldCompileBeforeAttributes), so compiling it compiles each + // selector — emitting the selected `#define 0/1` for the current permutation ahead of every + // body's `#if`. This gives per-permutation static switches with zero engine changes, and keeps the + // machinery off the user's body nodes. The anchor also makes the switch parameters visible in the + // Material Instance editor (reached via selector -> Value -> parameter). + if (AnchorInputs.Num() > 0) + { + UMaterialExpressionShaderLabParameterAnchor* Anchor = + NewExpr(Material, ParamY, -1300); + Anchor->Inputs.SetNum(AnchorInputs.Num()); + for (int32 Index = 0; Index < AnchorInputs.Num(); ++Index) + { + Anchor->Inputs[Index].Connect(0, AnchorInputs[Index]); + } + } + + Material.UpdateCachedExpressionData(); + return true; +} diff --git a/Source/UShaderLabEditor/Private/ShaderLabIntrinsicRegistry.cpp b/Source/UShaderLabEditor/Private/ShaderLabIntrinsicRegistry.cpp new file mode 100644 index 0000000..a58fa02 --- /dev/null +++ b/Source/UShaderLabEditor/Private/ShaderLabIntrinsicRegistry.cpp @@ -0,0 +1,189 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabIntrinsicRegistry.h" + +#include "Materials/Material.h" +#include "UObject/UObjectGlobals.h" + +// Backing expression node types for the v1 builtin intrinsics. +#include "Materials/MaterialExpressionActorPositionWS.h" +#include "Materials/MaterialExpressionCameraPositionWS.h" +#include "Materials/MaterialExpressionCameraVectorWS.h" +#include "Materials/MaterialExpressionDeltaTime.h" +#include "Materials/MaterialExpressionLightVector.h" +#include "Materials/MaterialExpressionLightmapUVs.h" +#include "Materials/MaterialExpressionLocalPosition.h" +#include "Materials/MaterialExpressionObjectBounds.h" +#include "Materials/MaterialExpressionObjectLocalBounds.h" +#include "Materials/MaterialExpressionObjectOrientation.h" +#include "Materials/MaterialExpressionObjectPositionWS.h" +#include "Materials/MaterialExpressionObjectRadius.h" +#include "Materials/MaterialExpressionParticleColor.h" +#include "Materials/MaterialExpressionPerInstanceCustomData.h" +#include "Materials/MaterialExpressionPerInstanceFadeAmount.h" +#include "Materials/MaterialExpressionPerInstanceRandom.h" +#include "Materials/MaterialExpressionPixelDepth.h" +#include "Materials/MaterialExpressionPixelNormalWS.h" +#include "Materials/MaterialExpressionPreSkinnedLocalBounds.h" +#include "Materials/MaterialExpressionPreSkinnedNormal.h" +#include "Materials/MaterialExpressionPreSkinnedPosition.h" +#include "Materials/MaterialExpressionScreenPosition.h" +#include "Materials/MaterialExpressionTextureCoordinate.h" +#include "Materials/MaterialExpressionTime.h" +#include "Materials/MaterialExpressionTwoSidedSign.h" +#include "Materials/MaterialExpressionVertexColor.h" +#include "Materials/MaterialExpressionVertexNormalWS.h" +#include "Materials/MaterialExpressionVertexTangentWS.h" +#include "Materials/MaterialExpressionViewProperty.h" +#include "Materials/MaterialExpressionWorldPosition.h" + +namespace ShaderLabIntrinsic_Private +{ + template + UMaterialExpression* MakeSimple(UMaterial& Material) + { + T* Expr = NewObject(&Material); + Material.GetExpressionCollection().AddExpression(Expr); + return Expr; + } +} + +FShaderLabIntrinsicRegistry& FShaderLabIntrinsicRegistry::Get() +{ + static FShaderLabIntrinsicRegistry Instance; + return Instance; +} + +FShaderLabIntrinsicRegistry::FShaderLabIntrinsicRegistry() +{ + RegisterBuiltins(); +} + +void FShaderLabIntrinsicRegistry::Register(FShaderLabIntrinsicDesc Desc) +{ + check(!Desc.Name.IsNone()); + check(Desc.MakeNode); + Descs.Add(Desc.Name, MoveTemp(Desc)); +} + +const FShaderLabIntrinsicDesc* FShaderLabIntrinsicRegistry::Find(FName Name) const +{ + return Descs.Find(Name); +} + +void FShaderLabIntrinsicRegistry::RegisterBuiltins() +{ + using namespace ShaderLabIntrinsic_Private; + using EFreq = EShaderLabIntrinsicFrequency; + + // Helper for the common no-arg case. + auto AddSimple = [this](const TCHAR* Name, EFreq Freq, TFunction Make) + { + FShaderLabIntrinsicDesc Desc; + Desc.Name = FName(Name); + Desc.Frequency = Freq; + Desc.MakeNode = [Make = MoveTemp(Make)](UMaterial& M, const TArray&, FString&) { return Make(M); }; + Register(MoveTemp(Desc)); + }; + + // --- A. Mesh / surface attributes --- + AddSimple(TEXT("VertexNormalWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("VertexTangentWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PixelNormalWS"), EFreq::PixelOnly, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("VertexColor"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("TwoSidedSign"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("LightmapUVs"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PreSkinnedNormal"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PreSkinnedPosition"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + + // TextureCoordinate(Index[, UTiling, VTiling]) — const args configure the node (mechanism: scalar/int args). + { + FShaderLabIntrinsicDesc Desc; + Desc.Name = FName(TEXT("TextureCoordinate")); + Desc.MakeNode = [](UMaterial& M, const TArray& Args, FString&) -> UMaterialExpression* + { + UMaterialExpressionTextureCoordinate* E = NewObject(&M); + if (Args.Num() >= 1) { E->CoordinateIndex = FCString::Atoi(*Args[0]); } + if (Args.Num() >= 2) { E->UTiling = FCString::Atof(*Args[1]); } + if (Args.Num() >= 3) { E->VTiling = FCString::Atof(*Args[2]); } + M.GetExpressionCollection().AddExpression(E); + return E; + }; + Register(MoveTemp(Desc)); + } + + // --- B. Positions / spaces --- + AddSimple(TEXT("WorldPosition"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("LocalPosition"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ActorPositionWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ObjectPositionWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("CameraPositionWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("CameraVectorWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("LightVector"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PixelDepth"), EFreq::PixelOnly, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ScreenPosition"), EFreq::PixelOnly, [](UMaterial& M) { return MakeSimple(M); }); + + // --- C. Object / bounds (ObjectLocalBounds & PreSkinnedLocalBounds are multi-output; output 0) --- + AddSimple(TEXT("ObjectRadius"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ObjectBounds"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ObjectLocalBounds"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ObjectOrientation"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PreSkinnedLocalBounds"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + + // --- D. Instancing --- + AddSimple(TEXT("PerInstanceRandom"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PerInstanceFadeAmount"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + + // PerInstanceCustomData(DataIndex[, ConstDefaultValue]) — mechanism: const args + optional input pin left at default. + { + FShaderLabIntrinsicDesc Desc; + Desc.Name = FName(TEXT("PerInstanceCustomData")); + Desc.MakeNode = [](UMaterial& M, const TArray& Args, FString&) -> UMaterialExpression* + { + UMaterialExpressionPerInstanceCustomData* E = NewObject(&M); + if (Args.Num() >= 1) { E->DataIndex = static_cast(FCString::Atoi(*Args[0])); } + if (Args.Num() >= 2) { E->ConstDefaultValue = FCString::Atof(*Args[1]); } + M.GetExpressionCollection().AddExpression(E); + return E; + }; + Register(MoveTemp(Desc)); + } + + // --- Time --- + AddSimple(TEXT("Time"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("DeltaTime"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + + // ViewProperty(Property) — representative of the enum-by-name const-arg mechanism. + { + FShaderLabIntrinsicDesc Desc; + Desc.Name = FName(TEXT("ViewProperty")); + Desc.MakeNode = [](UMaterial& M, const TArray& Args, FString& OutError) -> UMaterialExpression* + { + UMaterialExpressionViewProperty* E = NewObject(&M); + if (Args.Num() >= 1) + { + UEnum* Enum = StaticEnum(); + check(Enum); + const int64 Value = Enum->GetValueByNameString(Args[0]); + if (Value == INDEX_NONE) + { + OutError = FString::Printf(TEXT("unknown view property '%s'"), *Args[0]); + return nullptr; + } + E->Property = static_cast(Value); + } + M.GetExpressionCollection().AddExpression(E); + return E; + }; + Register(MoveTemp(Desc)); + } + + // ParticleColor — only meaningful on particle vertex factories; the instance enables the matching + // usage, so there is no base-level usage gate here. + { + FShaderLabIntrinsicDesc Desc; + Desc.Name = FName(TEXT("ParticleColor")); + Desc.MakeNode = [](UMaterial& M, const TArray&, FString&) { return MakeSimple(M); }; + Register(MoveTemp(Desc)); + } +} diff --git a/Source/UShaderLabEditor/Private/ShaderLabMaterialInstanceFactory.cpp b/Source/UShaderLabEditor/Private/ShaderLabMaterialInstanceFactory.cpp new file mode 100644 index 0000000..3465989 --- /dev/null +++ b/Source/UShaderLabEditor/Private/ShaderLabMaterialInstanceFactory.cpp @@ -0,0 +1,151 @@ +// Copyright FlecsProj. All Rights Reserved. + +#include "ShaderLabMaterialInstanceFactory.h" + +#include "AssetRegistry/AssetData.h" +#include "AssetThumbnail.h" +#include "AssetTypeCategories.h" +#include "Editor.h" +#include "Framework/Application/SlateApplication.h" +#include "Materials/Material.h" +#include "Misc/App.h" +#include "ShaderLabMaterialInstanceConstant.h" +#include "ShaderLabMaterialRegistry.h" +#include "Widgets/Input/SButton.h" +#include "Widgets/Layout/SBox.h" +#include "Widgets/Layout/SScrollBox.h" +#include "Widgets/Layout/SUniformWrapPanel.h" +#include "Widgets/SBoxPanel.h" +#include "Widgets/SWindow.h" +#include "Widgets/Text/STextBlock.h" + +#define LOCTEXT_NAMESPACE "ShaderLabMaterialInstanceFactory" + +namespace +{ + /** Modal picker of the discovered ShaderLab base materials, with preview thumbnails. */ + UMaterial* PickShaderLabBase() + { + FShaderLabMaterialRegistry& Registry = FShaderLabMaterialRegistry::Get(); + + // Collect the registered in-memory base materials (sorted by name for a stable layout). + TArray Bases; + TArray Names; + Registry.GetRegisteredModels().GenerateKeyArray(Names); + Names.Sort(); + for (const FString& Name : Names) + { + if (UMaterial* Base = Registry.FindMaterial(Name)) + { + Bases.Add(Base); + } + } + if (Bases.IsEmpty()) + { + return nullptr; + } + + UMaterial* Chosen = nullptr; + TSharedRef Window = SNew(SWindow) + .Title(LOCTEXT("PickBaseTitle", "Choose ShaderLab base material")) + .ClientSize(FVector2D(560, 520)) + .SupportsMaximize(false) + .SupportsMinimize(false); + + TSharedRef ThumbnailPool = MakeShared(64); + TSharedRef Grid = SNew(SUniformWrapPanel).SlotPadding(FMargin(6.f)); + + for (UMaterial* Base : Bases) + { + // Hold the thumbnail alive for the window's lifetime by capturing it in the button lambda. + TSharedRef Thumbnail = MakeShared(FAssetData(Base), 96, 96, ThumbnailPool); + Grid->AddSlot() + [ + SNew(SButton) + .HAlign(HAlign_Center) + .VAlign(VAlign_Center) + .ToolTipText(FText::FromString(Base->GetName())) + .OnClicked_Lambda([&Chosen, Base, Window, Thumbnail]() + { + Chosen = Base; + Window->RequestDestroyWindow(); + return FReply::Handled(); + }) + .Content() + [ + SNew(SVerticalBox) + + SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center) + [ + SNew(SBox).WidthOverride(96.f).HeightOverride(96.f) + [ + Thumbnail->MakeThumbnailWidget() + ] + ] + + SVerticalBox::Slot().AutoHeight().HAlign(HAlign_Center).Padding(0, 4, 0, 0) + [ + SNew(STextBlock).Text(FText::FromString(Base->GetName())) + ] + ] + ]; + } + + Window->SetContent( + SNew(SScrollBox) + + SScrollBox::Slot().Padding(8.f) + [ + Grid + ]); + + GEditor->EditorAddModalWindow(Window); + return Chosen; + } +} + +UShaderLabMaterialInstanceFactory::UShaderLabMaterialInstanceFactory() +{ + SupportedClass = UShaderLabMaterialInstanceConstant::StaticClass(); + bCreateNew = true; + bEditAfterNew = true; +} + +uint32 UShaderLabMaterialInstanceFactory::GetMenuCategories() const +{ + return EAssetTypeCategories::Materials; +} + +FText UShaderLabMaterialInstanceFactory::GetDisplayName() const +{ + return LOCTEXT("DisplayName", "ShaderLab Material Instance"); +} + +bool UShaderLabMaterialInstanceFactory::ConfigureProperties() +{ + // A commandlet/automation can set InitialParent directly and skip the (headless-unavailable) dialog. + if (InitialParent) + { + return true; + } + if (FApp::IsUnattended() || IsRunningCommandlet() || !GEditor) + { + return false; // No parent and no UI to pick one. + } + + InitialParent = PickShaderLabBase(); + return InitialParent != nullptr; // false = user cancelled / no bases -> abort creation. +} + +UObject* UShaderLabMaterialInstanceFactory::FactoryCreateNew( + UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) +{ + UShaderLabMaterialInstanceConstant* Instance = + NewObject(InParent, InClass, InName, Flags); + + if (InitialParent) + { + Instance->SetParentEditorOnly(InitialParent); + Instance->PostEditChange(); + } + return Instance; +} + +#undef LOCTEXT_NAMESPACE diff --git a/Source/UShaderLabEditor/Private/ShaderLabMaterialInstanceFactory.h b/Source/UShaderLabEditor/Private/ShaderLabMaterialInstanceFactory.h new file mode 100644 index 0000000..9ebfdef --- /dev/null +++ b/Source/UShaderLabEditor/Private/ShaderLabMaterialInstanceFactory.h @@ -0,0 +1,37 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "Factories/Factory.h" +#include "ShaderLabMaterialInstanceFactory.generated.h" + +class UMaterial; + +/** + * Creates a UShaderLabMaterialInstanceConstant parented to a ShaderLab in-memory /Script base + * material. Appears in the Content Browser under "Material & Textures ▸ ShaderLab Material Instance". + * + * ShaderLab base materials are hidden templates (not pickable as assets), so this factory is the + * entry point: ConfigureProperties() pops a picker of the discovered base materials (with preview + * thumbnails). A commandlet may instead set InitialParent directly and skip the dialog. + */ +UCLASS() +class UShaderLabMaterialInstanceFactory : public UFactory +{ + GENERATED_BODY() + +public: + UShaderLabMaterialInstanceFactory(); + + /** Parent base material for the created instance. Set by the picker, or directly by a commandlet. */ + UPROPERTY() + TObjectPtr InitialParent = nullptr; + + //~ UFactory interface + virtual bool ConfigureProperties() override; + virtual UObject* FactoryCreateNew(UClass* InClass, UObject* InParent, FName InName, EObjectFlags Flags, UObject* Context, FFeedbackContext* Warn) override; + virtual uint32 GetMenuCategories() const override; + virtual FText GetDisplayName() const override; + //~ End UFactory interface +}; diff --git a/Source/UShaderLabEditor/Public/ShaderLabGraphBuilder.h b/Source/UShaderLabEditor/Public/ShaderLabGraphBuilder.h new file mode 100644 index 0000000..082fceb --- /dev/null +++ b/Source/UShaderLabEditor/Public/ShaderLabGraphBuilder.h @@ -0,0 +1,28 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" + +class UMaterial; +struct FShaderLabModel; + +/** + * Editor-only builder that turns a parsed FShaderLabModel into a UMaterial expression graph: + * property parameter nodes + a Custom HLSL node for the Surface body wired into a Substrate + * Slab BSDF (-> FrontMaterial), plus an optional Vertex Custom node for WPO/displacement/UVs. + * + * The build is fully deterministic for a given model (node creation order, input/output order, + * generated HLSL) so the cook-time and editor-time graphs — and therefore the baked uniform + * expression set — match. + */ +class USHADERLABEDITOR_API FShaderLabGraphBuilder +{ +public: + /** + * Clear `Material`'s expression graph and rebuild it from `Model`, applying settings + * (domain/blend/two-sided) and updating cached expression data. Does NOT trigger shader + * compilation — callers decide when to compile. Returns false with diagnostics on failure. + */ + static bool BuildInto(UMaterial& Material, const FShaderLabModel& Model, TArray& OutErrors); +}; diff --git a/Source/UShaderLabEditor/Public/ShaderLabIntrinsicRegistry.h b/Source/UShaderLabEditor/Public/ShaderLabIntrinsicRegistry.h new file mode 100644 index 0000000..8a487b4 --- /dev/null +++ b/Source/UShaderLabEditor/Public/ShaderLabIntrinsicRegistry.h @@ -0,0 +1,63 @@ +// Copyright FlecsProj. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" + +class UMaterial; +class UMaterialExpression; + +/** Shader stage an intrinsic is valid in (used to reject e.g. PixelDepth inside a Vertex body). */ +enum class EShaderLabIntrinsicFrequency : uint8 +{ + Any, + PixelOnly, + VertexOnly, +}; + +/** + * Describes one `UE::NodeName(...)` intrinsic: how to build the backing material expression node, + * which of its outputs to read, and what it requires of the material. The Custom-node input type is + * derived by the engine from the connected node's real output type, so no output type is declared here. + */ +struct FShaderLabIntrinsicDesc +{ + FName Name; + + /** Which output pin of the created node to connect (covers multi-output nodes like ObjectLocalBounds). */ + int32 OutputIndex = 0; + + /** Stage restriction; misuse is a build error. */ + EShaderLabIntrinsicFrequency Frequency = EShaderLabIntrinsicFrequency::Any; + + /** + * Create the backing expression node and add it to `Material`'s expression collection. `ConstArgs` + * are the literal arguments parsed from the call (e.g. {"0","2.0"} for `UE::TextureCoordinate(0,2.0)`). + * Return nullptr and set `OutError` on a bad argument. Contract: this only runs in the editor/cook + * (graphs are editor-only); the cooked runtime never builds a graph. + */ + TFunction& /*ConstArgs*/, FString& /*OutError*/)> MakeNode; +}; + +/** + * Open registry mapping intrinsic names to descriptors. ShaderLab seeds its builtins on first use; + * other editor modules can register intrinsics for their own custom UMaterialExpression nodes from + * their StartupModule via Get().Register(...). + */ +class USHADERLABEDITOR_API FShaderLabIntrinsicRegistry +{ +public: + static FShaderLabIntrinsicRegistry& Get(); + + /** Register (or override) an intrinsic. */ + void Register(FShaderLabIntrinsicDesc Desc); + + /** Lookup by name; nullptr if unknown. */ + const FShaderLabIntrinsicDesc* Find(FName Name) const; + +private: + FShaderLabIntrinsicRegistry(); + void RegisterBuiltins(); + + TMap Descs; +}; diff --git a/Source/UShaderLabEditor/UShaderLabEditor.Build.cs b/Source/UShaderLabEditor/UShaderLabEditor.Build.cs new file mode 100644 index 0000000..1a18727 --- /dev/null +++ b/Source/UShaderLabEditor/UShaderLabEditor.Build.cs @@ -0,0 +1,36 @@ +// Copyright FlecsProj. All Rights Reserved. + +using UnrealBuildTool; + +public class UShaderLabEditor : ModuleRules +{ + public UShaderLabEditor(ReadOnlyTargetRules Target) : base(Target) + { + PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs; + + bUseUnity = false; + + PublicDependencyModuleNames.AddRange(new string[] + { + "Core", + "CoreUObject", + "Engine", + }); + + PrivateDependencyModuleNames.AddRange(new string[] + { + "UShaderLab", + "RenderCore", + "RHI", + "UnrealEd", + "AssetTools", + "MaterialEditor", + "Slate", + "SlateCore", + "Projects", + "DirectoryWatcher", + "ContentBrowserData", + "TargetPlatform", + }); + } +} diff --git a/UShaderLab.uplugin b/UShaderLab.uplugin new file mode 100644 index 0000000..e23770c --- /dev/null +++ b/UShaderLab.uplugin @@ -0,0 +1,28 @@ +{ + "FileVersion": 3, + "Version": 1, + "VersionName": "1.0", + "FriendlyName": "UShaderLab", + "Description": "Unity-ShaderLab-like text DSL that builds in-memory Substrate materials.", + "Category": "Rendering", + "CreatedBy": "FlecsProj", + "CreatedByURL": "", + "DocsURL": "", + "MarketplaceURL": "", + "CanContainContent": true, + "IsBetaVersion": false, + "IsExperimentalVersion": true, + "Installed": false, + "Modules": [ + { + "Name": "UShaderLab", + "Type": "Runtime", + "LoadingPhase": "Default" + }, + { + "Name": "UShaderLabEditor", + "Type": "Editor", + "LoadingPhase": "Default" + } + ] +}