diff --git a/Shaders/Private/ShaderLabDSL.ush b/Shaders/Private/ShaderLabDSL.ush index 437a0a5..fe56b2e 100644 --- a/Shaders/Private/ShaderLabDSL.ush +++ b/Shaders/Private/ShaderLabDSL.ush @@ -25,6 +25,19 @@ // Scalar/Color/Vector defaults come from the HLSL initializer; texture default from DefaultTexture. #define SL_PROPERTY(...) +// Function-library import. Annotates the `#include "...uslfunc"` on the NEXT line, carrying options such +// as Namespace= (prefixes the promoted parameters so same-named properties across libraries stay distinct). +// A plain `#include "...uslfunc"` (no marker) imports with default by-name merging. +// SL_IMPORT(Namespace = "Detail") +// #include "/Project/Lib/DetailLib.uslfunc" +#define SL_IMPORT(...) + +// Library function (`.uslfunc` files only). Precedes a normal HLSL function; it may use UE_ intrinsics, +// reference the library's own properties, and call other library functions. +// SL_FUNCTION() +// float3 ApplyDetail(float3 baseColor, float2 uv) { ... } +#define SL_FUNCTION(...) + // Pixel/vertex entry points. Precede a `void Name(inout F... X) { ... }` (Vertex takes FShaderLabVertex). #define SL_SURFACE(...) #define SL_POSTPROCESS(...) diff --git a/Source/UShaderLab/Private/ShaderLabDiscovery.cpp b/Source/UShaderLab/Private/ShaderLabDiscovery.cpp index 1daf71e..805451c 100644 --- a/Source/UShaderLab/Private/ShaderLabDiscovery.cpp +++ b/Source/UShaderLab/Private/ShaderLabDiscovery.cpp @@ -30,7 +30,16 @@ TArray FShaderLabDiscovery::FindShaderLabFiles() { TArray Found; IFileManager::Get().FindFilesRecursive(Found, *Root, TEXT("*.usl"), true, false, false); - Files.Append(Found); + for (const FString& File : Found) + { + // A `.usl` is a renderable shader (registered as a base material); a `.uslfunc` is a function + // library (imported by shaders, never a base material). The Windows `*.usl` wildcard also + // matches `*.uslfunc` via legacy short-name behavior, so filter by exact extension here. + if (FPaths::GetExtension(File).Equals(TEXT("usl"), ESearchCase::IgnoreCase)) + { + Files.Add(File); + } + } } } return Files; diff --git a/Source/UShaderLab/Private/ShaderLabImportResolver.cpp b/Source/UShaderLab/Private/ShaderLabImportResolver.cpp new file mode 100644 index 0000000..8e4526f --- /dev/null +++ b/Source/UShaderLab/Private/ShaderLabImportResolver.cpp @@ -0,0 +1,281 @@ +// Copyright UShaderLab. All Rights Reserved. + +#include "ShaderLabImportResolver.h" + +#include "ShaderLabParser.h" + +const FShaderLabFunction* FShaderLabResolvedProgram::FindFunction(FName Name, int32& OutLibraryIndex) const +{ + if (const int32* LibIdx = FunctionOwnerLibrary.Find(Name)) + { + OutLibraryIndex = *LibIdx; + return Libraries[*LibIdx].Model.FindFunction(Name); + } + OutLibraryIndex = INDEX_NONE; + return nullptr; +} + +FName FShaderLabImportResolver::PromoteName(const FString& Namespace, const FShaderLabProperty& Prop) +{ + // StaticBool promotes by bare name regardless of namespace: it reaches bodies through a global + // preprocessor `#define` (via the ParameterAnchor), which has no namespace to key on. + if (Prop.Type == EShaderLabPropertyType::StaticBool || Namespace.IsEmpty()) + { + return Prop.Name; + } + return FName(*(Namespace + TEXT("_") + Prop.Name.ToString())); +} + +namespace ShaderLabResolver_Private +{ + /** Canonical dedup key for a virtual path (case-insensitive, forward-slashed). */ + static FString Canonicalize(const FString& Path) + { + FString P = Path; + P.ReplaceInline(TEXT("\\"), TEXT("/")); + return P.ToLower(); + } + + static bool IsTexture(const FShaderLabProperty& P) + { + return P.Type == EShaderLabPropertyType::Texture2D || P.Type == EShaderLabPropertyType::TextureCube; + } + + struct FResolveState + { + const FShaderLabImportResolver::FSourceLoader& Loader; + FShaderLabResolvedProgram& Program; + TArray& Errors; + + /** Canonical path -> resolved library index. */ + TMap ByCanonical; + /** Canonical paths on the current DFS path (cycle detection). */ + TArray VisitingStack; + /** Per library index: the direct child (imported) library indices. Index-aligned with Program.Libraries. */ + TArray> ChildLibs; + + void Fail(const FString& Message) { Errors.Add(Message); } + }; + + /** + * Depth-first resolve one library import. Returns its library index, or INDEX_NONE on error. + * Post-order: children are appended to Program.Libraries before their importer, so a library always + * precedes anything that imports it (emit order = defined-before-use). + */ + static int32 ResolveLibrary(FResolveState& State, const FString& VirtualPath, const FString& Namespace) + { + const FString Canonical = Canonicalize(VirtualPath); + + if (State.VisitingStack.Contains(Canonical)) + { + State.Fail(FString::Printf(TEXT("ShaderLab: import cycle detected at '%s'"), *VirtualPath)); + return INDEX_NONE; + } + if (const int32* Existing = State.ByCanonical.Find(Canonical)) + { + // Already resolved via another path: the namespace must agree (a library has one identity). + const FShaderLabResolvedLibrary& Lib = State.Program.Libraries[*Existing]; + if (Lib.Namespace != Namespace) + { + State.Fail(FString::Printf( + TEXT("ShaderLab: library '%s' is imported with conflicting namespaces '%s' and '%s'"), + *VirtualPath, Lib.Namespace.IsEmpty() ? TEXT("") : *Lib.Namespace, + Namespace.IsEmpty() ? TEXT("") : *Namespace)); + return INDEX_NONE; + } + return *Existing; + } + + FString Source, DiskPath, LoadError; + if (!State.Loader(VirtualPath, Source, DiskPath, LoadError)) + { + State.Fail(FString::Printf(TEXT("ShaderLab: cannot load imported library '%s': %s"), *VirtualPath, *LoadError)); + return INDEX_NONE; + } + + FShaderLabModel LibModel; + TArray ParseErrors; + // Parse under the disk path so bodies' `#line` directives (and thus compile-error clicks) resolve + // to the real file; dedup/diagnostics below still key off the virtual path. + if (!FShaderLabParser::Parse(Source, DiskPath.IsEmpty() ? VirtualPath : DiskPath, LibModel, ParseErrors)) + { + for (const FShaderLabParseError& E : ParseErrors) + { + State.Fail(FString::Printf(TEXT("ShaderLab: parse error in library '%s' %s"), *VirtualPath, *E.ToString())); + } + return INDEX_NONE; + } + if (!LibModel.bIsFunctionLibrary) + { + State.Fail(FString::Printf(TEXT("ShaderLab: '%s' is imported as a function library but is not a `.uslfunc`"), *VirtualPath)); + return INDEX_NONE; + } + + // Recurse into this library's own imports first (post-order). + State.VisitingStack.Push(Canonical); + TArray Children; + for (const FShaderLabImport& Import : LibModel.Imports) + { + const int32 ChildIdx = ResolveLibrary(State, Import.Path, Import.Namespace); + if (ChildIdx == INDEX_NONE) + { + State.VisitingStack.Pop(); + return INDEX_NONE; + } + Children.AddUnique(ChildIdx); + } + State.VisitingStack.Pop(); + + FShaderLabResolvedLibrary Resolved; + Resolved.CanonicalPath = Canonical; + Resolved.VirtualPath = VirtualPath; + Resolved.Namespace = Namespace; + Resolved.Model = MoveTemp(LibModel); + const int32 Index = State.Program.Libraries.Add(MoveTemp(Resolved)); + State.ChildLibs.Add(MoveTemp(Children)); + check(State.ChildLibs.Num() == State.Program.Libraries.Num()); + State.ByCanonical.Add(Canonical, Index); + return Index; + } + + /** Accumulate the subtree (self + all transitively imported libraries) of a library, memoized. */ + static void CollectSubtree(const FResolveState& State, int32 LibIndex, TSet& OutSet) + { + if (OutSet.Contains(LibIndex)) + { + return; + } + OutSet.Add(LibIndex); + for (const int32 Child : State.ChildLibs[LibIndex]) + { + CollectSubtree(State, Child, OutSet); + } + } +} + +bool FShaderLabImportResolver::Resolve( + const FShaderLabModel& RootModel, + const FSourceLoader& Loader, + FShaderLabResolvedProgram& OutProgram, + TArray& OutErrors) +{ + using namespace ShaderLabResolver_Private; + + OutProgram = FShaderLabResolvedProgram(); + + FResolveState State{ Loader, OutProgram, OutErrors }; + + // 1) Load the transitive library closure (post-order into OutProgram.Libraries). + for (const FShaderLabImport& Import : RootModel.Imports) + { + if (ResolveLibrary(State, Import.Path, Import.Namespace) == INDEX_NONE) + { + return false; + } + } + + // 2) Function-name uniqueness across the whole closure (functions are flat HLSL symbols). + for (int32 LibIdx = 0; LibIdx < OutProgram.Libraries.Num(); ++LibIdx) + { + for (const FShaderLabFunction& Fn : OutProgram.Libraries[LibIdx].Model.Functions) + { + if (const int32* Prev = OutProgram.FunctionOwnerLibrary.Find(Fn.Name)) + { + OutErrors.Add(FString::Printf( + TEXT("ShaderLab: function '%s' is declared in both '%s' and '%s' (function names must be unique across imports)"), + *Fn.Name.ToString(), *OutProgram.Libraries[*Prev].VirtualPath, *OutProgram.Libraries[LibIdx].VirtualPath)); + return false; + } + OutProgram.FunctionOwnerLibrary.Add(Fn.Name, LibIdx); + } + } + + // 3) Promote properties. Root shader properties come first (owner = INDEX_NONE, no namespace); then + // each library's properties, namespaced. Dedup by promoted name: same type merges (first wins, so a + // shader can override a library default by re-declaring the same name); different type is an error. + auto AddPromoted = [&OutProgram, &OutErrors](const FShaderLabProperty& Prop, const FName PromotedName, int32 OwnerLib) -> bool + { + if (const int32* Existing = OutProgram.PropertyOwner.Find(PromotedName)) + { + const FShaderLabProperty& Prior = OutProgram.Properties[OutProgram.Properties.IndexOfByPredicate( + [PromotedName](const FShaderLabProperty& P) { return P.Name == PromotedName; })]; + if (Prior.Type != Prop.Type) + { + OutErrors.Add(FString::Printf( + TEXT("ShaderLab: parameter '%s' is declared with conflicting types across the shader/imported libraries"), + *PromotedName.ToString())); + return false; + } + return true; // Same type: keep the first (shader-wins) declaration. + } + FShaderLabProperty Promoted = Prop; + Promoted.Name = PromotedName; + OutProgram.Properties.Add(MoveTemp(Promoted)); + OutProgram.PropertyOwner.Add(PromotedName, OwnerLib); + return true; + }; + + for (const FShaderLabProperty& Prop : RootModel.Properties) + { + if (!AddPromoted(Prop, Prop.Name, INDEX_NONE)) + { + return false; + } + } + for (int32 LibIdx = 0; LibIdx < OutProgram.Libraries.Num(); ++LibIdx) + { + const FShaderLabResolvedLibrary& Lib = OutProgram.Libraries[LibIdx]; + for (const FShaderLabProperty& Prop : Lib.Model.Properties) + { + if (!AddPromoted(Prop, PromoteName(Lib.Namespace, Prop), LibIdx)) + { + return false; + } + } + } + + // 4) Build each library's PropRewrite (bare identifier -> promoted name) over its visibility scope + // (own + transitively imported properties), so function bodies can be rewritten. A bare name that + // is visible from two libraries with different promoted names is ambiguous HLSL -> error. + for (int32 LibIdx = 0; LibIdx < OutProgram.Libraries.Num(); ++LibIdx) + { + TSet Subtree; + CollectSubtree(State, LibIdx, Subtree); + + FShaderLabResolvedLibrary& Lib = OutProgram.Libraries[LibIdx]; + for (const int32 VisibleIdx : Subtree) + { + const FShaderLabResolvedLibrary& Visible = OutProgram.Libraries[VisibleIdx]; + for (const FShaderLabProperty& Prop : Visible.Model.Properties) + { + // StaticBool reaches function bodies through the global `#define` (promoted == bare name), so + // it is never a rewritten identifier nor a function parameter — keep it out of PropRewrite. + if (Prop.Type == EShaderLabPropertyType::StaticBool) + { + continue; + } + const FName Promoted = PromoteName(Visible.Namespace, Prop); + if (const FName* Prior = Lib.PropRewrite.Find(Prop.Name)) + { + if (*Prior != Promoted) + { + OutErrors.Add(FString::Printf( + TEXT("ShaderLab: property '%s' is visible from multiple libraries in scope of '%s' — rename or namespace one of them"), + *Prop.Name.ToString(), *Lib.VirtualPath)); + return false; + } + continue; + } + Lib.PropRewrite.Add(Prop.Name, Promoted); + if (IsTexture(Prop)) + { + Lib.PropRewrite.Add( + FName(*(Prop.Name.ToString() + TEXT("Sampler"))), + FName(*(Promoted.ToString() + TEXT("Sampler")))); + } + } + } + } + + return true; +} diff --git a/Source/UShaderLab/Private/ShaderLabModel.cpp b/Source/UShaderLab/Private/ShaderLabModel.cpp index fd505ff..6c40fc8 100644 --- a/Source/UShaderLab/Private/ShaderLabModel.cpp +++ b/Source/UShaderLab/Private/ShaderLabModel.cpp @@ -12,6 +12,11 @@ const FShaderLabInterpolator* FShaderLabModel::FindInterpolator(FName InName) co return Interpolators.FindByPredicate([InName](const FShaderLabInterpolator& I) { return I.Name == InName; }); } +const FShaderLabFunction* FShaderLabModel::FindFunction(FName InName) const +{ + return Functions.FindByPredicate([InName](const FShaderLabFunction& F) { return F.Name == InName; }); +} + bool FShaderLabModel::HasStaticSwitches() const { return Properties.ContainsByPredicate( diff --git a/Source/UShaderLab/Private/ShaderLabParser.cpp b/Source/UShaderLab/Private/ShaderLabParser.cpp index ff5def5..74db248 100644 --- a/Source/UShaderLab/Private/ShaderLabParser.cpp +++ b/Source/UShaderLab/Private/ShaderLabParser.cpp @@ -786,22 +786,132 @@ namespace Model.Properties.Add(MoveTemp(Prop)); } - void ParseInclude(FScanner& S, FShaderLabModel& Model) + /** + * At a '#include' directive ('#'+'include' already consumed). A `.uslfunc` path (or any include + * annotated by a preceding SL_IMPORT) is recorded as a function-library import; other quoted paths are + * real `.ush` library dependencies. bIsImport carries an explicit SL_IMPORT annotation (with Namespace). + */ + void ParseInclude(FScanner& S, FShaderLabModel& Model, bool bIsImport, const FString& Namespace, int32 ImportLine) { - // At a '#include' directive; '#' already consumed, 'include' identifier already read. FString Path; if (!S.ReadString(Path)) { + if (bIsImport) + { + S.Error(TEXT("SL_IMPORT must annotate a `#include \"...uslfunc\"` directive")); + return; + } // Tolerate angle-bracket includes by skipping the line; ShaderLab uses quoted virtual paths. S.SkipToEndOfLine(); return; } + + const bool bUslFunc = Path.EndsWith(TEXT(".uslfunc"), ESearchCase::IgnoreCase); + if (bIsImport || bUslFunc) + { + if (!bUslFunc) + { + S.Error(FString::Printf(TEXT("SL_IMPORT expects a `.uslfunc` library path, got '%s'"), *Path)); + return; + } + FShaderLabImport Import; + Import.Path = Path; + Import.Namespace = Namespace; + Import.Line = ImportLine > 0 ? ImportLine : S.Line; + Model.Imports.Add(MoveTemp(Import)); + return; + } + if (!IsAuthoringShimInclude(Path)) { Model.Includes.Add(Path); } } + /** Parse `SL_IMPORT( [Namespace = "..."] )` options; fills OutNamespace. Returns false on error. */ + bool ParseImport(FScanner& S, FString& OutNamespace) + { + const int32 SpecLine = S.Line, SpecCol = S.Column; + FString Inner; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), Inner)) + { + return false; + } + for (const FString& StmtRaw : SplitTopLevel(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 SL_IMPORT option '%s' (expected Key = Value)"), *Stmt), SpecLine, SpecCol); + return false; + } + Key = Key.TrimStartAndEnd(); + Value = Value.TrimStartAndEnd().TrimQuotes(); + if (Key == TEXT("Namespace")) + { + OutNamespace = Value; + } + else + { + S.Error(FString::Printf(TEXT("Unknown SL_IMPORT option '%s'"), *Key), SpecLine, SpecCol); + return false; + } + } + return true; + } + + /** SL_FUNCTION() () { } — a reusable library function (`.uslfunc` only). */ + void ParseFunction(FScanner& S, FShaderLabModel& Model) + { + FString Ignored; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), Ignored)) // SL_FUNCTION() + { + return; + } + S.SkipTrivia(); + const int32 DeclLine = S.Line, DeclCol = S.Column; + + FString RetType; + if (!S.ReadIdentifier(RetType)) + { + S.Error(TEXT("Expected a function declaration after SL_FUNCTION")); + return; + } + FString Name, SigInner, Body; + int32 BodyLine = 0; + if (!S.ReadIdentifier(Name)) + { + S.Error(TEXT("Expected a function name")); + return; + } + if (!S.ReadBalanced(TEXT('('), TEXT(')'), SigInner)) + { + return; + } + if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Body, &BodyLine)) + { + return; + } + const FName FnName(*Name); + if (Model.Functions.ContainsByPredicate([&FnName](const FShaderLabFunction& E) { return E.Name == FnName; })) + { + S.Error(FString::Printf(TEXT("Duplicate function name '%s'"), *Name), DeclLine, DeclCol); + return; + } + FShaderLabFunction Fn; + Fn.Name = FnName; + Fn.ReturnType = RetType; + Fn.SignatureInner = MoveTemp(SigInner); + Fn.Body = MoveTemp(Body); + Fn.BodyLine = BodyLine; + Model.Functions.Add(MoveTemp(Fn)); + } + void ParseEntry(FScanner& S, FShaderLabModel& Model, const FString& Marker) { // Marker macro takes no args: SL_SURFACE()/SL_POSTPROCESS()/SL_UI()/SL_VERTEX(). @@ -1216,9 +1326,18 @@ bool FShaderLabParser::Parse( // Identity + display name come from the file name (e.g. "Basic.usl" -> "Basic"); the file IS the // shader. Annotated-HLSL constructs are parsed flat to EOF in any order. OutModel.ShaderName = FPaths::GetBaseFilename(SourceName); + // A `.uslfunc` is a reusable function library (no pixel/vertex entry, no base material); a `.usl` is a + // renderable shader. The extension is the single source of truth for which constructs are legal. + OutModel.bIsFunctionLibrary = FPaths::GetExtension(SourceName).Equals(TEXT("uslfunc"), ESearchCase::IgnoreCase); + const TCHAR* const FileKind = OutModel.bIsFunctionLibrary ? TEXT("function library") : TEXT("shader"); FScanner S(Source, OutErrors); + // A pending `SL_IMPORT(...)` annotation must be consumed by the very next `#include "...uslfunc"`. + bool bPendingImport = false; + FString PendingImportNamespace; + int32 PendingImportLine = 0; + while (!S.bFailed) { S.SkipTrivia(); @@ -1227,8 +1346,16 @@ bool FShaderLabParser::Parse( break; // End of file: all constructs parsed. } - // Top-level preprocessor lines: `#include "..."` feeds Model.Includes; every other preprocessor - // line (`#pragma`, `#define`, `#if`, ...) is free HLSL captured verbatim into LocalCode. + // Contract: SL_IMPORT annotates exactly the next `#include`. Anything else after it is an error. + if (bPendingImport && S.Peek() != TEXT('#')) + { + S.Error(TEXT("SL_IMPORT must be immediately followed by a `#include \"...uslfunc\"` directive")); + return false; + } + + // Top-level preprocessor lines: `#include "..."` feeds Model.Includes (or Model.Imports for a + // `.uslfunc` / SL_IMPORT-annotated include); every other preprocessor line (`#pragma`, `#define`, + // `#if`, ...) is free HLSL captured verbatim into LocalCode. if (S.Peek() == TEXT('#')) { const TCHAR* Save = S.Ptr; @@ -1237,7 +1364,15 @@ bool FShaderLabParser::Parse( FString Directive; if (S.ReadIdentifier(Directive) && Directive == TEXT("include")) { - ParseInclude(S, OutModel); + ParseInclude(S, OutModel, bPendingImport, PendingImportNamespace, PendingImportLine); + bPendingImport = false; + PendingImportNamespace.Reset(); + PendingImportLine = 0; + } + else if (bPendingImport) + { + S.Error(TEXT("SL_IMPORT must be immediately followed by a `#include \"...uslfunc\"` directive")); + return false; } else { @@ -1257,8 +1392,41 @@ bool FShaderLabParser::Parse( return false; } - if (Token == TEXT("SL_SETTINGS")) + // Constructs that only make sense in a renderable shader (a library has no material to render). + auto RejectInLibrary = [&](const TCHAR* What) -> bool { + if (OutModel.bIsFunctionLibrary) + { + S.Error(FString::Printf(TEXT("%s is not allowed in a %s"), What, FileKind), DeclSaveLine, DeclSaveCol); + return true; + } + return false; + }; + + if (Token == TEXT("SL_IMPORT")) + { + const int32 ImportLine = DeclSaveLine; + FString Ns; + if (!ParseImport(S, Ns)) + { + return false; + } + bPendingImport = true; + PendingImportNamespace = MoveTemp(Ns); + PendingImportLine = ImportLine; + } + else if (Token == TEXT("SL_FUNCTION")) + { + if (!OutModel.bIsFunctionLibrary) + { + S.Error(TEXT("SL_FUNCTION is only allowed in a `.uslfunc` function library"), DeclSaveLine, DeclSaveCol); + return false; + } + ParseFunction(S, OutModel); + } + else if (Token == TEXT("SL_SETTINGS")) + { + if (RejectInLibrary(TEXT("SL_SETTINGS"))) { return false; } ParseSettings(S, OutModel); } else if (Token == TEXT("SL_PROPERTY")) @@ -1267,30 +1435,37 @@ bool FShaderLabParser::Parse( } else if (Token == TEXT("SL_SURFACE") || Token == TEXT("SL_POSTPROCESS") || Token == TEXT("SL_UI") || Token == TEXT("SL_VERTEX")) { + if (RejectInLibrary(TEXT("A pixel/vertex entry"))) { return false; } ParseEntry(S, OutModel, Token); } else if (Token == TEXT("SL_SLAB")) { + if (RejectInLibrary(TEXT("SL_SLAB"))) { return false; } ParseSlab(S, OutModel); } else if (Token == TEXT("SL_VALUE")) { + if (RejectInLibrary(TEXT("SL_VALUE"))) { return false; } ParseValue(S, OutModel); } else if (Token == TEXT("SL_INTERPOLATOR")) { + if (RejectInLibrary(TEXT("SL_INTERPOLATOR"))) { return false; } ParseInterpolator(S, OutModel); } else if (Token == TEXT("SL_FRONTMATERIAL")) { + if (RejectInLibrary(TEXT("SL_FRONTMATERIAL"))) { return false; } ParseFrontMaterial(S, OutModel); } else if (Token == TEXT("SL_OPACITY")) { + if (RejectInLibrary(TEXT("SL_OPACITY"))) { return false; } ParseMaterialOutput(S, OutModel.OpacityValueName, TEXT("SL_OPACITY")); } else if (Token == TEXT("SL_OPACITY_MASK")) { + if (RejectInLibrary(TEXT("SL_OPACITY_MASK"))) { return false; } ParseMaterialOutput(S, OutModel.OpacityMaskValueName, TEXT("SL_OPACITY_MASK")); } else @@ -1311,6 +1486,19 @@ bool FShaderLabParser::Parse( return false; } + if (bPendingImport) + { + S.Error(TEXT("SL_IMPORT at end of file has no `#include \"...uslfunc\"` to annotate")); + return false; + } + + // A function library has no pixel/vertex stage: skip all entry-point validation. Its Properties, + // Functions, Includes, Imports and LocalCode are the whole payload. + if (OutModel.bIsFunctionLibrary) + { + return true; + } + // 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) diff --git a/Source/UShaderLab/Public/ShaderLabImportResolver.h b/Source/UShaderLab/Public/ShaderLabImportResolver.h new file mode 100644 index 0000000..0f47b3f --- /dev/null +++ b/Source/UShaderLab/Public/ShaderLabImportResolver.h @@ -0,0 +1,85 @@ +// Copyright UShaderLab. All Rights Reserved. + +#pragma once + +#include "CoreMinimal.h" +#include "ShaderLabModel.h" + +/** + * Resolves the transitive `.uslfunc` import closure of a shader (or library) model: loads + parses each + * imported library, detects cycles / namespace conflicts / duplicate function names, and promotes every + * library's properties onto the consuming material (namespaced so same-named properties across libraries + * stay distinct). + * + * Pure model logic (no Engine/GPU dependency): the file bytes come in through an injected source loader, + * so this is unit-testable with in-memory sources and reused by the editor graph builder with a + * disk-backed loader. Promotion is an editor/cook-build concern (the cooked runtime never resolves + * imports — the MIC's baked shader map already carries the promoted parameters). + */ + +/** One library in the resolved closure. */ +struct USHADERLAB_API FShaderLabResolvedLibrary +{ + /** Canonical (lower-cased, forward-slashed) virtual path, used as the dedup key. */ + FString CanonicalPath; + /** Virtual path as first imported (for diagnostics / #line paths). */ + FString VirtualPath; + /** Effective promotion namespace ("" = merge promoted properties by bare name). */ + FString Namespace; + /** The parsed library model (its Functions/Includes/LocalCode are emitted by the graph builder). */ + FShaderLabModel Model; + + /** + * For every property visible in THIS library file (its own + all transitively imported libraries'), + * maps the bare in-HLSL identifier to the promoted material-parameter name that a function body's + * reference must be rewritten to. Texture properties also map the `Sampler` alias. StaticBool + * properties map to their bare name (they promote by name and reach bodies via the global `#define`). + */ + TMap PropRewrite; +}; + +struct USHADERLAB_API FShaderLabResolvedProgram +{ + /** + * Final material-parameter set: the root shader's own properties first, then every library's promoted + * properties (namespaced, deduped by promoted name). This replaces Model.Properties for parameter-node + * creation in the graph builder. + */ + TArray Properties; + + /** Libraries in dependency order (a library appears before any library that imports it). */ + TArray Libraries; + + /** Promoted-property name -> the source library index it came from (INDEX_NONE = the root shader). */ + TMap PropertyOwner; + + /** Function name -> owning library index (functions are unique across the whole closure). */ + TMap FunctionOwnerLibrary; + + /** Find a library function by name across the closure; returns nullptr (and INDEX_NONE) if absent. */ + const FShaderLabFunction* FindFunction(FName Name, int32& OutLibraryIndex) const; +}; + +class USHADERLAB_API FShaderLabImportResolver +{ +public: + /** + * Load the source text for a virtual `.uslfunc` path. Returns true and fills OutSource (and OutDiskPath, + * the absolute on-disk path used for `#line` error mapping) on success; on failure returns false and + * fills OutError. In tests the disk path may simply echo the virtual path. + */ + using FSourceLoader = TFunction; + + /** + * Resolve RootModel's transitive import closure. Returns true on success; on failure OutErrors is + * non-empty (each message is already prefixed for the shader-compile error path where relevant). + */ + static bool Resolve( + const FShaderLabModel& RootModel, + const FSourceLoader& Loader, + FShaderLabResolvedProgram& OutProgram, + TArray& OutErrors); + + /** The promoted material-parameter name for a property under a namespace (StaticBool ignores namespace). */ + static FName PromoteName(const FString& Namespace, const FShaderLabProperty& Prop); +}; diff --git a/Source/UShaderLab/Public/ShaderLabModel.h b/Source/UShaderLab/Public/ShaderLabModel.h index 766d84f..69a8c0b 100644 --- a/Source/UShaderLab/Public/ShaderLabModel.h +++ b/Source/UShaderLab/Public/ShaderLabModel.h @@ -124,6 +124,39 @@ struct USHADERLAB_API FShaderLabLocalCode int32 Line = 0; }; +/** + * A `SL_FUNCTION() (){...}` block in a `.uslfunc` function library. A library + * function is reusable HLSL that may use `UE_` intrinsics, reference the library's own properties, and + * call other library functions. The graph builder emits it (rewritten to take the referenced + * properties/intrinsics as trailing parameters) and injects the matching context at each call site. + */ +struct USHADERLAB_API FShaderLabFunction +{ + FName Name; + /** HLSL return type verbatim ("float3", "void", ...). */ + FString ReturnType; + /** Author-declared parameters verbatim (the text inside the signature `(...)`). */ + FString SignatureInner; + FString Body; + int32 BodyLine = 0; +}; + +/** + * A `#include ".uslfunc"` import of a function library, optionally annotated by a preceding + * `SL_IMPORT(Namespace = "...")` marker. Importing a library promotes its properties onto the consuming + * material and makes its functions callable. Namespacing decouples the promoted material-parameter name + * (identity/merge key) from the in-HLSL identifier, so same-named properties across libraries stay + * distinct. Present in both shaders and libraries (libraries may import other libraries). + */ +struct USHADERLAB_API FShaderLabImport +{ + /** Virtual path as written, e.g. "/Project/Lib/DetailLib.uslfunc". */ + FString Path; + /** From `SL_IMPORT(Namespace = "...")`; empty means merge promoted properties by bare name. */ + FString Namespace; + int32 Line = 0; +}; + /** Substrate topology operators (short DSL aliases mapping to engine Substrate expression nodes). */ enum class EShaderLabOp : uint8 { @@ -196,6 +229,24 @@ struct USHADERLAB_API FShaderLabModel TArray Properties; TArray Includes; + /** + * `.uslfunc` function-library imports (`#include "...uslfunc"`, optionally annotated by SL_IMPORT). + * Present in both shaders and libraries. Resolved (transitively) by the editor graph builder, which + * promotes each library's properties and emits its functions; the cooked runtime ignores them (the + * MIC's baked shader map already carries the promoted parameters). + */ + TArray Imports; + + /** + * True when this model was parsed from a `.uslfunc`: it is a reusable function library, NOT a renderable + * shader. It has no pixel/vertex entry and no base material is created for it; only its Properties, + * Functions, Includes, Imports and LocalCode are meaningful. + */ + bool bIsFunctionLibrary = false; + + /** Reusable functions (only in a `.uslfunc` library). */ + TArray Functions; + // 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). @@ -235,6 +286,9 @@ struct USHADERLAB_API FShaderLabModel /** Find an interpolator by name (nullptr if absent). */ const FShaderLabInterpolator* FindInterpolator(FName InName) const; + /** Find a library function by name (nullptr if absent). */ + const FShaderLabFunction* FindFunction(FName InName) const; + /** True if any property is a StaticBool (drives shader-map permutation count). */ bool HasStaticSwitches() const; }; diff --git a/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp b/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp index f3a991d..6283ed9 100644 --- a/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp +++ b/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp @@ -12,10 +12,13 @@ #include "Misc/Paths.h" #include "Modules/ModuleManager.h" #include "ShaderCore.h" +#include "Misc/FileHelper.h" #include "ShaderLabDiscovery.h" #include "ShaderLabGraphBuilder.h" +#include "ShaderLabImportResolver.h" #include "ShaderLabMaterialRegistry.h" #include "ShaderLabModel.h" +#include "ShaderLabParser.h" #include "ShaderLabSubsystem.h" #include "ShaderLabVSCodeButton.h" #include "UObject/Package.h" @@ -191,6 +194,78 @@ private: WatchedRoots.Reset(); } + /** Resolve a virtual shader path to disk via the registered shader-source directory mappings. */ + static bool ResolveVirtualToDisk(const FString& VirtualPath, FString& OutDiskPath) + { + FString Best, BestDir; + for (const TPair& Pair : AllShaderSourceDirectoryMappings()) + { + if ((VirtualPath.StartsWith(Pair.Key + TEXT("/")) || VirtualPath == Pair.Key) && Pair.Key.Len() > Best.Len()) + { + Best = Pair.Key; + BestDir = Pair.Value; + } + } + if (Best.IsEmpty()) { return false; } + FString Rest = VirtualPath.Mid(Best.Len()); + Rest.RemoveFromStart(TEXT("/")); + OutDiskPath = FPaths::Combine(BestDir, Rest); + return true; + } + + static FString NormalizePath(const FString& Path) + { + FString N = FPaths::ConvertRelativePathToFull(Path); + N.ReplaceInline(TEXT("\\"), TEXT("/")); + return N; + } + + /** + * Every discovered `.usl` shader whose transitive `.uslfunc` import closure contains ChangedDiskPath — + * i.e. the shaders that must be rebuilt when that library changes. Resolves each shader's imports so + * both direct and nested (library-imports-library) dependencies are caught. + */ + static TArray FindShadersImporting(const FString& ChangedDiskPathNormalized) + { + TArray Result; + for (const FString& Usl : FShaderLabDiscovery::FindShaderLabFiles()) + { + FString Source; + if (!FFileHelper::LoadFileToString(Source, *Usl)) + { + continue; + } + FShaderLabModel Model; + TArray ParseErrors; + if (!FShaderLabParser::Parse(Source, Usl, Model, ParseErrors) || Model.Imports.Num() == 0) + { + continue; + } + FShaderLabImportResolver::FSourceLoader Loader = + [](const FString& VPath, FString& OutSrc, FString& OutDisk, FString& OutErr) -> bool + { + if (!ResolveVirtualToDisk(VPath, OutDisk)) { OutErr = TEXT("unmapped"); return false; } + if (!FFileHelper::LoadFileToString(OutSrc, *OutDisk)) { OutErr = TEXT("read failed"); return false; } + return true; + }; + FShaderLabResolvedProgram Program; + TArray ResolveErrors; + if (!FShaderLabImportResolver::Resolve(Model, Loader, Program, ResolveErrors)) + { + continue; + } + for (const FShaderLabResolvedLibrary& Lib : Program.Libraries) + { + if (NormalizePath(Lib.Model.SourceFilePath) == ChangedDiskPathNormalized) + { + Result.Add(Usl); + break; + } + } + } + return Result; + } + static void OnDirectoryChanged(const TArray& Changes) { UShaderLabSubsystem* Subsystem = GEngine ? GEngine->GetEngineSubsystem() : nullptr; @@ -199,24 +274,35 @@ private: return; } TSet Rebuilt; + auto RebuildOnce = [&](const FString& Normalized) + { + if (Rebuilt.Contains(Normalized)) { return; } + Rebuilt.Add(Normalized); + Subsystem->RebuildFromFile(Normalized); + UE_LOG(LogShaderLabEditor, Log, TEXT("ShaderLab hot-reloaded '%s'"), *Normalized); + }; + 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)) + const FString Ext = FPaths::GetExtension(Change.Filename); + const FString Normalized = NormalizePath(Change.Filename); + if (Ext.Equals(TEXT("usl"), ESearchCase::IgnoreCase)) { - continue; + RebuildOnce(Normalized); + } + else if (Ext.Equals(TEXT("uslfunc"), ESearchCase::IgnoreCase)) + { + // A library changed: rebuild every shader that (transitively) imports it, so their promoted + // parameters and emitted function HLSL refresh — the dependency-aware hot reload. + for (const FString& Dependent : FindShadersImporting(Normalized)) + { + RebuildOnce(NormalizePath(Dependent)); + } } - Rebuilt.Add(Normalized); - Subsystem->RebuildFromFile(Normalized); - UE_LOG(LogShaderLabEditor, Log, TEXT("ShaderLab hot-reloaded '%s'"), *Normalized); } } diff --git a/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp b/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp index 0cd29d2..81afe2b 100644 --- a/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp +++ b/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp @@ -21,6 +21,7 @@ #include "Materials/MaterialExpressionVertexInterpolator.h" #include "MaterialExpressionShaderLabParameterAnchor.h" #include "ShaderLabIntrinsicRegistry.h" +#include "ShaderLabImportResolver.h" #include "ShaderLabRuntimeBuilder.h" #include "UObject/Class.h" #include "ShaderLabSettingsApplier.h" @@ -109,6 +110,32 @@ namespace ShaderLabGraph return Full; } + /** + * Resolve a virtual shader path ("/Project/Lib/X.uslfunc", "/Plugin/ShaderLab/...", "/Engine/...") to an + * absolute disk path via the editor's registered shader-source directory mappings (longest-prefix match). + * Returns false if no mapping root contains the path. Used to load `.uslfunc` imports off disk. + */ + static bool ResolveVirtualShaderFile(const FString& VirtualPath, FString& OutDiskPath) + { + FString Best, BestDir; + for (const TPair& Pair : AllShaderSourceDirectoryMappings()) + { + if ((VirtualPath.StartsWith(Pair.Key + TEXT("/")) || VirtualPath == Pair.Key) && Pair.Key.Len() > Best.Len()) + { + Best = Pair.Key; + BestDir = Pair.Value; + } + } + if (Best.IsEmpty()) + { + return false; + } + FString Rest = VirtualPath.Mid(Best.Len()); + Rest.RemoveFromStart(TEXT("/")); + OutDiskPath = FPaths::Combine(BestDir, Rest); + return true; + } + /** * 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 @@ -484,6 +511,561 @@ namespace ShaderLabGraph return bOk; } + // ===================================================================================================== + // Function-library emission (`.uslfunc` imports). + // + // A library function is reusable HLSL that may use `UE_` intrinsics, its library's promoted properties, + // and other library functions. Custom nodes are opaque HLSL blocks — a node-local value can't be wired + // out to another node — so we can't turn a library function into a separate graph node. Instead each + // function is emitted to the shader's generated `.gen.ush`, REWRITTEN to take the properties/intrinsics + // it (transitively) needs as trailing parameters, and every call site (in a body or in another function) + // is rewritten to pass those. The consuming Custom node wires the matching parameter/intrinsic nodes as + // node-local inputs so they are available to pass in. Everything downstream works in the promoted-name + // space produced by the resolver. + // ===================================================================================================== + + /** One `UE_Name(args)` intrinsic use pulled out of a function body. */ + struct FIntrinsicUse + { + FString Name; + FString ArgsRaw; + FString ArgSig; // identifier-safe signature of ArgsRaw (matches EmitIntrinsics' naming) + }; + + /** The Custom-node input / function-parameter variable name for an intrinsic use. */ + static FString MakeIntrinsicVar(const FString& Name, const FString& ArgSig) + { + return FString(TEXT("SLI_")) + Name + (ArgSig.IsEmpty() ? TEXT("") : (FString(TEXT("_")) + ArgSig)); + } + + /** Transitive context a library function needs: promoted property names + intrinsic uses (stable order). */ + struct FFunctionCtx + { + TArray ReqProps; + TArray ReqIntr; + int32 State = 0; // 0 = not computed, 1 = computing (recursion guard), 2 = done + }; + + /** Resolved program + per-function context, computed once per BuildInto. */ + struct FLibraryEmit + { + const FShaderLabResolvedProgram* Program = nullptr; + TMap Ctx; + bool HasLibraries() const { return Program && Program->Libraries.Num() > 0; } + }; + + /** + * Create the backing expression node for an intrinsic use (registry lookup + stage/arg validation). + * Mirrors EmitIntrinsics' registry path; used to wire intrinsics that a called library function needs + * but that do not appear literally in the calling body. Returns false + OutError on any violation. + */ + static bool CreateIntrinsicNode(UMaterial& Material, const FString& Name, const FString& ArgsRaw, + EShaderLabIntrinsicFrequency Stage, UMaterialExpression*& OutExpr, int32& OutOutputIndex, FString& OutError) + { + const FShaderLabIntrinsicRegistry& Registry = FShaderLabIntrinsicRegistry::Get(); + const FShaderLabIntrinsicDesc* Desc = Registry.Find(FName(*Name)); + if (!Desc) + { + OutError = FString::Printf(TEXT("unknown intrinsic 'UE_%s'"), *Name); + return false; + } + if (Desc->Frequency == EShaderLabIntrinsicFrequency::PixelOnly && Stage == EShaderLabIntrinsicFrequency::VertexOnly) + { + OutError = FString::Printf(TEXT("intrinsic 'UE_%s' is pixel-only and cannot be used in a Vertex body"), *Name); + return false; + } + if (Desc->Frequency == EShaderLabIntrinsicFrequency::VertexOnly && Stage == EShaderLabIntrinsicFrequency::PixelOnly) + { + OutError = FString::Printf(TEXT("intrinsic 'UE_%s' is vertex-only and cannot be used in a pixel body"), *Name); + return false; + } + const TArray CallArgs = SplitArgs(ArgsRaw); + if (CallArgs.Num() > Desc->Params.Num()) + { + OutError = FString::Printf(TEXT("intrinsic 'UE_%s' takes at most %d argument(s), got %d"), *Name, Desc->Params.Num(), CallArgs.Num()); + return false; + } + for (int32 a = 0; a < CallArgs.Num(); ++a) + { + const FShaderLabIntrinsicParam& P = Desc->Params[a]; + if (P.Enum) + { + if (P.Enum->GetValueByNameString(CallArgs[a]) == INDEX_NONE) + { + OutError = FString::Printf(TEXT("intrinsic 'UE_%s' argument '%s' must be a %s token, got '%s'"), *Name, *P.Name, *P.Enum->GetName(), *CallArgs[a]); + return false; + } + } + else if (!IsNumericLiteral(CallArgs[a])) + { + OutError = FString::Printf(TEXT("intrinsic 'UE_%s' argument '%s' must be a compile-time constant, got '%s'"), *Name, *P.Name, *CallArgs[a]); + return false; + } + } + FString MakeError; + UMaterialExpression* Expr = Desc->MakeNode(Material, CallArgs, MakeError); + if (!Expr) + { + OutError = FString::Printf(TEXT("intrinsic 'UE_%s': %s"), *Name, MakeError.IsEmpty() ? TEXT("failed to create node") : *MakeError); + return false; + } + OutExpr = Expr; + OutOutputIndex = Desc->OutputIndex; + return true; + } + + /** The HLSL parameter type token for a promoted property (textures also emit a paired sampler). */ + static void AppendPropParam(const FShaderLabProperty& Prop, TArray& OutParams) + { + const FString Name = Prop.Name.ToString(); + switch (Prop.Type) + { + case EShaderLabPropertyType::Scalar: OutParams.Add(FString::Printf(TEXT("float %s"), *Name)); break; + case EShaderLabPropertyType::Color: OutParams.Add(FString::Printf(TEXT("float3 %s"), *Name)); break; + case EShaderLabPropertyType::Vector: OutParams.Add(FString::Printf(TEXT("float4 %s"), *Name)); break; + case EShaderLabPropertyType::Texture2D: + OutParams.Add(FString::Printf(TEXT("Texture2D %s"), *Name)); + OutParams.Add(FString::Printf(TEXT("SamplerState %sSampler"), *Name)); + break; + case EShaderLabPropertyType::TextureCube: + OutParams.Add(FString::Printf(TEXT("TextureCube %s"), *Name)); + OutParams.Add(FString::Printf(TEXT("SamplerState %sSampler"), *Name)); + break; + default: break; // StaticBool never becomes a parameter (reaches bodies via the global #define). + } + } + + /** The call-site argument(s) for a promoted property (textures pass texture + sampler). */ + static void AppendPropArg(const FShaderLabProperty& Prop, TArray& OutArgs) + { + const FString Name = Prop.Name.ToString(); + OutArgs.Add(Name); + if (Prop.Type == EShaderLabPropertyType::Texture2D || Prop.Type == EShaderLabPropertyType::TextureCube) + { + OutArgs.Add(Name + TEXT("Sampler")); + } + } + + /** + * Build the trailing parameter-declaration list (bArgs=false) or call-argument list (bArgs=true) for a + * function context. Iterated in the same stable order for both so params and args line up positionally. + */ + static FString BuildTrailing(const FFunctionCtx& Ctx, const FShaderLabResolvedProgram& Program, bool bArgs) + { + TArray Parts; + for (const FName& PropName : Ctx.ReqProps) + { + const FShaderLabProperty* Prop = Program.Properties.FindByPredicate( + [PropName](const FShaderLabProperty& P) { return P.Name == PropName; }); + if (!Prop) + { + continue; // Defensive: resolver guarantees membership; skip if somehow absent. + } + if (bArgs) { AppendPropArg(*Prop, Parts); } + else { AppendPropParam(*Prop, Parts); } + } + for (const FIntrinsicUse& Use : Ctx.ReqIntr) + { + const FString Var = MakeIntrinsicVar(Use.Name, Use.ArgSig); + if (bArgs) + { + Parts.Add(Var); + } + else + { + const FShaderLabIntrinsicDesc* Desc = FShaderLabIntrinsicRegistry::Get().Find(FName(*Use.Name)); + const FString Type = (Desc && !Desc->ReturnType.IsEmpty()) ? Desc->ReturnType : FString(TEXT("float")); + Parts.Add(FString::Printf(TEXT("%s %s"), *Type, *Var)); + } + } + return FString::Join(Parts, TEXT(", ")); + } + + /** True at an identifier start position (previous char is not part of an identifier). */ + static bool IsIdentBoundary(const FString& B, int32 i) + { + auto IsIdent = [](TCHAR C) { return FChar::IsAlnum(C) || C == TEXT('_'); }; + return (i == 0 || !IsIdent(B[i - 1])) && (IsIdent(B[i]) || B[i] == TEXT('_')); + } + + /** Index just past an identifier starting at i (assumes IsIdentBoundary(B,i)). */ + static int32 IdentEnd(const FString& B, int32 i) + { + auto IsIdent = [](TCHAR C) { return FChar::IsAlnum(C) || C == TEXT('_'); }; + int32 j = i; + while (j < B.Len() && IsIdent(B[j])) { ++j; } + return j; + } + + /** First non-whitespace index at/after i (B.Len() if none). */ + static int32 SkipSpace(const FString& B, int32 i) + { + while (i < B.Len() && FChar::IsWhitespace(B[i])) { ++i; } + return i; + } + + /** Scan a function body for its DIRECT promoted-property refs, intrinsic uses, and callee names. */ + static bool ScanDirectRefs(const FString& Body, const FShaderLabResolvedLibrary& OwnerLib, + const FShaderLabResolvedProgram& Program, const FString& SrcPath, int32 BodyLine, + TSet& OutProps, TArray& OutIntr, TSet& OutCallees, TArray& OutErrors) + { + const FShaderLabIntrinsicRegistry& Registry = FShaderLabIntrinsicRegistry::Get(); + + // Promoted properties visible in this library are found by whole-word reference. + for (const TPair& Pair : OwnerLib.PropRewrite) + { + if (Program.PropertyOwner.Contains(Pair.Value) && ReferencesToken(Body, Pair.Key.ToString())) + { + OutProps.Add(Pair.Value); + } + } + + const int32 Len = Body.Len(); + int32 i = 0; + bool bOk = true; + while (i < Len) + { + const TCHAR C = Body[i]; + // Skip comments and string/char literals so tokens inside them are not treated as code. + if (C == TEXT('/') && i + 1 < Len && Body[i + 1] == TEXT('/')) { while (i < Len && Body[i] != TEXT('\n')) { ++i; } continue; } + if (C == TEXT('/') && i + 1 < Len && Body[i + 1] == TEXT('*')) { i += 2; while (i + 1 < Len && !(Body[i] == TEXT('*') && Body[i + 1] == TEXT('/'))) { ++i; } i += 2; continue; } + if (C == TEXT('"') || C == TEXT('\'')) { const TCHAR Q = C; ++i; while (i < Len && Body[i] != Q) { if (Body[i] == TEXT('\\')) { ++i; } ++i; } ++i; continue; } + if (!IsIdentBoundary(Body, i)) { ++i; continue; } + + const int32 e = IdentEnd(Body, i); + const FString Ident = Body.Mid(i, e - i); + const int32 paren = SkipSpace(Body, e); + const bool bCall = (paren < Len && Body[paren] == TEXT('(')); + + if (bCall && Ident.StartsWith(TEXT("UE_"))) + { + const FString Name = Ident.Mid(3); + if (Name == TEXT("Interpolator")) + { + OutErrors.Add(FString::Printf(TEXT("%s(%d,1): error: UE_Interpolator cannot be used inside a library function"), *SrcPath, FMath::Max(BodyLine, 1))); + bOk = false; + i = e; + continue; + } + if (Registry.Find(FName(*Name))) + { + // Registry intrinsic: read its balanced (...) and record the use (args are literals). + int32 depth = 0, m = paren; + for (; m < Len; ++m) { if (Body[m] == TEXT('(')) { ++depth; } else if (Body[m] == TEXT(')')) { if (--depth == 0) { break; } } } + const FString ArgsRaw = (m < Len) ? Body.Mid(paren + 1, m - (paren + 1)) : FString(); + const FString ArgSig = MakeArgSig(ArgsRaw); + if (!OutIntr.ContainsByPredicate([&](const FIntrinsicUse& U) { return U.Name == Name && U.ArgSig == ArgSig; })) + { + OutIntr.Add(FIntrinsicUse{ Name, ArgsRaw, ArgSig }); + } + i = (m < Len) ? m + 1 : e; + continue; + } + // Unknown UE_ (an HLSL library helper like UE_Noise): leave it; keep scanning its args. + i = e; + continue; + } + + if (bCall && Program.FunctionOwnerLibrary.Contains(FName(*Ident))) + { + OutCallees.Add(FName(*Ident)); + } + i = e; + } + return bOk; + } + + /** Compute (memoized) the transitive context of a function; detects recursion (illegal in HLSL). */ + static bool ComputeFunctionCtx(const FName FuncName, FLibraryEmit& Emit, TArray& OutErrors) + { + FFunctionCtx& Ctx = Emit.Ctx.FindOrAdd(FuncName); + if (Ctx.State == 2) { return true; } + if (Ctx.State == 1) + { + OutErrors.Add(FString::Printf(TEXT("ShaderLab: recursive library function call involving '%s' (not allowed)"), *FuncName.ToString())); + return false; + } + Ctx.State = 1; + + int32 LibIdx = INDEX_NONE; + const FShaderLabFunction* Fn = Emit.Program->FindFunction(FuncName, LibIdx); + check(Fn); // caller only asks for known functions + const FShaderLabResolvedLibrary& Lib = Emit.Program->Libraries[LibIdx]; + const FString SrcPath = MakeLineDirectivePath(Lib.Model.SourceFilePath); + + TSet DirectProps, DirectCallees; + TArray DirectIntr; + if (!ScanDirectRefs(Fn->Body, Lib, *Emit.Program, SrcPath, Fn->BodyLine, DirectProps, DirectIntr, DirectCallees, OutErrors)) + { + return false; + } + + TSet Props = DirectProps; + TArray Intr = DirectIntr; + for (const FName& Callee : DirectCallees) + { + if (!ComputeFunctionCtx(Callee, Emit, OutErrors)) + { + return false; + } + // Re-find after potential rehash of the map from recursive FindOrAdd. + const FFunctionCtx& CalleeCtx = Emit.Ctx[Callee]; + for (const FName& P : CalleeCtx.ReqProps) { Props.Add(P); } + for (const FIntrinsicUse& U : CalleeCtx.ReqIntr) + { + if (!Intr.ContainsByPredicate([&](const FIntrinsicUse& X) { return X.Name == U.Name && X.ArgSig == U.ArgSig; })) + { + Intr.Add(U); + } + } + } + + // Stable deterministic order (params and args must agree across all emission sites). + TArray SortedProps = Props.Array(); + SortedProps.Sort([](const FName& A, const FName& B) { return A.LexicalLess(B); }); + Intr.Sort([](const FIntrinsicUse& A, const FIntrinsicUse& B) + { + return (A.Name + TEXT("|") + A.ArgSig) < (B.Name + TEXT("|") + B.ArgSig); + }); + + FFunctionCtx& Store = Emit.Ctx.FindOrAdd(FuncName); + Store.ReqProps = MoveTemp(SortedProps); + Store.ReqIntr = MoveTemp(Intr); + Store.State = 2; + return true; + } + + /** + * Append each library function call's trailing context arguments (the callee's required promoted + * properties + intrinsic variables) just before its closing paren. Used on both rewritten library + * function bodies and consuming shader bodies. Comments and string literals are skipped. + */ + static FString InjectCalleeArgs(const FString& P1, const FLibraryEmit& Emit) + { + if (!Emit.HasLibraries()) + { + return P1; + } + FString Out; + Out.Reserve(P1.Len()); + const int32 L2 = P1.Len(); + struct FParenInfo { FString Trailing; bool bHasArgs = false; }; + TArray Stack; + int32 k = 0; + while (k < L2) + { + const TCHAR C = P1[k]; + if (C == TEXT('/') && k + 1 < L2 && P1[k + 1] == TEXT('/')) { const int32 s = k; while (k < L2 && P1[k] != TEXT('\n')) { ++k; } Out += P1.Mid(s, k - s); continue; } + if (C == TEXT('/') && k + 1 < L2 && P1[k + 1] == TEXT('*')) { const int32 s = k; k += 2; while (k + 1 < L2 && !(P1[k] == TEXT('*') && P1[k + 1] == TEXT('/'))) { ++k; } k = FMath::Min(k + 2, L2); Out += P1.Mid(s, k - s); continue; } + if (C == TEXT('"') || C == TEXT('\'')) { const int32 s = k; const TCHAR Q = C; ++k; while (k < L2 && P1[k] != Q) { if (P1[k] == TEXT('\\')) { ++k; } ++k; } ++k; Out += P1.Mid(s, FMath::Min(k, L2) - s); if (Stack.Num()) { Stack.Last().bHasArgs = true; } continue; } + + if (IsIdentBoundary(P1, k)) + { + const int32 e = IdentEnd(P1, k); + const FString Ident = P1.Mid(k, e - k); + const int32 paren = SkipSpace(P1, e); + if (paren < L2 && P1[paren] == TEXT('(') && Emit.Program->FunctionOwnerLibrary.Contains(FName(*Ident))) + { + Out += P1.Mid(k, (paren + 1) - k); // identifier + spaces + '(' + FParenInfo Info; + Info.Trailing = BuildTrailing(Emit.Ctx[FName(*Ident)], *Emit.Program, /*bArgs*/ true); + Stack.Add(Info); + if (Stack.Num() > 1) { Stack[Stack.Num() - 2].bHasArgs = true; } + k = paren + 1; + continue; + } + if (Stack.Num()) { Stack.Last().bHasArgs = true; } + Out += Ident; + k = e; + continue; + } + + if (C == TEXT('(')) + { + Stack.Add(FParenInfo()); + if (Stack.Num() > 1) { Stack[Stack.Num() - 2].bHasArgs = true; } + Out.AppendChar(C); + ++k; + continue; + } + if (C == TEXT(')')) + { + FParenInfo Info = Stack.Num() ? Stack.Pop() : FParenInfo(); + if (!Info.Trailing.IsEmpty()) + { + Out += Info.bHasArgs ? (FString(TEXT(", ")) + Info.Trailing) : Info.Trailing; + } + Out.AppendChar(C); + ++k; + continue; + } + if (!FChar::IsWhitespace(C) && Stack.Num()) { Stack.Last().bHasArgs = true; } + Out.AppendChar(C); + ++k; + } + return Out; + } + + /** + * Rewrite a library function body: promoted-property refs and registry-intrinsic calls are renamed + * (to their promoted / SLI variable names), then each call to another library function gets the callee's + * trailing context arguments appended. Comments and string literals are skipped. + */ + static FString RewriteFunctionBody(const FString& Body, const FShaderLabResolvedLibrary& OwnerLib, + const FLibraryEmit& Emit) + { + const FShaderLabIntrinsicRegistry& Registry = FShaderLabIntrinsicRegistry::Get(); + const int32 Len = Body.Len(); + + // Pass 1: rename properties (bare -> promoted) and registry intrinsics (UE_X(...) -> SLI var). + FString P1; + P1.Reserve(Len); + int32 i = 0; + while (i < Len) + { + const TCHAR C = Body[i]; + if (C == TEXT('/') && i + 1 < Len && Body[i + 1] == TEXT('/')) { const int32 s = i; while (i < Len && Body[i] != TEXT('\n')) { ++i; } P1 += Body.Mid(s, i - s); continue; } + if (C == TEXT('/') && i + 1 < Len && Body[i + 1] == TEXT('*')) { const int32 s = i; i += 2; while (i + 1 < Len && !(Body[i] == TEXT('*') && Body[i + 1] == TEXT('/'))) { ++i; } i = FMath::Min(i + 2, Len); P1 += Body.Mid(s, i - s); continue; } + if (C == TEXT('"') || C == TEXT('\'')) { const int32 s = i; const TCHAR Q = C; ++i; while (i < Len && Body[i] != Q) { if (Body[i] == TEXT('\\')) { ++i; } ++i; } ++i; P1 += Body.Mid(s, FMath::Min(i, Len) - s); continue; } + if (!IsIdentBoundary(Body, i)) { P1.AppendChar(C); ++i; continue; } + + const int32 e = IdentEnd(Body, i); + const FString Ident = Body.Mid(i, e - i); + const int32 paren = SkipSpace(Body, e); + const bool bCall = (paren < Len && Body[paren] == TEXT('(')); + + if (bCall && Ident.StartsWith(TEXT("UE_"))) + { + const FString Name = Ident.Mid(3); + if (Registry.Find(FName(*Name))) + { + int32 depth = 0, m = paren; + for (; m < Len; ++m) { if (Body[m] == TEXT('(')) { ++depth; } else if (Body[m] == TEXT(')')) { if (--depth == 0) { break; } } } + const FString ArgsRaw = (m < Len) ? Body.Mid(paren + 1, m - (paren + 1)) : FString(); + P1 += MakeIntrinsicVar(Name, MakeArgSig(ArgsRaw)); + i = (m < Len) ? m + 1 : e; + continue; + } + // Unknown UE_ helper: keep the identifier, keep scanning its args. + P1 += Ident; + i = e; + continue; + } + + // Don't rewrite a member access (`s.Contrast`, `p->Contrast`): only a bare identifier is the + // promoted property. Member fields that happen to share a property's name must be left alone. + const bool bMemberAccess = (i > 0 && Body[i - 1] == TEXT('.')) + || (i > 1 && Body[i - 1] == TEXT('>') && Body[i - 2] == TEXT('-')); + if (!bMemberAccess) + { + if (const FName* Promoted = OwnerLib.PropRewrite.Find(FName(*Ident))) + { + P1 += Promoted->ToString(); + i = e; + continue; + } + } + P1 += Ident; + i = e; + } + + // Pass 2: append trailing context args to each call of a library function. + return InjectCalleeArgs(P1, Emit); + } + + /** Collect the names of library functions called (syntactically `Name(`) in a body. */ + static void CollectCalledFunctions(const FString& Body, const FShaderLabResolvedProgram& Program, TSet& Out) + { + const int32 Len = Body.Len(); + for (int32 i = 0; i < Len; ) + { + if (!IsIdentBoundary(Body, i)) { ++i; continue; } + const int32 e = IdentEnd(Body, i); + const int32 paren = SkipSpace(Body, e); + if (paren < Len && Body[paren] == TEXT('(')) + { + const FName Ident(*Body.Mid(i, e - i)); + if (Program.FunctionOwnerLibrary.Contains(Ident)) { Out.Add(Ident); } + } + i = e; + } + } + + /** + * Prepare a consuming body (Surface/Slab/Value/Interpolator/Vertex): rewrite calls to library functions + * (appending their context args), emit the body's own UE_ intrinsics, and additionally create+wire the + * intrinsics that called functions need (stage-validated). Fills OutWires (intrinsic inputs) and + * OutReqProps (promoted property names the called functions need, which the caller also wires). + */ + static bool PrepareBody( + UMaterial& Material, EShaderLabIntrinsicFrequency Stage, + FString& InOutBody, int32 BodyLine, const FString& SrcPath, + const FLibraryEmit& Emit, + TArray& OutWires, TSet& OutReqProps, + const TMap& InterpByName, TSet& UsedInterps, + TArray& OutErrors) + { + // Which library functions does this body call? (Scan the original body — function names are not + // touched by intrinsic substitution, so the result is order-independent.) + TSet Called; + if (Emit.HasLibraries()) + { + CollectCalledFunctions(InOutBody, *Emit.Program, Called); + } + + // The body's own UE_ intrinsics FIRST: EmitIntrinsics space-pads its substitutions so compile-error + // columns map accurately, and it reports offsets against THIS body — so it must run before callee-arg + // injection (which inserts text mid-line and would shift those columns). + if (!EmitIntrinsics(Material, Stage, InOutBody, BodyLine, SrcPath, OutWires, InterpByName, UsedInterps, OutErrors)) + { + return false; + } + + // Now rewrite each library-function call to pass the context args the callee needs. Safe to run + // after EmitIntrinsics: it only touches `Name(` for library functions, and appends already-final + // promoted/SLI identifiers. + if (Called.Num() > 0) + { + InOutBody = InjectCalleeArgs(InOutBody, Emit); + } + + // Add intrinsics/props required by called functions (deduped against what the body already wired). + bool bOk = true; + for (const FName& Callee : Called) + { + const FFunctionCtx& Ctx = Emit.Ctx[Callee]; + for (const FName& P : Ctx.ReqProps) { OutReqProps.Add(P); } + + // Map a validation failure back to the library that actually contains the offending intrinsic, + // not this calling body (the intrinsic literal lives in the callee's source, not here). + int32 CalleeLib = INDEX_NONE; + Emit.Program->FindFunction(Callee, CalleeLib); + const FString CalleeSrc = (CalleeLib != INDEX_NONE) + ? MakeLineDirectivePath(Emit.Program->Libraries[CalleeLib].Model.SourceFilePath) : SrcPath; + + for (const FIntrinsicUse& Use : Ctx.ReqIntr) + { + const FName InputName(*MakeIntrinsicVar(Use.Name, Use.ArgSig)); + if (OutWires.ContainsByPredicate([&](const FIntrinsicWire& W) { return W.InputName == InputName; })) + { + continue; + } + UMaterialExpression* Expr = nullptr; + int32 OutIdx = 0; + FString Err; + if (!CreateIntrinsicNode(Material, Use.Name, Use.ArgsRaw, Stage, Expr, OutIdx, Err)) + { + OutErrors.Add(FString::Printf(TEXT("%s(1,1): error: %s (in library function '%s', reached from %s)"), + *CalleeSrc, *Err, *Callee.ToString(), *FPaths::GetCleanFilename(SrcPath))); + bOk = false; + continue; + } + OutWires.Add(FIntrinsicWire{ InputName, Expr, OutIdx }); + } + } + return bOk; + } + /** A created property parameter node (shared across all slabs/values that reference it). */ struct FParamNode { @@ -491,6 +1073,35 @@ namespace ShaderLabGraph bool bIsTexture = false; }; + /** + * Wire the promoted-property parameter nodes a body needs onto its Custom node: every non-StaticBool + * property that is either referenced literally in the body OR required by a called library function. + * Iterating the (deduped) promoted property set once means a property that is both never doubles up. + */ + static void WirePropInputs(UMaterialExpressionCustom& Custom, const FShaderLabResolvedProgram& Program, + const TMap& PropertyNodes, const FString& InBody, const TSet& ReqProps) + { + for (const FShaderLabProperty& Prop : Program.Properties) + { + if (Prop.Type == EShaderLabPropertyType::StaticBool) + { + continue; + } + if (!ReqProps.Contains(Prop.Name) && !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); + } + } + } + /** Map an SL_INTERPOLATOR return type to a Custom-node output type (parser guarantees float1..4). */ static ECustomMaterialOutputType InterpolatorOutputType(const FString& ReturnType) { @@ -511,10 +1122,14 @@ namespace ShaderLabGraph return Out.IsEmpty() ? FString(TEXT("Unnamed")) : Out; } - /** Virtual `#include` path for a shader's generated local-code header (empty if it has no local code). */ + /** + * Virtual `#include` path for a shader's generated header (free local code + emitted library functions), + * or empty when the shader has neither local code nor `.uslfunc` imports. Gated on Model alone so the + * node-include side (AddIncludes) and the file-writing side (WriteLocalCodeInclude) always agree. + */ static FString LocalCodeVirtualPath(const FShaderLabModel& Model) { - if (Model.LocalCode.Num() == 0) + if (Model.LocalCode.Num() == 0 && Model.Imports.Num() == 0) { return FString(); } @@ -574,18 +1189,56 @@ namespace ShaderLabGraph * #includes this file, so helpers/structs/globals are defined-before-use for all pixel/vertex bodies * regardless of the translator's per-node compile order. No-op when the shader has no local code. */ - static bool WriteLocalCodeInclude(const FShaderLabModel& Model, TArray& OutErrors) + /** Join an author signature with the trailing context params/args (handles the empty-signature case). */ + static FString JoinSignature(const FString& SignatureInner, const FString& Trailing) { - if (Model.LocalCode.Num() == 0) + const FString Sig = SignatureInner.TrimStartAndEnd(); + if (Trailing.IsEmpty()) { return Sig; } + if (Sig.IsEmpty()) { return Trailing; } + return Sig + TEXT(", ") + Trailing; + } + + static bool WriteLocalCodeInclude(const FShaderLabModel& Model, const FLibraryEmit& Emit, TArray& OutErrors) + { + if (Model.LocalCode.Num() == 0 && !Emit.HasLibraries()) { return true; } const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); FString Content; - Content += FString::Printf(TEXT("// Generated by ShaderLab from %s.usl - do not edit.\n"), *Model.ShaderName); + Content += FString::Printf(TEXT("// Generated by ShaderLab from %s - do not edit.\n"), *Model.ShaderName); Content += TEXT("#pragma once\n"); Content += FString::Printf(TEXT("#include \"%s\"\n"), SHADERLAB_COMMON_INCLUDE); Content += FString::Printf(TEXT("#include \"%s\"\n"), SHADERLAB_FUNCTIONS_INCLUDE); + + // Imported library dependencies: real `.ush` includes, then library free HLSL (in dependency order), + // so library functions below see their own helpers/structs/globals. + if (Emit.HasLibraries()) + { + TSet SeenIncludes; + for (const FShaderLabResolvedLibrary& Lib : Emit.Program->Libraries) + { + for (const FString& Inc : Lib.Model.Includes) + { + if (!Inc.IsEmpty() && !SeenIncludes.Contains(Inc)) + { + SeenIncludes.Add(Inc); + Content += FString::Printf(TEXT("#include \"%s\"\n"), *Inc); + } + } + } + for (const FShaderLabResolvedLibrary& Lib : Emit.Program->Libraries) + { + const FString LibSrc = MakeLineDirectivePath(Lib.Model.SourceFilePath); + for (const FShaderLabLocalCode& LC : Lib.Model.LocalCode) + { + Content += FString::Printf(TEXT("#line %d \"%s\"\n"), FMath::Max(LC.Line, 1), *LibSrc); + Content += LC.Text; + Content += TEXT("\n"); + } + } + } + for (const FShaderLabLocalCode& LC : Model.LocalCode) { Content += FString::Printf(TEXT("#line %d \"%s\"\n"), FMath::Max(LC.Line, 1), *SrcPath); @@ -593,6 +1246,37 @@ namespace ShaderLabGraph Content += TEXT("\n"); } + // Library functions actually reached from the shader's bodies (Emit.Ctx holds exactly those). Emit + // prototypes for all first so intra-/cross-library calls resolve regardless of declaration order, + // then the rewritten definitions in dependency order. + if (Emit.HasLibraries()) + { + Content += TEXT("#line 1 \"ShaderLabGenerated.ush\"\n"); + for (const FShaderLabResolvedLibrary& Lib : Emit.Program->Libraries) + { + for (const FShaderLabFunction& Fn : Lib.Model.Functions) + { + const FFunctionCtx* Ctx = Emit.Ctx.Find(Fn.Name); + if (!Ctx || Ctx->State != 2) { continue; } + const FString Sig = JoinSignature(Fn.SignatureInner, BuildTrailing(*Ctx, *Emit.Program, /*bArgs*/ false)); + Content += FString::Printf(TEXT("%s %s(%s);\n"), *Fn.ReturnType, *Fn.Name.ToString(), *Sig); + } + } + for (const FShaderLabResolvedLibrary& Lib : Emit.Program->Libraries) + { + const FString LibSrc = MakeLineDirectivePath(Lib.Model.SourceFilePath); + for (const FShaderLabFunction& Fn : Lib.Model.Functions) + { + const FFunctionCtx* Ctx = Emit.Ctx.Find(Fn.Name); + if (!Ctx || Ctx->State != 2) { continue; } + const FString Sig = JoinSignature(Fn.SignatureInner, BuildTrailing(*Ctx, *Emit.Program, /*bArgs*/ false)); + const FString RewrittenBody = RewriteFunctionBody(Fn.Body, Lib, Emit); + Content += FString::Printf(TEXT("%s %s(%s)\n{\n%s}\n"), *Fn.ReturnType, *Fn.Name.ToString(), *Sig, + *WrapBodyWithLineMapping(RewrittenBody, Fn.BodyLine, LibSrc)); + } + } + } + const FString GenDir = FShaderLabGraphBuilder::GetGeneratedShaderDir(); if (GenDir.IsEmpty()) { @@ -643,6 +1327,7 @@ namespace ShaderLabGraph static UMaterialExpressionSubstrateSlabBSDF* BuildSlab( UMaterial& Material, UMaterialEditorOnlyData& EditorOnly, const FString& OutParamName, const FString& InBody, int32 BodyLine, const FShaderLabModel& Model, + const FShaderLabResolvedProgram& Program, const FLibraryEmit& Emit, const TMap& PropertyNodes, const TMap& InterpByName, TSet& UsedInterps, bool bAllowMaterialOutputs, int32& IoY, TArray& OutErrors) @@ -672,7 +1357,8 @@ namespace ShaderLabGraph FString Body = InBody; TArray Wires; - if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Wires, InterpByName, UsedInterps, OutErrors)) + TSet ReqProps; + if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, Wires, ReqProps, InterpByName, UsedInterps, OutErrors)) { return nullptr; } @@ -683,21 +1369,7 @@ namespace ShaderLabGraph 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); - } - } + WirePropInputs(*Custom, Program, PropertyNodes, InBody, ReqProps); const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); FString Code = FString::Printf(TEXT("FShaderLabSurface %s = ShaderLabDefaultSurface();\n{\n%s}\n"), @@ -754,6 +1426,7 @@ namespace ShaderLabGraph 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 FShaderLabResolvedProgram& Program, const FLibraryEmit& Emit, const TMap& PropertyNodes, const TMap& InterpByName, TSet& UsedInterps, int32& IoY, TArray& OutErrors) @@ -765,7 +1438,8 @@ namespace ShaderLabGraph FString Body = InBody; TArray Wires; - if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Wires, InterpByName, UsedInterps, OutErrors)) + TSet ReqProps; + if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, Wires, ReqProps, InterpByName, UsedInterps, OutErrors)) { return false; } @@ -776,21 +1450,7 @@ namespace ShaderLabGraph 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); - } - } + WirePropInputs(*Custom, Program, PropertyNodes, InBody, ReqProps); const bool bUsesColor = ReferencesToken(InBody, OutParamName + TEXT(".Color")); const bool bUsesOpacity = ReferencesToken(InBody, OutParamName + TEXT(".Opacity")); @@ -828,6 +1488,7 @@ namespace ShaderLabGraph /** 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 FShaderLabResolvedProgram& Program, const FLibraryEmit& Emit, const TMap& PropertyNodes, const TMap& InterpByName, TSet& UsedInterps, int32& IoY, TArray& OutErrors) @@ -839,7 +1500,8 @@ namespace ShaderLabGraph FString Body = Value.Body; TArray Wires; - if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, Value.BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Wires, InterpByName, UsedInterps, OutErrors)) + TSet ReqProps; + if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, Value.BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, Wires, ReqProps, InterpByName, UsedInterps, OutErrors)) { return nullptr; } @@ -850,21 +1512,7 @@ namespace ShaderLabGraph 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); - } - } + WirePropInputs(*Custom, Program, PropertyNodes, Value.Body, ReqProps); // The body itself contains `return ;`, so it is the Custom function's body directly. const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); @@ -880,6 +1528,7 @@ namespace ShaderLabGraph */ static UMaterialExpression* BuildInterpolatorNode( UMaterial& Material, const FShaderLabInterpolator& Interp, const FShaderLabModel& Model, + const FShaderLabResolvedProgram& Program, const FLibraryEmit& Emit, const TMap& PropertyNodes, int32& IoY, TArray& OutErrors) { UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -600); @@ -890,9 +1539,10 @@ namespace ShaderLabGraph // Vertex frequency: pixel-only intrinsics (incl. UE_Interpolator) are rejected. No interpolator map. FString Body = Interp.Body; TArray Wires; + TSet ReqProps; const TMap EmptyInterp; TSet IgnoredUsed; - if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::VertexOnly, Body, Interp.BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Wires, EmptyInterp, IgnoredUsed, OutErrors)) + if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::VertexOnly, Body, Interp.BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, Wires, ReqProps, EmptyInterp, IgnoredUsed, OutErrors)) { return nullptr; } @@ -903,21 +1553,7 @@ namespace ShaderLabGraph In.Input.Connect(Wire.OutputIndex, Wire.Expr); Custom->Inputs.Add(In); } - for (const FShaderLabProperty& Prop : Model.Properties) - { - if (Prop.Type == EShaderLabPropertyType::StaticBool || !ReferencesToken(Interp.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); - } - } + WirePropInputs(*Custom, Program, PropertyNodes, Interp.Body, ReqProps); // The body contains `return ;`, so it is the Custom function's body directly. Custom->Code = WrapBodyWithLineMapping(Body, Interp.BodyLine, MakeLineDirectivePath(Model.SourceFilePath)); @@ -1078,10 +1714,78 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode return false; } - // Emit the shader's free top-level HLSL (local functions/structs/globals) to a generated header that - // every Custom node #includes at file scope (defined-before-use for all pixel/vertex bodies). No-op - // when the shader declares none. Guard against graph intrinsics in local code first (clear .usl error). - if (!CheckLocalCodeIntrinsics(Model, OutErrors) || !WriteLocalCodeInclude(Model, OutErrors)) + // Resolve `.uslfunc` imports (transitively): promote their properties onto this material and gather the + // functions to emit. With no imports this is just the shader's own properties. Then compute the context + // (properties + intrinsics) each reachable library function needs, so emission and call sites agree. + FShaderLabResolvedProgram Program; + FLibraryEmit Emit; + Emit.Program = &Program; + if (Model.Imports.Num() > 0) + { + FShaderLabImportResolver::FSourceLoader Loader = + [](const FString& VirtualPath, FString& OutSource, FString& OutDiskPath, FString& OutError) -> bool + { + if (!ResolveVirtualShaderFile(VirtualPath, OutDiskPath)) + { + OutError = TEXT("no shader-source directory maps this virtual path"); + return false; + } + if (!FFileHelper::LoadFileToString(OutSource, *OutDiskPath)) + { + OutError = FString::Printf(TEXT("cannot read '%s'"), *OutDiskPath); + return false; + } + return true; + }; + if (!FShaderLabImportResolver::Resolve(Model, Loader, Program, OutErrors)) + { + return false; + } + // Library free HLSL (non-function) is pure like shader local code — reject graph intrinsics in it. + for (const FShaderLabResolvedLibrary& Lib : Program.Libraries) + { + if (!CheckLocalCodeIntrinsics(Lib.Model, OutErrors)) + { + return false; + } + } + } + else + { + Program.Properties = Model.Properties; + } + + // Compute the transitive context of every library function reachable from the shader's bodies. + if (Emit.HasLibraries()) + { + TSet DirectlyCalled; + auto Collect = [&](const FString& Body) { CollectCalledFunctions(Body, Program, DirectlyCalled); }; + if (Model.bHasSurface) { Collect(Model.SurfaceBody); } + for (const FShaderLabSlab& Slab : Model.Slabs) { Collect(Slab.Body); } + for (const FShaderLabValue& Value : Model.Values) { Collect(Value.Body); } + for (const FShaderLabInterpolator& Interp : Model.Interpolators) { Collect(Interp.Body); } + if (Model.bHasVertex) { Collect(Model.VertexBody); } + for (const FName& Fn : DirectlyCalled) + { + if (!ComputeFunctionCtx(Fn, Emit, OutErrors)) + { + return false; + } + } + } + + // Promoted properties required (transitively) by any reachable library function — these get a parameter + // node even though they never appear literally in a shader body. + TSet GloballyUsedReqProps; + for (const TPair& Pair : Emit.Ctx) + { + for (const FName& P : Pair.Value.ReqProps) { GloballyUsedReqProps.Add(P); } + } + + // Emit the shader's free top-level HLSL (local functions/structs/globals) AND the rewritten library + // functions to a generated header that every Custom node #includes at file scope (defined-before-use + // for all pixel/vertex bodies). Guard against graph intrinsics in the shader's local code first. + if (!CheckLocalCodeIntrinsics(Model, OutErrors) || !WriteLocalCodeInclude(Model, Emit, OutErrors)) { return false; } @@ -1117,6 +1821,32 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode return false; }; + // A promoted property gets a node if a body references it directly OR a reachable library function needs + // it. StaticBool additionally counts references inside reachable library function bodies (its promoted + // name equals its bare name, and it reaches those bodies through the global `#define`). + auto IsPropUsed = [&](const FShaderLabProperty& Prop) -> bool + { + const FString NameStr = Prop.Name.ToString(); + if (IsPropertyReferenced(Prop.Name, NameStr)) + { + return true; + } + if (Prop.Type == EShaderLabPropertyType::StaticBool) + { + for (const TPair& Pair : Emit.Ctx) + { + int32 LibIdx = INDEX_NONE; + const FShaderLabFunction* Fn = Program.FindFunction(Pair.Key, LibIdx); + if (Fn && ReferencesToken(Fn->Body, NameStr)) + { + return true; + } + } + return false; + } + return GloballyUsedReqProps.Contains(Prop.Name); + }; + // 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 @@ -1132,13 +1862,13 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode TMap InterpByName; TSet UsedInterps; - for (const FShaderLabProperty& Prop : Model.Properties) + for (const FShaderLabProperty& Prop : Program.Properties) { const FString NameStr = Prop.Name.ToString(); if (Prop.Type == EShaderLabPropertyType::StaticBool) { - if (IsPropertyReferenced(Prop.Name, NameStr)) + if (IsPropUsed(Prop)) { // 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). @@ -1173,7 +1903,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode continue; } - if (!IsPropertyReferenced(Prop.Name, NameStr)) + if (!IsPropUsed(Prop)) { continue; // Unused value property: skip (keeps the graph minimal and deterministic). } @@ -1238,7 +1968,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode OutErrors.Add(FString::Printf(TEXT("Duplicate interpolator name '%s'"), *Interp.Name.ToString())); return false; } - UMaterialExpression* Node = BuildInterpolatorNode(Material, Interp, Model, PropertyNodes, ParamY, OutErrors); + UMaterialExpression* Node = BuildInterpolatorNode(Material, Interp, Model, Program, Emit, PropertyNodes, ParamY, OutErrors); if (!Node) { return false; @@ -1251,7 +1981,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode { // 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, InterpByName, UsedInterps, ParamY, OutErrors)) + SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, Model, Program, Emit, PropertyNodes, InterpByName, UsedInterps, ParamY, OutErrors)) { return false; } @@ -1259,7 +1989,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode else if (Model.bHasSurface && Model.SurfaceEntry == EShaderLabEntry::UI) { if (!BuildEmissiveEntry(Material, *EditorOnly, TEXT("FShaderLabUI"), TEXT("ShaderLabDefaultUI"), - SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, Model, PropertyNodes, InterpByName, UsedInterps, ParamY, OutErrors)) + SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, Model, Program, Emit, PropertyNodes, InterpByName, UsedInterps, ParamY, OutErrors)) { return false; } @@ -1270,7 +2000,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode // allowed as material-level outputs. UMaterialExpressionSubstrateSlabBSDF* Slab = BuildSlab( Material, *EditorOnly, SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, - Model, PropertyNodes, InterpByName, UsedInterps, /*bAllowMaterialOutputs*/ true, ParamY, OutErrors); + Model, Program, Emit, PropertyNodes, InterpByName, UsedInterps, /*bAllowMaterialOutputs*/ true, ParamY, OutErrors); if (!Slab) { return false; @@ -1291,7 +2021,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode } UMaterialExpressionSubstrateSlabBSDF* Slab = BuildSlab( Material, *EditorOnly, SlabDecl.OutParamName, SlabDecl.Body, SlabDecl.BodyLine, - Model, PropertyNodes, InterpByName, UsedInterps, /*bAllowMaterialOutputs*/ false, ParamY, OutErrors); + Model, Program, Emit, PropertyNodes, InterpByName, UsedInterps, /*bAllowMaterialOutputs*/ false, ParamY, OutErrors); if (!Slab) { return false; @@ -1308,7 +2038,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode return false; } UMaterialExpressionCustom* ValueNode = BuildValueNode( - Material, ValueDecl, Model, PropertyNodes, InterpByName, UsedInterps, ParamY, OutErrors); + Material, ValueDecl, Model, Program, Emit, PropertyNodes, InterpByName, UsedInterps, ParamY, OutErrors); if (!ValueNode) { return false; @@ -1380,13 +2110,14 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode FString Code; - // Intrinsics (Stage = vertex). + // Intrinsics (Stage = vertex) + library-function context. FString VertexBody = Model.VertexBody; TArray VtxIntrinsicWires; + TSet VtxReqProps; // Vertex stage: UE_Interpolator is pixel-only, so pass an empty interpolator map (rejected there). const TMap EmptyInterp; TSet IgnoredUsedInterps; - if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::VertexOnly, VertexBody, Model.VertexBodyLine, MakeLineDirectivePath(Model.SourceFilePath), VtxIntrinsicWires, EmptyInterp, IgnoredUsedInterps, OutErrors)) + if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::VertexOnly, VertexBody, Model.VertexBodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, VtxIntrinsicWires, VtxReqProps, EmptyInterp, IgnoredUsedInterps, OutErrors)) { return false; } @@ -1397,27 +2128,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode 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); - } + WirePropInputs(*VCustom, Program, PropertyNodes, Model.VertexBody, VtxReqProps); const FString VSrcPath = MakeLineDirectivePath(Model.SourceFilePath); Code += FString::Printf(TEXT("FShaderLabVertex %s = ShaderLabDefaultVertex();\n{\n%s}\n"), diff --git a/Source/UShaderLabEditor/Private/ShaderLabIDEPrepare.cpp b/Source/UShaderLabEditor/Private/ShaderLabIDEPrepare.cpp index 7a6047f..13fe19a 100644 --- a/Source/UShaderLabEditor/Private/ShaderLabIDEPrepare.cpp +++ b/Source/UShaderLabEditor/Private/ShaderLabIDEPrepare.cpp @@ -7,7 +7,7 @@ // generated from FShaderLabIntrinsicRegistry. (The static // ShaderLab.ush umbrella includes it; it is IDE-only.) // 2. /.vscode/settings.json — merges (non-destructively) files.associations -// (*.usl/*.usf/*.ush -> hlsl) and shader-validator.pathRemapping +// (*.usl/*.uslfunc/*.usf/*.ush -> hlsl) and shader-validator.pathRemapping // (virtual shader roots -> disk, from AllShaderSourceDirectoryMappings). // pathRemapping is machine-specific; the command regenerates it. @@ -338,8 +338,8 @@ namespace ShaderLabIDEPrepare_Private return Remap; } - // Merge files.associations (*.usl/*.usf/*.ush -> hlsl) and shader-validator.pathRemapping into - // .vscode/settings.json, preserving all other keys. Rewrites only when something actually changed. + // Merge files.associations (*.usl/*.uslfunc/*.usf/*.ush -> hlsl) and shader-validator.pathRemapping + // into .vscode/settings.json, preserving all other keys. Rewrites only when something actually changed. static void MergeVSCodeSettings(const FString& ProjectDir) { const FString SettingsPath = FPaths::Combine(ProjectDir, TEXT(".vscode"), TEXT("settings.json")); @@ -368,7 +368,7 @@ namespace ShaderLabIDEPrepare_Private const TSharedPtr* AssocPtr = nullptr; const TSharedPtr Assoc = Settings->TryGetObjectField(TEXT("files.associations"), AssocPtr) ? *AssocPtr : MakeShared(); - for (const TCHAR* Ext : { TEXT("*.usl"), TEXT("*.usf"), TEXT("*.ush") }) + for (const TCHAR* Ext : { TEXT("*.usl"), TEXT("*.uslfunc"), TEXT("*.usf"), TEXT("*.ush") }) { FString Value; if (!Assoc->TryGetStringField(Ext, Value) || Value != TEXT("hlsl"))