Add SL_FUNCTION and .uslfunc

This commit is contained in:
Eragon-Brisingr
2026-07-02 17:32:03 +08:00
parent c27af6af49
commit 2ed659d4d2
10 changed files with 1558 additions and 126 deletions

View File

@@ -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(...)

View File

@@ -30,7 +30,16 @@ TArray<FString> FShaderLabDiscovery::FindShaderLabFiles()
{
TArray<FString> 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;

View File

@@ -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<FString>& Errors;
/** Canonical path -> resolved library index. */
TMap<FString, int32> ByCanonical;
/** Canonical paths on the current DFS path (cycle detection). */
TArray<FString> VisitingStack;
/** Per library index: the direct child (imported) library indices. Index-aligned with Program.Libraries. */
TArray<TArray<int32>> 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("<none>") : *Lib.Namespace,
Namespace.IsEmpty() ? TEXT("<none>") : *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<FShaderLabParseError> 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<int32> 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<int32>& 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<FString>& 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<int32> 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;
}

View File

@@ -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(

View File

@@ -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() <ret> <Name>(<params>) { <body> } — 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)

View File

@@ -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 `<Name>Sampler` alias. StaticBool
* properties map to their bare name (they promote by name and reach bodies via the global `#define`).
*/
TMap<FName, FName> 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<FShaderLabProperty> Properties;
/** Libraries in dependency order (a library appears before any library that imports it). */
TArray<FShaderLabResolvedLibrary> Libraries;
/** Promoted-property name -> the source library index it came from (INDEX_NONE = the root shader). */
TMap<FName, int32> PropertyOwner;
/** Function name -> owning library index (functions are unique across the whole closure). */
TMap<FName, int32> 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<bool(const FString& /*VirtualPath*/, FString& /*OutSource*/, FString& /*OutDiskPath*/, FString& /*OutError*/)>;
/**
* 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<FString>& OutErrors);
/** The promoted material-parameter name for a property under a namespace (StaticBool ignores namespace). */
static FName PromoteName(const FString& Namespace, const FShaderLabProperty& Prop);
};

View File

@@ -124,6 +124,39 @@ struct USHADERLAB_API FShaderLabLocalCode
int32 Line = 0;
};
/**
* A `SL_FUNCTION() <ret> <Name>(<params>){...}` 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 "<path>.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<FShaderLabProperty> Properties;
TArray<FString> 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<FShaderLabImport> 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<FShaderLabFunction> Functions;
// Pixel stage. Either a single `Surface(...)` (sugar: one anonymous slab straight to FrontMaterial),
// OR one-or-more named `Slab` blocks + a `FrontMaterial = <topology>` 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;
};

View File

@@ -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<FString, FString>& 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<FString> FindShadersImporting(const FString& ChangedDiskPathNormalized)
{
TArray<FString> Result;
for (const FString& Usl : FShaderLabDiscovery::FindShaderLabFiles())
{
FString Source;
if (!FFileHelper::LoadFileToString(Source, *Usl))
{
continue;
}
FShaderLabModel Model;
TArray<FShaderLabParseError> 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<FString> 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<FFileChangeData>& Changes)
{
UShaderLabSubsystem* Subsystem = GEngine ? GEngine->GetEngineSubsystem<UShaderLabSubsystem>() : nullptr;
@@ -199,24 +274,35 @@ private:
return;
}
TSet<FString> 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);
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -7,7 +7,7 @@
// generated from FShaderLabIntrinsicRegistry. (The static
// ShaderLab.ush umbrella includes it; it is IDE-only.)
// 2. <Project>/.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<FJsonObject>* AssocPtr = nullptr;
const TSharedPtr<FJsonObject> Assoc = Settings->TryGetObjectField(TEXT("files.associations"), AssocPtr)
? *AssocPtr : MakeShared<FJsonObject>();
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"))