// Copyright FlecsProj. All Rights Reserved. #include "ShaderLabGraphBuilder.h" #include "ShaderLabModel.h" #include "MaterialDomain.h" #include "Engine/EngineTypes.h" #include "Misc/Paths.h" #include "Engine/Texture.h" #include "Engine/Texture2D.h" #include "Materials/Material.h" #include "Materials/MaterialExpressionConstant.h" #include "Materials/MaterialExpressionCustom.h" #include "Materials/MaterialExpressionScalarParameter.h" #include "Materials/MaterialExpressionStaticBoolParameter.h" #include "Materials/MaterialExpressionStaticSwitch.h" #include "Materials/MaterialExpressionSubstrate.h" #include "Materials/MaterialExpressionTextureObjectParameter.h" #include "Materials/MaterialExpressionVectorParameter.h" #include "MaterialExpressionShaderLabParameterAnchor.h" #include "ShaderLabIntrinsicRegistry.h" #include "ShaderLabRuntimeBuilder.h" #include "UObject/Class.h" #include "ShaderLabSettingsApplier.h" #include "UObject/UObjectGlobals.h" #define SHADERLAB_COMMON_INCLUDE TEXT("/Plugin/ShaderLab/Private/ShaderLabCommon.ush") #define SHADERLAB_FUNCTIONS_INCLUDE TEXT("/Plugin/ShaderLab/Private/ShaderLabUEFunctions.ush") namespace ShaderLabGraph { // --- Surface fields that map to Substrate Slab pins (in deterministic order). --- struct FSlabFieldDef { const TCHAR* Field; ECustomMaterialOutputType OutType; }; static const FSlabFieldDef GSlabFields[] = { { TEXT("DiffuseAlbedo"), CMOT_Float3 }, { TEXT("F0"), CMOT_Float3 }, { TEXT("F90"), CMOT_Float3 }, { TEXT("Roughness"), CMOT_Float1 }, { TEXT("Anisotropy"), CMOT_Float1 }, { TEXT("Normal"), CMOT_Float3 }, { TEXT("Tangent"), CMOT_Float3 }, { TEXT("SSSMFP"), CMOT_Float3 }, { TEXT("SSSMFPScale"), CMOT_Float1 }, { TEXT("SSSPhaseAnisotropy"), CMOT_Float1 }, { TEXT("EmissiveColor"), CMOT_Float3 }, { TEXT("SecondRoughness"), CMOT_Float1 }, { TEXT("SecondRoughnessWeight"), CMOT_Float1 }, { TEXT("FuzzRoughness"), CMOT_Float1 }, { TEXT("FuzzAmount"), CMOT_Float1 }, { TEXT("FuzzColor"), CMOT_Float3 }, { TEXT("GlintValue"), CMOT_Float1 }, { TEXT("GlintUV"), CMOT_Float2 }, }; static FExpressionInput* GetSlabPin(UMaterialExpressionSubstrateSlabBSDF* Slab, const FString& Field) { if (Field == TEXT("DiffuseAlbedo")) return &Slab->DiffuseAlbedo; if (Field == TEXT("F0")) return &Slab->F0; if (Field == TEXT("F90")) return &Slab->F90; if (Field == TEXT("Roughness")) return &Slab->Roughness; if (Field == TEXT("Anisotropy")) return &Slab->Anisotropy; if (Field == TEXT("Normal")) return &Slab->Normal; if (Field == TEXT("Tangent")) return &Slab->Tangent; if (Field == TEXT("SSSMFP")) return &Slab->SSSMFP; if (Field == TEXT("SSSMFPScale")) return &Slab->SSSMFPScale; if (Field == TEXT("SSSPhaseAnisotropy")) return &Slab->SSSPhaseAnisotropy; if (Field == TEXT("EmissiveColor")) return &Slab->EmissiveColor; if (Field == TEXT("SecondRoughness")) return &Slab->SecondRoughness; if (Field == TEXT("SecondRoughnessWeight")) return &Slab->SecondRoughnessWeight; if (Field == TEXT("FuzzRoughness")) return &Slab->FuzzRoughness; if (Field == TEXT("FuzzAmount")) return &Slab->FuzzAmount; if (Field == TEXT("FuzzColor")) return &Slab->FuzzColor; if (Field == TEXT("GlintValue")) return &Slab->GlintValue; if (Field == TEXT("GlintUV")) return &Slab->GlintUV; return nullptr; } // --- Vertex-stage output fields. --- struct FVertexFieldDef { const TCHAR* Field; ECustomMaterialOutputType OutType; }; static const FVertexFieldDef GVertexFields[] = { { TEXT("WorldPositionOffset"), CMOT_Float3 }, { TEXT("Displacement"), CMOT_Float1 }, { TEXT("CustomizedUV0"), CMOT_Float2 }, { TEXT("CustomizedUV1"), CMOT_Float2 }, { TEXT("CustomizedUV2"), CMOT_Float2 }, { TEXT("CustomizedUV3"), CMOT_Float2 }, }; /** Absolute, forward-slashed path for use inside an HLSL `#line N "path"` directive. */ static FString MakeLineDirectivePath(const FString& SourceFilePath) { FString Full = FPaths::ConvertRelativePathToFull(SourceFilePath); Full.ReplaceInline(TEXT("\\"), TEXT("/")); return Full; } /** * Wrap a user HLSL body so shader-compiler errors map back to the .usl source: a `#line` * directive sets the file+line to the body's origin, and a trailing directive points past it to a * sentinel so errors in our generated epilogue are not mis-attributed to the user's file. */ static FString WrapBodyWithLineMapping(const FString& Body, int32 BodyLine, const FString& SrcPath) { // BodyLine is the source line of the char right after '{' (usually the newline ending that // line); the body's real content starts on the next line. Empirically the compiler reports // content one line high relative to `#line BodyLine`, so map with BodyLine-1. const int32 MappedLine = FMath::Max(BodyLine - 1, 1); return FString::Printf(TEXT("#line %d \"%s\"\n%s\n#line 1 \"ShaderLabGenerated.ush\"\n"), MappedLine, *SrcPath, *Body); } /** True if `Token` appears in `Body` delimited by non-identifier characters. */ static bool ReferencesToken(const FString& Body, const FString& Token) { auto IsIdent = [](TCHAR C) { return FChar::IsAlnum(C) || C == TEXT('_'); }; int32 From = 0; while (true) { const int32 Idx = Body.Find(Token, ESearchCase::CaseSensitive, ESearchDir::FromStart, From); if (Idx == INDEX_NONE) { return false; } const TCHAR Before = (Idx > 0) ? Body[Idx - 1] : TEXT(' '); const int32 AfterIdx = Idx + Token.Len(); const TCHAR After = (AfterIdx < Body.Len()) ? Body[AfterIdx] : TEXT(' '); if (!IsIdent(Before) && !IsIdent(After)) { return true; } From = Idx + Token.Len(); } } static UTexture* ResolveDefaultTexture(const FString& Token, bool& bOutIsNormal) { bOutIsNormal = (Token == TEXT("normal")); const TCHAR* Path = nullptr; if (Token == TEXT("white")) { Path = TEXT("/Engine/EngineResources/WhiteSquareTexture.WhiteSquareTexture"); } else if (Token == TEXT("black")) { Path = TEXT("/Engine/EngineResources/Black.Black"); } else if (Token == TEXT("grey") || Token == TEXT("gray")) { Path = TEXT("/Engine/EngineResources/GreyTexture.GreyTexture"); } else if (Token == TEXT("normal")) { Path = TEXT("/Engine/EngineMaterials/DefaultNormal.DefaultNormal"); } UTexture* Tex = nullptr; if (Path) { Tex = LoadObject(nullptr, Path); } else if (!Token.IsEmpty()) { Tex = LoadObject(nullptr, *Token); } if (!Tex) { Tex = LoadObject(nullptr, TEXT("/Engine/EngineResources/WhiteSquareTexture.WhiteSquareTexture")); } return Tex; } static EMaterialDomain MapDomain(EShaderLabDomain D) { switch (D) { case EShaderLabDomain::PostProcess: return MD_PostProcess; case EShaderLabDomain::UI: return MD_UI; case EShaderLabDomain::Decal: return MD_DeferredDecal; case EShaderLabDomain::Surface: default: return MD_Surface; } } static EBlendMode MapBlend(EShaderLabBlendMode B) { switch (B) { case EShaderLabBlendMode::Masked: return BLEND_Masked; case EShaderLabBlendMode::Translucent: return BLEND_Translucent; case EShaderLabBlendMode::Additive: return BLEND_Additive; case EShaderLabBlendMode::Modulate: return BLEND_Modulate; case EShaderLabBlendMode::Opaque: default: return BLEND_Opaque; } } template static T* NewExpr(UMaterial& Material, int32& IoY, int32 Column) { T* Expr = NewObject(&Material); Material.GetExpressionCollection().AddExpression(Expr); Expr->MaterialExpressionEditorX = Column; Expr->MaterialExpressionEditorY = IoY; IoY += 120; return Expr; } /** One intrinsic call resolved to a Custom-node input wired from an engine expression node. */ struct FIntrinsicWire { FName InputName; UMaterialExpression* Expr = nullptr; int32 OutputIndex = 0; }; /** Split a call's argument text into trimmed, top-level comma-separated literals. */ static TArray SplitArgs(const FString& ArgsRaw) { TArray Out; if (ArgsRaw.TrimStartAndEnd().IsEmpty()) { return Out; } ArgsRaw.ParseIntoArray(Out, TEXT(","), /*CullEmpty*/ false); for (FString& A : Out) { A.TrimStartAndEndInline(); } return Out; } /** A stable, identifier-safe suffix encoding a call's literal args (e.g. "0, 2.0" -> "0_2_0"). */ static FString MakeArgSig(const FString& ArgsRaw) { FString Sig; for (const TCHAR C : ArgsRaw) { if (FChar::IsAlnum(C)) { Sig.AppendChar(C); } else if (!FChar::IsWhitespace(C)) { Sig.AppendChar(TEXT('_')); } } return Sig; } /** * Scan a body for `UE_Name(args)` intrinsic calls, create the backing expression node for each * unique (name,args), collect the resulting Custom-node inputs, and rewrite the body so each call * becomes its input variable — space-padded to the original call's length so line/column layout is * preserved (keeps `#line` compile-error mapping accurate). Returns false (and fills OutErrors) on * an unknown intrinsic, a stage/usage violation, or a bad argument. */ /** True if `Arg` is a numeric literal (the only non-enum arg an intrinsic's const config accepts). */ static bool IsNumericLiteral(const FString& Arg) { const FString T = Arg.TrimStartAndEnd(); if (T.IsEmpty()) { return false; } bool bAnyDigit = false; for (int32 i = 0; i < T.Len(); ++i) { const TCHAR C = T[i]; if (FChar::IsDigit(C)) { bAnyDigit = true; } else if (C == TEXT('.') || C == TEXT('+') || C == TEXT('-') || C == TEXT('e') || C == TEXT('E') || C == TEXT('f') || C == TEXT('F')) { /* allowed */ } else { return false; } } return bAnyDigit; } static bool EmitIntrinsics( UMaterial& Material, EShaderLabIntrinsicFrequency Stage, FString& InOutBody, int32 BodyLine, const FString& SrcPath, TArray& OutWires, TArray& OutErrors) { const FShaderLabIntrinsicRegistry& Registry = FShaderLabIntrinsicRegistry::Get(); const FString& Body = InOutBody; const int32 Len = Body.Len(); FString Result; Result.Reserve(Len); TMap InputByKey; // (Name + argsig) -> already-created input name (dedup) bool bOk = true; auto IsIdent = [](TCHAR C) { return FChar::IsAlnum(C) || C == TEXT('_'); }; // Format a `path.usl(line,col): error: ` prefix from a body offset, so intrinsic diagnostics map // to the real .usl location and flow through the same clickable/cook-failing path as compile errors. auto Loc = [&Body, BodyLine, &SrcPath](int32 Offset) -> FString { int32 Line = BodyLine; int32 Col = 1; for (int32 p = 0; p < Offset && p < Body.Len(); ++p) { if (Body[p] == TEXT('\n')) { ++Line; Col = 1; } else { ++Col; } } return FString::Printf(TEXT("%s(%d,%d): error: "), *SrcPath, Line, Col); }; int32 i = 0; while (i < Len) { const bool bBoundary = (i == 0) || !IsIdent(Body[i - 1]); if (bBoundary && i + 3 <= Len && Body[i] == TEXT('U') && Body[i + 1] == TEXT('E') && Body[i + 2] == TEXT('_')) { int32 j = i + 3; while (j < Len && IsIdent(Body[j])) { ++j; } const FString Name = Body.Mid(i + 3, j - (i + 3)); int32 k = j; while (k < Len && FChar::IsWhitespace(Body[k])) { ++k; } if (!Name.IsEmpty() && k < Len && Body[k] == TEXT('(')) { // Read balanced (...) for the argument list. int32 Depth = 0; int32 m = k; for (; m < Len; ++m) { if (Body[m] == TEXT('(')) { ++Depth; } else if (Body[m] == TEXT(')')) { if (--Depth == 0) { break; } } } if (m < Len) { const FString ArgsRaw = Body.Mid(k + 1, m - (k + 1)); const int32 CallLen = (m + 1) - i; // Only registered node-intrinsics are rewritten into material-expression inputs. // Any other `UE_X(...)` is left for the shader compiler: it's an HLSL library // function (e.g. UE_Noise from ShaderLabFunctions.ush, auto-included) or a typo. // Copy just the `UE_Name` identifier and keep scanning from the '(' — so any // intrinsic nested in the arguments (e.g. UE_Noise(UE_WorldPosition(), ...)) still // gets rewritten. if (!Registry.Find(FName(*Name))) { Result += Body.Mid(i, j - i); i = j; continue; } const FString ArgSig = MakeArgSig(ArgsRaw); const FString Key = Name + TEXT("|") + ArgSig; FName InputName; if (const FName* Existing = InputByKey.Find(Key)) { InputName = *Existing; } else { const FShaderLabIntrinsicDesc* Desc = Registry.Find(FName(*Name)); if (Desc->Frequency == EShaderLabIntrinsicFrequency::PixelOnly && Stage == EShaderLabIntrinsicFrequency::VertexOnly) { OutErrors.Add(Loc(i) + FString::Printf(TEXT("intrinsic 'UE_%s' is pixel-only and cannot be used in a Vertex body"), *Name)); bOk = false; } else if (Desc->Frequency == EShaderLabIntrinsicFrequency::VertexOnly && Stage == EShaderLabIntrinsicFrequency::PixelOnly) { OutErrors.Add(Loc(i) + FString::Printf(TEXT("intrinsic 'UE_%s' is vertex-only and cannot be used in a pixel body"), *Name)); bOk = false; } else { // Config args are baked into the node at graph-build time, so they must be // compile-time constants (numeric literals, or the enum's token names) — a // variable can't configure a node field. Reject non-literals instead of // silently coercing them (e.g. Atoi("myVar") -> 0). const TArray CallArgs = SplitArgs(ArgsRaw); bool bArgsOk = true; if (CallArgs.Num() > Desc->Params.Num()) { OutErrors.Add(Loc(i) + FString::Printf(TEXT("intrinsic 'UE_%s' takes at most %d argument(s), got %d"), *Name, Desc->Params.Num(), CallArgs.Num())); bArgsOk = false; } for (int32 a = 0; bArgsOk && a < CallArgs.Num(); ++a) { const FShaderLabIntrinsicParam& P = Desc->Params[a]; if (P.Enum) { if (P.Enum->GetValueByNameString(CallArgs[a]) == INDEX_NONE) { OutErrors.Add(Loc(i) + FString::Printf(TEXT("intrinsic 'UE_%s' argument '%s' must be a %s token, got '%s'"), *Name, *P.Name, *P.Enum->GetName(), *CallArgs[a])); bArgsOk = false; } } else if (!IsNumericLiteral(CallArgs[a])) { OutErrors.Add(Loc(i) + FString::Printf(TEXT("intrinsic 'UE_%s' argument '%s' must be a compile-time constant, got '%s'"), *Name, *P.Name, *CallArgs[a])); bArgsOk = false; } } FString MakeError; UMaterialExpression* Expr = bArgsOk ? Desc->MakeNode(Material, CallArgs, MakeError) : nullptr; if (!bArgsOk) { bOk = false; } else if (!Expr) { OutErrors.Add(Loc(i) + FString::Printf(TEXT("intrinsic 'UE_%s': %s"), *Name, MakeError.IsEmpty() ? TEXT("failed to create node") : *MakeError)); bOk = false; } else { InputName = FName(*(FString(TEXT("SLI_")) + Name + (ArgSig.IsEmpty() ? TEXT("") : (FString(TEXT("_")) + ArgSig)))); InputByKey.Add(Key, InputName); OutWires.Add(FIntrinsicWire{ InputName, Expr, Desc->OutputIndex }); } } } // Substitute the call with its input variable, space-padded to keep columns stable. FString Replacement = InputName.IsNone() ? FString() : InputName.ToString(); while (Replacement.Len() < CallLen) { Replacement.AppendChar(TEXT(' ')); } Result += Replacement; i = m + 1; continue; } } } Result.AppendChar(Body[i]); ++i; } InOutBody = MoveTemp(Result); return bOk; } /** A created property parameter node (shared across all slabs/values that reference it). */ struct FParamNode { UMaterialExpression* Expr = nullptr; bool bIsTexture = false; }; /** Add the shared struct + function-library includes plus any user `Includes { }` paths. */ static void AddIncludes(UMaterialExpressionCustom& Custom, const FShaderLabModel& Model) { Custom.IncludeFilePaths.Add(SHADERLAB_COMMON_INCLUDE); // The function library provides the UE_ HLSL helpers (UE_Noise, UE_RotateAboutAxis, ...) that // are left verbatim in the body (not rewritten into nodes). Custom.IncludeFilePaths.Add(SHADERLAB_FUNCTIONS_INCLUDE); for (const FString& Include : Model.Includes) { if (!Include.IsEmpty()) { Custom.IncludeFilePaths.AddUnique(Include); } } } /** * Emit the Custom node for a pixel-stage body that writes FShaderLabSurface fields and wire it into * a fresh Substrate Slab BSDF. Returns the slab (nullptr only on error). When bAllowMaterialOutputs, * S.Opacity / S.OpacityMask are wired to the material-level pins (single-Surface sugar path only). */ static UMaterialExpressionSubstrateSlabBSDF* BuildSlab( UMaterial& Material, UMaterialEditorOnlyData& EditorOnly, const FString& OutParamName, const FString& InBody, int32 BodyLine, const FShaderLabModel& Model, const TMap& PropertyNodes, bool bAllowMaterialOutputs, int32& IoY, TArray& OutErrors) { UMaterialExpressionSubstrateSlabBSDF* Slab = NewExpr(Material, IoY, 0); TArray UsedSlab; for (const FSlabFieldDef& F : GSlabFields) { if (ReferencesToken(InBody, OutParamName + TEXT(".") + F.Field)) { UsedSlab.Add(&F); } } const bool bUsesOpacity = bAllowMaterialOutputs && ReferencesToken(InBody, OutParamName + TEXT(".Opacity")); const bool bUsesOpacityMask = bAllowMaterialOutputs && ReferencesToken(InBody, OutParamName + TEXT(".OpacityMask")); if (UsedSlab.Num() == 0 && !bUsesOpacity && !bUsesOpacityMask) { return Slab; // Empty body: a default Substrate slab. } UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); Custom->Description = TEXT("ShaderLab Surface"); Custom->OutputType = CMOT_Float1; AddIncludes(*Custom, Model); FString Body = InBody; TArray Wires; if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Wires, OutErrors)) { return nullptr; } for (const FIntrinsicWire& Wire : Wires) { FCustomInput In; In.InputName = Wire.InputName; In.Input.Connect(Wire.OutputIndex, Wire.Expr); Custom->Inputs.Add(In); } for (const FShaderLabProperty& Prop : Model.Properties) { if (Prop.Type == EShaderLabPropertyType::StaticBool || !ReferencesToken(InBody, Prop.Name.ToString())) { continue; } const FParamNode* Node = PropertyNodes.Find(Prop.Name); if (Node && Node->Expr) { FCustomInput In; In.InputName = Prop.Name; In.Input.Connect(0, Node->Expr); Custom->Inputs.Add(In); } } const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); FString Code = FString::Printf(TEXT("FShaderLabSurface %s = ShaderLabDefaultSurface();\n{\n%s}\n"), *OutParamName, *WrapBodyWithLineMapping(Body, BodyLine, SrcPath)); int32 OutputIndex = 1; // index 0 is the (unused) main return TArray> SlabOutputs; for (const FSlabFieldDef* F : UsedSlab) { FCustomOutput Out; Out.OutputName = FName(*(FString(TEXT("SLO_")) + F->Field)); Out.OutputType = F->OutType; Custom->AdditionalOutputs.Add(Out); Code += FString::Printf(TEXT("SLO_%s = %s.%s;\n"), F->Field, *OutParamName, F->Field); SlabOutputs.Add(TPair(F, OutputIndex)); ++OutputIndex; } int32 OpacityOutIdx = INDEX_NONE; int32 OpacityMaskOutIdx = INDEX_NONE; if (bUsesOpacity) { FCustomOutput Out; Out.OutputName = TEXT("SLO_Opacity"); Out.OutputType = CMOT_Float1; Custom->AdditionalOutputs.Add(Out); Code += FString::Printf(TEXT("SLO_Opacity = %s.Opacity;\n"), *OutParamName); OpacityOutIdx = OutputIndex++; } if (bUsesOpacityMask) { FCustomOutput Out; Out.OutputName = TEXT("SLO_OpacityMask"); Out.OutputType = CMOT_Float1; Custom->AdditionalOutputs.Add(Out); Code += FString::Printf(TEXT("SLO_OpacityMask = %s.OpacityMask;\n"), *OutParamName); OpacityMaskOutIdx = OutputIndex++; } Code += TEXT("return 0.0f;\n"); Custom->Code = Code; Custom->RebuildOutputs(); for (const TPair& Pair : SlabOutputs) { if (FExpressionInput* Pin = GetSlabPin(Slab, Pair.Key->Field)) { Pin->Connect(Pair.Value, Custom); } } if (OpacityOutIdx != INDEX_NONE) { EditorOnly.Opacity.Connect(OpacityOutIdx, Custom); } if (OpacityMaskOutIdx != INDEX_NONE) { EditorOnly.OpacityMask.Connect(OpacityMaskOutIdx, Custom); } return Slab; } /** * Build the Custom node for a PostProcess/UI entry (Domain = PostProcess/UI). The output struct has * Color (-> material EmissiveColor) and Opacity (-> material Opacity); there is no Substrate slab. */ static bool BuildEmissiveEntry( UMaterial& Material, UMaterialEditorOnlyData& EditorOnly, const TCHAR* StructName, const TCHAR* DefaultFn, const FString& OutParamName, const FString& InBody, int32 BodyLine, const FShaderLabModel& Model, const TMap& PropertyNodes, int32& IoY, TArray& OutErrors) { UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); Custom->Description = TEXT("ShaderLab Emissive Entry"); Custom->OutputType = CMOT_Float1; AddIncludes(*Custom, Model); FString Body = InBody; TArray Wires; if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Wires, OutErrors)) { return false; } for (const FIntrinsicWire& Wire : Wires) { FCustomInput In; In.InputName = Wire.InputName; In.Input.Connect(Wire.OutputIndex, Wire.Expr); Custom->Inputs.Add(In); } for (const FShaderLabProperty& Prop : Model.Properties) { if (Prop.Type == EShaderLabPropertyType::StaticBool || !ReferencesToken(InBody, Prop.Name.ToString())) { continue; } const FParamNode* Node = PropertyNodes.Find(Prop.Name); if (Node && Node->Expr) { FCustomInput In; In.InputName = Prop.Name; In.Input.Connect(0, Node->Expr); Custom->Inputs.Add(In); } } const bool bUsesColor = ReferencesToken(InBody, OutParamName + TEXT(".Color")); const bool bUsesOpacity = ReferencesToken(InBody, OutParamName + TEXT(".Opacity")); const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); FString Code = FString::Printf(TEXT("%s %s = %s();\n{\n%s}\n"), StructName, *OutParamName, DefaultFn, *WrapBodyWithLineMapping(Body, BodyLine, SrcPath)); int32 OutputIndex = 1; int32 ColorOutIdx = INDEX_NONE; int32 OpacityOutIdx = INDEX_NONE; if (bUsesColor) { FCustomOutput Out; Out.OutputName = TEXT("SLO_Color"); Out.OutputType = CMOT_Float3; Custom->AdditionalOutputs.Add(Out); Code += FString::Printf(TEXT("SLO_Color = %s.Color;\n"), *OutParamName); ColorOutIdx = OutputIndex++; } if (bUsesOpacity) { FCustomOutput Out; Out.OutputName = TEXT("SLO_Opacity"); Out.OutputType = CMOT_Float1; Custom->AdditionalOutputs.Add(Out); Code += FString::Printf(TEXT("SLO_Opacity = %s.Opacity;\n"), *OutParamName); OpacityOutIdx = OutputIndex++; } Code += TEXT("return 0.0f;\n"); Custom->Code = Code; Custom->RebuildOutputs(); if (ColorOutIdx != INDEX_NONE) { EditorOnly.EmissiveColor.Connect(ColorOutIdx, Custom); } if (OpacityOutIdx != INDEX_NONE) { EditorOnly.Opacity.Connect(OpacityOutIdx, Custom); } return true; } /** Build a Custom node whose return value is the scalar Value-block body. Output 0 is the scalar. */ static UMaterialExpressionCustom* BuildValueNode( UMaterial& Material, const FShaderLabValue& Value, const FShaderLabModel& Model, const TMap& PropertyNodes, int32& IoY, TArray& OutErrors) { UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); Custom->Description = FString::Printf(TEXT("ShaderLab Value %s"), *Value.Name.ToString()); Custom->OutputType = CMOT_Float1; AddIncludes(*Custom, Model); FString Body = Value.Body; TArray Wires; if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, Value.BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Wires, OutErrors)) { return nullptr; } for (const FIntrinsicWire& Wire : Wires) { FCustomInput In; In.InputName = Wire.InputName; In.Input.Connect(Wire.OutputIndex, Wire.Expr); Custom->Inputs.Add(In); } for (const FShaderLabProperty& Prop : Model.Properties) { if (Prop.Type == EShaderLabPropertyType::StaticBool || !ReferencesToken(Value.Body, Prop.Name.ToString())) { continue; } const FParamNode* Node = PropertyNodes.Find(Prop.Name); if (Node && Node->Expr) { FCustomInput In; In.InputName = Prop.Name; In.Input.Connect(0, Node->Expr); Custom->Inputs.Add(In); } } // The body itself contains `return ;`, so it is the Custom function's body directly. const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); Custom->Code = WrapBodyWithLineMapping(Body, Value.BodyLine, SrcPath); Custom->RebuildOutputs(); return Custom; } /** Connect a topology mix factor (literal / Value block / Scalar property) to an operator scalar pin. */ static bool ConnectFactor( UMaterial& Material, FExpressionInput& Target, const FShaderLabFactor& Factor, const TMap& ValueByName, const TMap& PropertyNodes, int32& IoY, TArray& OutErrors) { if (Factor.Kind == FShaderLabFactor::EKind::Literal) { UMaterialExpressionConstant* Const = NewExpr(Material, IoY, -300); Const->R = Factor.Literal; Target.Connect(0, Const); return true; } if (UMaterialExpressionCustom* const* ValueNode = ValueByName.Find(Factor.Name)) { Target.Connect(0, *ValueNode); return true; } if (const FParamNode* Node = PropertyNodes.Find(Factor.Name)) { if (Node->Expr && !Node->bIsTexture) { Target.Connect(0, Node->Expr); return true; } } OutErrors.Add(FString::Printf( TEXT("Topology factor '%s' is neither a Value block nor a Scalar property"), *Factor.Name.ToString())); return false; } /** Recursively build the Substrate expression for topology node `Index`. Returns nullptr on error. */ static UMaterialExpression* BuildTopologyNode( UMaterial& Material, int32 Index, const FShaderLabModel& Model, const TMap& SlabByName, const TMap& ValueByName, const TMap& PropertyNodes, int32& IoY, TArray& OutErrors) { if (!Model.Topology.IsValidIndex(Index)) { OutErrors.Add(TEXT("Invalid topology node index")); return nullptr; } const FShaderLabTopoNode& Node = Model.Topology[Index]; if (Node.Op == EShaderLabOp::SlabRef) { if (UMaterialExpressionSubstrateSlabBSDF* const* Found = SlabByName.Find(Node.SlabRef)) { return *Found; } OutErrors.Add(FString::Printf(TEXT("FrontMaterial references unknown Slab '%s'"), *Node.SlabRef.ToString())); return nullptr; } UMaterialExpression* ChildA = BuildTopologyNode(Material, Node.ChildA, Model, SlabByName, ValueByName, PropertyNodes, IoY, OutErrors); UMaterialExpression* ChildB = (Node.ChildB != INDEX_NONE) ? BuildTopologyNode(Material, Node.ChildB, Model, SlabByName, ValueByName, PropertyNodes, IoY, OutErrors) : nullptr; if (!ChildA || (Node.ChildB != INDEX_NONE && !ChildB)) { return nullptr; } switch (Node.Op) { case EShaderLabOp::VerticalLayer: { UMaterialExpressionSubstrateVerticalLayering* N = NewExpr(Material, IoY, -150); N->Top.Connect(0, ChildA); N->Base.Connect(0, ChildB); return ConnectFactor(Material, N->Thickness, Node.Factor, ValueByName, PropertyNodes, IoY, OutErrors) ? N : nullptr; } case EShaderLabOp::HorizontalMix: { UMaterialExpressionSubstrateHorizontalMixing* N = NewExpr(Material, IoY, -150); N->Background.Connect(0, ChildA); N->Foreground.Connect(0, ChildB); return ConnectFactor(Material, N->Mix, Node.Factor, ValueByName, PropertyNodes, IoY, OutErrors) ? N : nullptr; } case EShaderLabOp::Add: { UMaterialExpressionSubstrateAdd* N = NewExpr(Material, IoY, -150); N->A.Connect(0, ChildA); N->B.Connect(0, ChildB); return N; } case EShaderLabOp::Weight: { UMaterialExpressionSubstrateWeight* N = NewExpr(Material, IoY, -150); N->A.Connect(0, ChildA); return ConnectFactor(Material, N->Weight, Node.Factor, ValueByName, PropertyNodes, IoY, OutErrors) ? N : nullptr; } case EShaderLabOp::Select: { UMaterialExpressionSubstrateSelect* N = NewExpr(Material, IoY, -150); N->A.Connect(0, ChildA); N->B.Connect(0, ChildB); return ConnectFactor(Material, N->SelectValue, Node.Factor, ValueByName, PropertyNodes, IoY, OutErrors) ? N : nullptr; } default: OutErrors.Add(TEXT("Unhandled topology operator")); return nullptr; } } } bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabModel& Model, TArray& OutErrors) { using namespace ShaderLabGraph; // Reset to a clean graph + apply material settings. Material.AssignExpressionCollection(FMaterialExpressionCollection()); Material.MaterialDomain = MapDomain(Model.Settings.Domain); Material.BlendMode = MapBlend(Model.Settings.BlendMode); Material.TwoSided = Model.Settings.bTwoSided ? 1 : 0; Material.bUseMaterialAttributes = false; // Reflected long-tail settings (no usage: the base is a template; usage is set per-instance). // Identical to the runtime shell (same model -> same FShaderLabSettingsApplier). Bad settings are // a hard build failure (contract style). if (!FShaderLabSettingsApplier::ApplyReflectedSettings(Material, Model.RawSettings, OutErrors)) { return false; } UMaterialEditorOnlyData* EditorOnly = Material.GetEditorOnlyData(); if (!EditorOnly) { OutErrors.Add(TEXT("Material has no editor-only data")); return false; } // Per-pixel context is read via UE_* intrinsics, so the Surface entry takes just the output // struct: `Surface(inout FShaderLabSurface S)`. For multi-slab there is no Surface param. const FShaderLabEntryParam* SurfaceOutParam = Model.bHasSurface && Model.SurfaceParams.Num() > 0 ? &Model.SurfaceParams.Last() : nullptr; if (Model.bHasSurface && !SurfaceOutParam) { OutErrors.Add(TEXT("Surface(...) must take an (inout FShaderLabSurface) parameter")); return false; } int32 ParamY = -400; // True if a property is referenced by any body (Surface / Slabs / Values / Vertex) OR used directly // as a topology mix factor (e.g. `VerticalLayer(Coat, Base, Thickness)` with Thickness a Scalar). auto IsPropertyReferenced = [&Model](const FName PropName, const FString& NameStr) -> bool { if (Model.bHasSurface && ReferencesToken(Model.SurfaceBody, NameStr)) { return true; } for (const FShaderLabSlab& Slab : Model.Slabs) { if (ReferencesToken(Slab.Body, NameStr)) { return true; } } for (const FShaderLabValue& Value : Model.Values) { if (ReferencesToken(Value.Body, NameStr)) { return true; } } if (Model.bHasVertex && ReferencesToken(Model.VertexBody, NameStr)) { return true; } for (const FShaderLabTopoNode& Node : Model.Topology) { if (Node.bHasFactor && Node.Factor.Kind == FShaderLabFactor::EKind::Named && Node.Factor.Name == PropName) { return true; } } return false; }; // 1) Create a parameter node per property referenced by any stage. TMap PropertyNodes; // Static-switch selectors funneled into the ParameterAnchor: each is a StaticSwitch over two // `#define 1` / `#define 0` Custom nodes, driven by the switch parameter. The anchor // is compiled before the material attributes and compiles these, so the selected per-permutation // `#define` is emitted ahead of every body's `#if` — per-permutation static switches with zero // engine changes, and nothing wired onto the user's body nodes. TArray AnchorInputs; for (const FShaderLabProperty& Prop : Model.Properties) { const FString NameStr = Prop.Name.ToString(); if (Prop.Type == EShaderLabPropertyType::StaticBool) { if (IsPropertyReferenced(Prop.Name, NameStr)) { // Real static-switch parameter so Material Instances can override it (shown in the MIC editor). // Reached for visibility via the selector below (which the anchor connects). UMaterialExpressionStaticBoolParameter* E = NewExpr(Material, ParamY, -1000); E->ParameterName = Prop.Name; E->DefaultValue = Prop.bStaticBoolDefault ? 1 : 0; E->Group = FName(*Prop.Group); E->SortPriority = Prop.SortPriority; // Two trivial Custom nodes emit `#define Name 1` / `#define Name 0`; a StaticSwitch driven by // the parameter selects one. The translator compiles ONLY the selected branch, so exactly one // `#define` is produced per shader permutation (incl. the MIC's static override). auto MakeDefiner = [&](bool bValue) -> UMaterialExpressionCustom* { UMaterialExpressionCustom* D = NewExpr(Material, ParamY, -1300); D->Description = TEXT("ShaderLab StaticSwitch Define"); D->OutputType = CMOT_Float1; D->Code = TEXT("return 0;"); FCustomDefine DD; DD.DefineName = NameStr; DD.DefineValue = bValue ? TEXT("1") : TEXT("0"); D->AdditionalDefines.Add(DD); return D; }; UMaterialExpressionStaticSwitch* Selector = NewExpr(Material, ParamY, -1150); Selector->A.Connect(0, MakeDefiner(true)); // selected when the switch is TRUE Selector->B.Connect(0, MakeDefiner(false)); // selected when FALSE Selector->Value.Connect(0, E); Selector->DefaultValue = Prop.bStaticBoolDefault; AnchorInputs.Add(Selector); } continue; } if (!IsPropertyReferenced(Prop.Name, NameStr)) { continue; // Unused value property: skip (keeps the graph minimal and deterministic). } FParamNode Node; switch (Prop.Type) { case EShaderLabPropertyType::Scalar: { UMaterialExpressionScalarParameter* E = NewExpr(Material, ParamY, -1000); E->ParameterName = Prop.Name; E->DefaultValue = Prop.ScalarDefault; E->Group = FName(*Prop.Group); E->SortPriority = Prop.SortPriority; if (Prop.bHasRange) { E->SliderMin = Prop.RangeMin; E->SliderMax = Prop.RangeMax; } Node.Expr = E; break; } case EShaderLabPropertyType::Color: case EShaderLabPropertyType::Vector: { UMaterialExpressionVectorParameter* E = NewExpr(Material, ParamY, -1000); E->ParameterName = Prop.Name; E->DefaultValue = Prop.VectorDefault; E->Group = FName(*Prop.Group); E->SortPriority = Prop.SortPriority; Node.Expr = E; break; } case EShaderLabPropertyType::Texture2D: case EShaderLabPropertyType::TextureCube: { UMaterialExpressionTextureObjectParameter* E = NewExpr(Material, ParamY, -1000); E->ParameterName = Prop.Name; E->Group = FName(*Prop.Group); E->SortPriority = Prop.SortPriority; bool bIsNormal = false; E->Texture = ResolveDefaultTexture(Prop.TextureDefault, bIsNormal); E->SamplerType = bIsNormal ? SAMPLERTYPE_Normal : SAMPLERTYPE_Color; Node.Expr = E; Node.bIsTexture = true; break; } default: break; } if (Node.Expr) { PropertyNodes.Add(Prop.Name, Node); } } // 2) Build the pixel stage and connect it to FrontMaterial (what makes it a Substrate material). if (Model.bHasSurface && Model.SurfaceEntry == EShaderLabEntry::PostProcess) { // PostProcess domain: Color -> EmissiveColor, Opacity -> Opacity (no Substrate slab). if (!BuildEmissiveEntry(Material, *EditorOnly, TEXT("FShaderLabPostProcess"), TEXT("ShaderLabDefaultPostProcess"), SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, Model, PropertyNodes, ParamY, OutErrors)) { return false; } } else if (Model.bHasSurface && Model.SurfaceEntry == EShaderLabEntry::UI) { if (!BuildEmissiveEntry(Material, *EditorOnly, TEXT("FShaderLabUI"), TEXT("ShaderLabDefaultUI"), SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, Model, PropertyNodes, ParamY, OutErrors)) { return false; } } else if (Model.bHasSurface) { // Single-Surface sugar: one slab straight to FrontMaterial, with S.Opacity/S.OpacityMask // allowed as material-level outputs. UMaterialExpressionSubstrateSlabBSDF* Slab = BuildSlab( Material, *EditorOnly, SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, Model, PropertyNodes, /*bAllowMaterialOutputs*/ true, ParamY, OutErrors); if (!Slab) { return false; } EditorOnly->FrontMaterial.Connect(0, Slab); } else { // Multi-slab: each named Slab -> its own slab node; Value blocks -> scalar Custom nodes; the // FrontMaterial topology tree mixes them; Opacity/OpacityMask come from named Value blocks. TMap SlabByName; for (const FShaderLabSlab& SlabDecl : Model.Slabs) { if (SlabByName.Contains(SlabDecl.Name)) { OutErrors.Add(FString::Printf(TEXT("Duplicate Slab name '%s'"), *SlabDecl.Name.ToString())); return false; } UMaterialExpressionSubstrateSlabBSDF* Slab = BuildSlab( Material, *EditorOnly, SlabDecl.OutParamName, SlabDecl.Body, SlabDecl.BodyLine, Model, PropertyNodes, /*bAllowMaterialOutputs*/ false, ParamY, OutErrors); if (!Slab) { return false; } SlabByName.Add(SlabDecl.Name, Slab); } TMap ValueByName; for (const FShaderLabValue& ValueDecl : Model.Values) { if (ValueByName.Contains(ValueDecl.Name)) { OutErrors.Add(FString::Printf(TEXT("Duplicate Value name '%s'"), *ValueDecl.Name.ToString())); return false; } UMaterialExpressionCustom* ValueNode = BuildValueNode( Material, ValueDecl, Model, PropertyNodes, ParamY, OutErrors); if (!ValueNode) { return false; } ValueByName.Add(ValueDecl.Name, ValueNode); } // Every declared Slab must be reachable from FrontMaterial (contract: no dead slabs). TSet ReferencedSlabs; for (const FShaderLabTopoNode& Node : Model.Topology) { if (Node.Op == EShaderLabOp::SlabRef) { ReferencedSlabs.Add(Node.SlabRef); } } for (const FShaderLabSlab& SlabDecl : Model.Slabs) { if (!ReferencedSlabs.Contains(SlabDecl.Name)) { OutErrors.Add(FString::Printf(TEXT("Slab '%s' is declared but never used in FrontMaterial"), *SlabDecl.Name.ToString())); return false; } } UMaterialExpression* Root = BuildTopologyNode( Material, Model.TopologyRoot, Model, SlabByName, ValueByName, PropertyNodes, ParamY, OutErrors); if (!Root) { return false; } EditorOnly->FrontMaterial.Connect(0, Root); // Material-level Opacity / OpacityMask from named Value blocks. auto ConnectMaterialOutput = [&](FExpressionInput& Pin, FName ValueName, const TCHAR* What) -> bool { if (ValueName.IsNone()) { return true; } UMaterialExpressionCustom* const* ValueNode = ValueByName.Find(ValueName); if (!ValueNode) { OutErrors.Add(FString::Printf(TEXT("%s references unknown Value '%s'"), What, *ValueName.ToString())); return false; } Pin.Connect(0, *ValueNode); return true; }; if (!ConnectMaterialOutput(EditorOnly->Opacity, Model.OpacityValueName, TEXT("Opacity"))) { return false; } if (!ConnectMaterialOutput(EditorOnly->OpacityMask, Model.OpacityMaskValueName, TEXT("OpacityMask"))) { return false; } } // 3) Optional Vertex stage. Per-pixel/vertex context is read via UE_* intrinsics, so the entry // takes just the output struct: `Vertex(inout FShaderLabVertex V)`. if (Model.bHasVertex && Model.VertexParams.Num() >= 1) { const FShaderLabEntryParam& VtxOut = Model.VertexParams.Last(); TArray UsedVtx; for (const FVertexFieldDef& F : GVertexFields) { if (ReferencesToken(Model.VertexBody, VtxOut.Name + TEXT(".") + F.Field)) { UsedVtx.Add(&F); } } if (UsedVtx.Num() > 0) { UMaterialExpressionCustom* VCustom = NewExpr(Material, ParamY, -300); VCustom->Description = TEXT("ShaderLab Vertex"); VCustom->OutputType = CMOT_Float1; AddIncludes(*VCustom, Model); FString Code; // Intrinsics (Stage = vertex). FString VertexBody = Model.VertexBody; TArray VtxIntrinsicWires; if (!EmitIntrinsics(Material, EShaderLabIntrinsicFrequency::VertexOnly, VertexBody, Model.VertexBodyLine, MakeLineDirectivePath(Model.SourceFilePath), VtxIntrinsicWires, OutErrors)) { return false; } for (const FIntrinsicWire& Wire : VtxIntrinsicWires) { FCustomInput In; In.InputName = Wire.InputName; In.Input.Connect(Wire.OutputIndex, Wire.Expr); VCustom->Inputs.Add(In); } for (const FShaderLabProperty& Prop : Model.Properties) { if (Prop.Type == EShaderLabPropertyType::StaticBool) { continue; } if (!ReferencesToken(Model.VertexBody, Prop.Name.ToString())) { continue; } const FParamNode* Node = PropertyNodes.Find(Prop.Name); if (!Node || !Node->Expr) { continue; } FCustomInput In; In.InputName = Prop.Name; In.Input.Connect(0, Node->Expr); VCustom->Inputs.Add(In); } const FString VSrcPath = MakeLineDirectivePath(Model.SourceFilePath); Code += FString::Printf(TEXT("FShaderLabVertex %s = ShaderLabDefaultVertex();\n{\n%s}\n"), *VtxOut.Name, *WrapBodyWithLineMapping(VertexBody, Model.VertexBodyLine, VSrcPath)); int32 VOutputIndex = 1; TArray> VtxOutputs; for (const FVertexFieldDef* F : UsedVtx) { FCustomOutput Out; Out.OutputName = FName(*(FString(TEXT("SLO_")) + F->Field)); Out.OutputType = F->OutType; VCustom->AdditionalOutputs.Add(Out); Code += FString::Printf(TEXT("SLO_%s = %s.%s;\n"), F->Field, *VtxOut.Name, F->Field); VtxOutputs.Add(TPair(F->Field, VOutputIndex)); ++VOutputIndex; } Code += TEXT("return 0.0f;\n"); VCustom->Code = Code; VCustom->RebuildOutputs(); for (const TPair& Pair : VtxOutputs) { if (Pair.Key == TEXT("WorldPositionOffset")) { EditorOnly->WorldPositionOffset.Connect(Pair.Value, VCustom); } else if (Pair.Key == TEXT("Displacement")) { EditorOnly->Displacement.Connect(Pair.Value, VCustom); } else if (Pair.Key.StartsWith(TEXT("CustomizedUV"))) { const int32 UvIndex = FCString::Atoi(*Pair.Key.Mid(12)); if (UvIndex >= 0 && UvIndex < 8) { EditorOnly->CustomizedUVs[UvIndex].Connect(Pair.Value, VCustom); } } } } } // Funnel every static-switch selector into the ParameterAnchor. The anchor is a CustomOutput compiled // BEFORE the material attributes (ShouldCompileBeforeAttributes), so compiling it compiles each // selector — emitting the selected `#define 0/1` for the current permutation ahead of every // body's `#if`. This gives per-permutation static switches with zero engine changes, and keeps the // machinery off the user's body nodes. The anchor also makes the switch parameters visible in the // Material Instance editor (reached via selector -> Value -> parameter). if (AnchorInputs.Num() > 0) { UMaterialExpressionShaderLabParameterAnchor* Anchor = NewExpr(Material, ParamY, -1300); Anchor->Inputs.SetNum(AnchorInputs.Num()); for (int32 Index = 0; Index < AnchorInputs.Num(); ++Index) { Anchor->Inputs[Index].Connect(0, AnchorInputs[Index]); } } Material.UpdateCachedExpressionData(); return true; }