// Copyright UShaderLab. 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/MaterialExpressionCollectionParameter.h" #include "Materials/MaterialExpressionCustom.h" #include "Materials/MaterialParameterCollection.h" #include "Materials/MaterialExpressionScalarParameter.h" #include "Materials/MaterialExpressionStaticBoolParameter.h" #include "Materials/MaterialExpressionStaticSwitch.h" #include "Materials/MaterialExpressionQualitySwitch.h" #include "Materials/MaterialExpressionFeatureLevelSwitch.h" #include "Materials/MaterialExpressionShadingPathSwitch.h" #include "SceneTypes.h" #include "RHIDefinitions.h" #include "Materials/MaterialExpressionSubstrate.h" #include "Materials/MaterialExpressionSingleLayerWaterMaterialOutput.h" #include "Materials/MaterialExpressionSceneTexture.h" #include "Engine/SubsurfaceProfile.h" #include "Engine/SpecularProfile.h" #include "Engine/ToonProfile.h" #include "Materials/MaterialExpressionTextureObjectParameter.h" #include "Materials/MaterialExpressionTextureSampleParameter2D.h" #include "Materials/MaterialExpressionTextureCoordinate.h" #include "Materials/MaterialExpressionRuntimeVirtualTextureSampleParameter.h" #include "Materials/MaterialExpressionRuntimeVirtualTextureOutput.h" #include "VT/RuntimeVirtualTextureEnum.h" #include "VT/RuntimeVirtualTexture.h" #include "Materials/MaterialExpressionVectorParameter.h" #include "Materials/MaterialExpressionVertexInterpolator.h" #include "MaterialExpressionShaderLabParameterAnchor.h" #include "ShaderLabIntrinsicRegistry.h" #include "ShaderLabAnchorCapabilityRegistry.h" #include "ShaderLabImportResolver.h" #include "UObject/Class.h" #include "ShaderLabSettingsApplier.h" #include "UObject/UObjectGlobals.h" #include "HAL/FileManager.h" #include "Interfaces/IPluginManager.h" #include "Misc/FileHelper.h" #include "ShaderCore.h" #include "SceneTypes.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; } // Virtual-shader-path -> disk resolution is now the shared FShaderLabGraphBuilder::ResolveVirtualShaderFile // (defined below, exposed in the header) so the editor module (hot-reload loader) and the VSCode button // reuse the same longest-prefix-match logic instead of each hand-rolling it. /** * 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; } /** * Map a non-virtual SamplerType token (SL_PROPERTY(SamplerType=...)) to EMaterialSamplerType. Returns * false for an unknown or Virtual* token (Virtual belongs to SL_VTSAMPLE, not a plain texture property). */ static bool MapSamplerType(const FString& Token, EMaterialSamplerType& Out) { if (Token == TEXT("Color")) { Out = SAMPLERTYPE_Color; return true; } if (Token == TEXT("LinearColor")) { Out = SAMPLERTYPE_LinearColor; return true; } if (Token == TEXT("Grayscale")) { Out = SAMPLERTYPE_Grayscale; return true; } if (Token == TEXT("LinearGrayscale")) { Out = SAMPLERTYPE_LinearGrayscale; return true; } if (Token == TEXT("Alpha")) { Out = SAMPLERTYPE_Alpha; return true; } if (Token == TEXT("Normal")) { Out = SAMPLERTYPE_Normal; return true; } if (Token == TEXT("Masks")) { Out = SAMPLERTYPE_Masks; return true; } if (Token == TEXT("DistanceFieldFont")) { Out = SAMPLERTYPE_DistanceFieldFont; return true; } if (Token == TEXT("Data")) { Out = SAMPLERTYPE_Data; return true; } return false; } /** Map a Virtual* SamplerType token (SL_VTSAMPLE(SamplerType=...)) to EMaterialSamplerType. */ static bool MapVirtualSamplerType(const FString& Token, EMaterialSamplerType& Out) { if (Token == TEXT("VirtualColor")) { Out = SAMPLERTYPE_VirtualColor; return true; } if (Token == TEXT("VirtualGrayscale")) { Out = SAMPLERTYPE_VirtualGrayscale; return true; } if (Token == TEXT("VirtualAlpha")) { Out = SAMPLERTYPE_VirtualAlpha; return true; } if (Token == TEXT("VirtualNormal")) { Out = SAMPLERTYPE_VirtualNormal; return true; } if (Token == TEXT("VirtualMasks")) { Out = SAMPLERTYPE_VirtualMasks; return true; } if (Token == TEXT("VirtualLinearColor")) { Out = SAMPLERTYPE_VirtualLinearColor; return true; } if (Token == TEXT("VirtualLinearGrayscale")) { Out = SAMPLERTYPE_VirtualLinearGrayscale; return true; } return false; } /** Map an SL_RVTSAMPLE MaterialType token to ERuntimeVirtualTextureMaterialType. */ static bool MapRVTMaterialType(const FString& Token, ERuntimeVirtualTextureMaterialType& Out) { if (Token == TEXT("BaseColor")) { Out = ERuntimeVirtualTextureMaterialType::BaseColor; return true; } if (Token == TEXT("Mask4")) { Out = ERuntimeVirtualTextureMaterialType::Mask4; return true; } if (Token == TEXT("BaseColor_Normal_Roughness")) { Out = ERuntimeVirtualTextureMaterialType::BaseColor_Normal_Roughness; return true; } if (Token == TEXT("BaseColor_Normal_Specular")) { Out = ERuntimeVirtualTextureMaterialType::BaseColor_Normal_Specular; return true; } if (Token == TEXT("BaseColor_Normal_Specular_YCoCg")) { Out = ERuntimeVirtualTextureMaterialType::BaseColor_Normal_Specular_YCoCg; return true; } if (Token == TEXT("BaseColor_Normal_Specular_Mask_YCoCg")) { Out = ERuntimeVirtualTextureMaterialType::BaseColor_Normal_Specular_Mask_YCoCg; return true; } if (Token == TEXT("WorldHeight")) { Out = ERuntimeVirtualTextureMaterialType::WorldHeight; return true; } if (Token == TEXT("Displacement")) { Out = ERuntimeVirtualTextureMaterialType::Displacement; return true; } return false; } // FShaderLabRVT member -> RuntimeVirtualTextureSample output pin index + Custom-input HLSL type. // Pin order mirrors UMaterialExpressionRuntimeVirtualTextureSample::InitOutputs (MaterialExpressions.cpp). struct FRVTMemberDef { const TCHAR* Member; int32 PinIndex; ECustomMaterialOutputType OutType; }; static const FRVTMemberDef GRVTMembers[] = { { TEXT("BaseColor"), 0, CMOT_Float3 }, { TEXT("Specular"), 1, CMOT_Float1 }, { TEXT("Roughness"), 2, CMOT_Float1 }, { TEXT("Normal"), 3, CMOT_Float3 }, { TEXT("WorldHeight"), 4, CMOT_Float1 }, { TEXT("Mask"), 5, CMOT_Float1 }, { TEXT("Displacement"), 6, CMOT_Float1 }, { TEXT("Mask4"), 7, CMOT_Float4 }, }; // FShaderLabRVTOutput field -> RuntimeVirtualTextureOutput input pin + Custom-output HLSL type. struct FRVTOutFieldDef { const TCHAR* Field; ECustomMaterialOutputType OutType; }; static const FRVTOutFieldDef GRVTOutFields[] = { { TEXT("BaseColor"), CMOT_Float3 }, { TEXT("Specular"), CMOT_Float1 }, { TEXT("Roughness"), CMOT_Float1 }, { TEXT("Normal"), CMOT_Float3 }, { TEXT("WorldHeight"), CMOT_Float1 }, { TEXT("Opacity"), CMOT_Float1 }, { TEXT("Mask"), CMOT_Float1 }, { TEXT("Displacement"), CMOT_Float1 }, { TEXT("Mask4"), CMOT_Float4 }, }; static FExpressionInput* GetRVTOutputPin(UMaterialExpressionRuntimeVirtualTextureOutput* N, const FString& F) { if (F == TEXT("BaseColor")) return &N->BaseColor; if (F == TEXT("Specular")) return &N->Specular; if (F == TEXT("Roughness")) return &N->Roughness; if (F == TEXT("Normal")) return &N->Normal; if (F == TEXT("WorldHeight")) return &N->WorldHeight; if (F == TEXT("Opacity")) return &N->Opacity; if (F == TEXT("Mask")) return &N->Mask; if (F == TEXT("Displacement")) return &N->Displacement; if (F == TEXT("Mask4")) return &N->Mask4; return nullptr; } 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::Volume: return MD_Volume; case EShaderLabDomain::LightFunction: return MD_LightFunction; case EShaderLabDomain::Surface: default: return MD_Surface; } } template static T* NewExpr(UMaterial& Material, int32& IoY, int32 Column); // A Substrate material in the Decal domain must route its BSDF through a SubstrateConvertToDecal node — that // node flags the material with the SSM_Decal shading model, without which the engine's Substrate sanitization // (Material.cpp) silently resets MaterialDomain back to MD_Surface (→ DecalComponent then rejects it with // "Decal Material must use Deferred Decal Material Domain"). This mirrors exactly what the material editor // inserts when you pick the Deferred Decal domain. Returns the node to connect to FrontMaterial (the wrapper // for Decal, otherwise the BSDF unchanged). static UMaterialExpression* WrapBsdfForDecal(UMaterial& Material, const FShaderLabModel& Model, UMaterialExpression* Bsdf, int32& IoY) { if (Model.Settings.Domain != EShaderLabDomain::Decal) { return Bsdf; } UMaterialExpressionSubstrateConvertToDecal* Node = NewExpr(Material, IoY, 300); Node->DecalMaterial.Connect(0, Bsdf); return Node; } 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::AlphaComposite: return BLEND_AlphaComposite; case EShaderLabBlendMode::AlphaHoldout: return BLEND_AlphaHoldout; 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; } /** A trivial Custom node that emits `#define ` (used for the before-attributes define leak). */ static UMaterialExpressionCustom* MakeDefineNode(UMaterial& Material, int32& IoY, const TCHAR* Name, const TCHAR* Value) { UMaterialExpressionCustom* D = NewExpr(Material, IoY, -1300); D->Description = TEXT("ShaderLab Define"); D->OutputType = CMOT_Float1; D->Code = TEXT("return 0;"); FCustomDefine DD; DD.DefineName = Name; DD.DefineValue = Value; D->AdditionalDefines.Add(DD); return D; } // --- Per-BSDF descriptor ----------------------------------------------------------------------- // Each Substrate BSDF is described by {output struct, default fn, field->pin table, node factory, // pin resolver}. The generic BuildBsdf() works off this so all BSDFs share one code path. The engine // BSDF node classes are distinct C++ types with no common named-pin base, so each descriptor supplies // its own MakeNode (news the concrete node) and GetPin (casts + returns the FExpressionInput*). struct FBsdfDesc { EShaderLabBsdfType Type; const TCHAR* CustomDesc; // Custom-node Description (kept "ShaderLab Surface" for Slab: tests key on it) const TCHAR* StructName; // e.g. "FShaderLabSurface" const TCHAR* DefaultFn; // e.g. "ShaderLabDefaultSurface" const FSlabFieldDef* Fields; int32 NumFields; UMaterialExpression* (*MakeNode)(UMaterial&, int32& IoY); FExpressionInput* (*GetPin)(UMaterialExpression*, const FString& Field); }; static UMaterialExpression* MakeSlabNode(UMaterial& M, int32& IoY) { return NewExpr(M, IoY, 0); } static FExpressionInput* GetSlabPinGeneric(UMaterialExpression* Node, const FString& Field) { UMaterialExpressionSubstrateSlabBSDF* Slab = Cast(Node); return Slab ? GetSlabPin(Slab, Field) : nullptr; } // --- Unlit --- static const FSlabFieldDef GUnlitFields[] = { { TEXT("EmissiveColor"), CMOT_Float3 }, { TEXT("TransmittanceColor"), CMOT_Float3 }, { TEXT("Normal"), CMOT_Float3 }, }; static UMaterialExpression* MakeUnlitNode(UMaterial& M, int32& IoY) { return NewExpr(M, IoY, 0); } static FExpressionInput* GetUnlitPin(UMaterialExpression* N, const FString& F) { UMaterialExpressionSubstrateUnlitBSDF* B = Cast(N); if (!B) return nullptr; if (F == TEXT("EmissiveColor")) return &B->EmissiveColor; if (F == TEXT("TransmittanceColor")) return &B->TransmittanceColor; if (F == TEXT("Normal")) return &B->Normal; return nullptr; } // --- Hair --- static const FSlabFieldDef GHairFields[] = { { TEXT("BaseColor"), CMOT_Float3 }, { TEXT("Scatter"), CMOT_Float1 }, { TEXT("Specular"), CMOT_Float1 }, { TEXT("Roughness"), CMOT_Float1 }, { TEXT("Backlit"), CMOT_Float3 }, { TEXT("Tangent"), CMOT_Float3 }, { TEXT("EmissiveColor"), CMOT_Float3 }, }; static UMaterialExpression* MakeHairNode(UMaterial& M, int32& IoY) { return NewExpr(M, IoY, 0); } static FExpressionInput* GetHairPin(UMaterialExpression* N, const FString& F) { UMaterialExpressionSubstrateHairBSDF* B = Cast(N); if (!B) return nullptr; if (F == TEXT("BaseColor")) return &B->BaseColor; if (F == TEXT("Scatter")) return &B->Scatter; if (F == TEXT("Specular")) return &B->Specular; if (F == TEXT("Roughness")) return &B->Roughness; if (F == TEXT("Backlit")) return &B->Backlit; if (F == TEXT("Tangent")) return &B->Tangent; if (F == TEXT("EmissiveColor")) return &B->EmissiveColor; return nullptr; } // --- Eye --- static const FSlabFieldDef GEyeFields[] = { { TEXT("DiffuseColor"), CMOT_Float3 }, { TEXT("Roughness"), CMOT_Float1 }, { TEXT("CorneaNormal"), CMOT_Float3 }, { TEXT("IrisNormal"), CMOT_Float3 }, { TEXT("IrisPlaneNormal"), CMOT_Float3 }, { TEXT("IrisMask"), CMOT_Float1 }, { TEXT("IrisDistance"), CMOT_Float1 }, { TEXT("EmissiveColor"), CMOT_Float3 }, }; static UMaterialExpression* MakeEyeNode(UMaterial& M, int32& IoY) { return NewExpr(M, IoY, 0); } static FExpressionInput* GetEyePin(UMaterialExpression* N, const FString& F) { UMaterialExpressionSubstrateEyeBSDF* B = Cast(N); if (!B) return nullptr; if (F == TEXT("DiffuseColor")) return &B->DiffuseColor; if (F == TEXT("Roughness")) return &B->Roughness; if (F == TEXT("CorneaNormal")) return &B->CorneaNormal; if (F == TEXT("IrisNormal")) return &B->IrisNormal; if (F == TEXT("IrisPlaneNormal")) return &B->IrisPlaneNormal; if (F == TEXT("IrisMask")) return &B->IrisMask; if (F == TEXT("IrisDistance")) return &B->IrisDistance; if (F == TEXT("EmissiveColor")) return &B->EmissiveColor; return nullptr; } // --- Single Layer Water --- static const FSlabFieldDef GWaterFields[] = { { TEXT("BaseColor"), CMOT_Float3 }, { TEXT("Metallic"), CMOT_Float1 }, { TEXT("Specular"), CMOT_Float1 }, { TEXT("Roughness"), CMOT_Float1 }, { TEXT("Normal"), CMOT_Float3 }, { TEXT("EmissiveColor"), CMOT_Float3 }, { TEXT("TopMaterialOpacity"), CMOT_Float3 }, { TEXT("WaterAlbedo"), CMOT_Float3 }, { TEXT("WaterExtinction"), CMOT_Float3 }, { TEXT("WaterPhaseG"), CMOT_Float1 }, { TEXT("ColorScaleBehindWater"), CMOT_Float3 }, }; static UMaterialExpression* MakeWaterNode(UMaterial& M, int32& IoY) { return NewExpr(M, IoY, 0); } static FExpressionInput* GetWaterPin(UMaterialExpression* N, const FString& F) { UMaterialExpressionSubstrateSingleLayerWaterBSDF* B = Cast(N); if (!B) return nullptr; if (F == TEXT("BaseColor")) return &B->BaseColor; if (F == TEXT("Metallic")) return &B->Metallic; if (F == TEXT("Specular")) return &B->Specular; if (F == TEXT("Roughness")) return &B->Roughness; if (F == TEXT("Normal")) return &B->Normal; if (F == TEXT("EmissiveColor")) return &B->EmissiveColor; if (F == TEXT("TopMaterialOpacity")) return &B->TopMaterialOpacity; if (F == TEXT("WaterAlbedo")) return &B->WaterAlbedo; if (F == TEXT("WaterExtinction")) return &B->WaterExtinction; if (F == TEXT("WaterPhaseG")) return &B->WaterPhaseG; if (F == TEXT("ColorScaleBehindWater")) return &B->ColorScaleBehindWater; return nullptr; } // --- Volumetric-Fog-Cloud --- static const FSlabFieldDef GVolumeFields[] = { { TEXT("Albedo"), CMOT_Float3 }, { TEXT("Extinction"), CMOT_Float3 }, { TEXT("EmissiveColor"), CMOT_Float3 }, { TEXT("AmbientOcclusion"), CMOT_Float1 }, }; static UMaterialExpression* MakeVolumeNode(UMaterial& M, int32& IoY) { return NewExpr(M, IoY, 0); } static FExpressionInput* GetVolumePin(UMaterialExpression* N, const FString& F) { UMaterialExpressionSubstrateVolumetricFogCloudBSDF* B = Cast(N); if (!B) return nullptr; if (F == TEXT("Albedo")) return &B->Albedo; if (F == TEXT("Extinction")) return &B->Extinction; if (F == TEXT("EmissiveColor")) return &B->EmissiveColor; if (F == TEXT("AmbientOcclusion")) return &B->AmbientOcclusion; return nullptr; } // --- Simple Clear Coat --- static const FSlabFieldDef GClearCoatFields[] = { { TEXT("DiffuseAlbedo"), CMOT_Float3 }, { TEXT("F0"), CMOT_Float3 }, { TEXT("Roughness"), CMOT_Float1 }, { TEXT("ClearCoatCoverage"), CMOT_Float1 }, { TEXT("ClearCoatRoughness"), CMOT_Float1 }, { TEXT("Normal"), CMOT_Float3 }, { TEXT("EmissiveColor"), CMOT_Float3 }, { TEXT("BottomNormal"), CMOT_Float3 }, }; static UMaterialExpression* MakeClearCoatNode(UMaterial& M, int32& IoY) { return NewExpr(M, IoY, 0); } static FExpressionInput* GetClearCoatPin(UMaterialExpression* N, const FString& F) { UMaterialExpressionSubstrateSimpleClearCoatBSDF* B = Cast(N); if (!B) return nullptr; if (F == TEXT("DiffuseAlbedo")) return &B->DiffuseAlbedo; if (F == TEXT("F0")) return &B->F0; if (F == TEXT("Roughness")) return &B->Roughness; if (F == TEXT("ClearCoatCoverage")) return &B->ClearCoatCoverage; if (F == TEXT("ClearCoatRoughness")) return &B->ClearCoatRoughness; if (F == TEXT("Normal")) return &B->Normal; if (F == TEXT("EmissiveColor")) return &B->EmissiveColor; if (F == TEXT("BottomNormal")) return &B->BottomNormal; return nullptr; } // --- Toon (experimental) --- static const FSlabFieldDef GToonFields[] = { { TEXT("BaseColor"), CMOT_Float3 }, { TEXT("Metallic"), CMOT_Float1 }, { TEXT("Specular"), CMOT_Float1 }, { TEXT("Roughness"), CMOT_Float1 }, { TEXT("Normal"), CMOT_Float3 }, { TEXT("EmissiveColor"), CMOT_Float3 }, { TEXT("PatternUVs"), CMOT_Float2 }, { TEXT("Anisotropy"), CMOT_Float1 }, { TEXT("Tangent"), CMOT_Float3 }, }; static UMaterialExpression* MakeToonNode(UMaterial& M, int32& IoY) { return NewExpr(M, IoY, 0); } static FExpressionInput* GetToonPin(UMaterialExpression* N, const FString& F) { UMaterialExpressionSubstrateToonBSDF* B = Cast(N); if (!B) return nullptr; if (F == TEXT("BaseColor")) return &B->BaseColor; if (F == TEXT("Metallic")) return &B->Metallic; if (F == TEXT("Specular")) return &B->Specular; if (F == TEXT("Roughness")) return &B->Roughness; if (F == TEXT("Normal")) return &B->Normal; if (F == TEXT("EmissiveColor")) return &B->EmissiveColor; if (F == TEXT("PatternUVs")) return &B->PatternUVs; if (F == TEXT("Anisotropy")) return &B->Anisotropy; if (F == TEXT("Tangent")) return &B->Tangent; return nullptr; } // --- Light Function --- static const FSlabFieldDef GLightFunctionFields[] = { { TEXT("Color"), CMOT_Float3 }, }; static UMaterialExpression* MakeLightFunctionNode(UMaterial& M, int32& IoY) { return NewExpr(M, IoY, 0); } static FExpressionInput* GetLightFunctionPin(UMaterialExpression* N, const FString& F) { UMaterialExpressionSubstrateLightFunction* B = Cast(N); if (!B) return nullptr; if (F == TEXT("Color")) return &B->Color; return nullptr; } static const FBsdfDesc GBsdfDescs[] = { { EShaderLabBsdfType::Slab, TEXT("ShaderLab Surface"), TEXT("FShaderLabSurface"), TEXT("ShaderLabDefaultSurface"), GSlabFields, UE_ARRAY_COUNT(GSlabFields), &MakeSlabNode, &GetSlabPinGeneric }, { EShaderLabBsdfType::Unlit, TEXT("ShaderLab Unlit"), TEXT("FShaderLabUnlit"), TEXT("ShaderLabDefaultUnlit"), GUnlitFields, UE_ARRAY_COUNT(GUnlitFields), &MakeUnlitNode, &GetUnlitPin }, { EShaderLabBsdfType::Hair, TEXT("ShaderLab Hair"), TEXT("FShaderLabHair"), TEXT("ShaderLabDefaultHair"), GHairFields, UE_ARRAY_COUNT(GHairFields), &MakeHairNode, &GetHairPin }, { EShaderLabBsdfType::Eye, TEXT("ShaderLab Eye"), TEXT("FShaderLabEye"), TEXT("ShaderLabDefaultEye"), GEyeFields, UE_ARRAY_COUNT(GEyeFields), &MakeEyeNode, &GetEyePin }, { EShaderLabBsdfType::Water, TEXT("ShaderLab Water"), TEXT("FShaderLabWater"), TEXT("ShaderLabDefaultWater"), GWaterFields, UE_ARRAY_COUNT(GWaterFields), &MakeWaterNode, &GetWaterPin }, { EShaderLabBsdfType::Volume, TEXT("ShaderLab Volume"), TEXT("FShaderLabVolume"), TEXT("ShaderLabDefaultVolume"), GVolumeFields, UE_ARRAY_COUNT(GVolumeFields), &MakeVolumeNode, &GetVolumePin }, { EShaderLabBsdfType::ClearCoat, TEXT("ShaderLab ClearCoat"), TEXT("FShaderLabClearCoat"), TEXT("ShaderLabDefaultClearCoat"), GClearCoatFields, UE_ARRAY_COUNT(GClearCoatFields), &MakeClearCoatNode, &GetClearCoatPin }, { EShaderLabBsdfType::Toon, TEXT("ShaderLab Toon"), TEXT("FShaderLabToon"), TEXT("ShaderLabDefaultToon"), GToonFields, UE_ARRAY_COUNT(GToonFields), &MakeToonNode, &GetToonPin }, { EShaderLabBsdfType::LightFunction, TEXT("ShaderLab LightFunction"), TEXT("FShaderLabLightFunction"), TEXT("ShaderLabDefaultLightFunction"), GLightFunctionFields, UE_ARRAY_COUNT(GLightFunctionFields), &MakeLightFunctionNode, &GetLightFunctionPin }, }; static const FBsdfDesc* FindBsdfDesc(EShaderLabBsdfType Type) { for (const FBsdfDesc& D : GBsdfDescs) { if (D.Type == Type) { return &D; } } return nullptr; } /** 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; }; /** * Pre-sampled VT/RVT read nodes available to a (pixel) body, keyed by the DSL name. Because a virtual * texture cannot be sampled inside an opaque Custom node, these real sample nodes are built up-front and * their outputs wired into the body as inputs by PrepareBody: a VT `` becomes a float4 input; an * RVT `.` is rewritten to a per-member input carrying the matching output pin. Null maps * (vertex/interpolator bodies) mean "no VT/RVT here" — a reference there is rejected as pixel-only. */ struct FSampleNodes { const TMap* VT = nullptr; // name -> UMaterialExpressionTextureSampleParameter2D (Virtual*) const TMap* RVT = nullptr; // name -> UMaterialExpressionRuntimeVirtualTextureSampleParameter bool HasAny() const { return (VT && VT->Num() > 0) || (RVT && RVT->Num() > 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, const TMap& InterpByName, TSet& UsedInterps, 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; // UE_Interpolator(Name): read a Vertex Interpolator's value in the pixel shader. Not a // registry intrinsic — its arg is an interpolator name (not a numeric/enum literal), so // handle it before the registry lookup and arg-literal validation below. if (Name == TEXT("Interpolator")) { const FString InterpNameStr = ArgsRaw.TrimStartAndEnd(); FName InputName; // None on error -> substituted blank (error already recorded) if (Stage == EShaderLabIntrinsicFrequency::VertexOnly) { OutErrors.Add(Loc(i) + TEXT("intrinsic 'UE_Interpolator' is pixel-only and cannot be used in a Vertex or Interpolator body")); bOk = false; } else if (InterpNameStr.IsEmpty()) { OutErrors.Add(Loc(i) + TEXT("intrinsic 'UE_Interpolator' requires an interpolator name argument")); bOk = false; } else { const FString Key = FString(TEXT("Interpolator|")) + InterpNameStr; if (const FName* Existing = InputByKey.Find(Key)) { InputName = *Existing; } else if (UMaterialExpression* const* Node = InterpByName.Find(FName(*InterpNameStr))) { InputName = FName(*(FString(TEXT("SLI_Interp_")) + InterpNameStr)); InputByKey.Add(Key, InputName); OutWires.Add(FIntrinsicWire{ InputName, *Node, 0 }); UsedInterps.Add(FName(*InterpNameStr)); } else { OutErrors.Add(Loc(i) + FString::Printf(TEXT("intrinsic 'UE_Interpolator' references unknown interpolator '%s'"), *InterpNameStr)); bOk = false; } } FString Replacement = InputName.IsNone() ? FString() : InputName.ToString(); while (Replacement.Len() < CallLen) { Replacement.AppendChar(TEXT(' ')); } Result += Replacement; i = m + 1; continue; } // 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. // // Overloaded intrinsics (bAllowRawHelperWithArgs) also fall through here when the call // carries arguments: the nullary form is the wired intrinsic, but `UE_Name()` is a // same-named raw HLSL helper — leave it for the shader compiler (its side-effect binding // is handled by an anchor capability). const FShaderLabIntrinsicDesc* Found = Registry.Find(FName(*Name)); const bool bRawHelperOverload = Found && Found->bAllowRawHelperWithArgs && SplitArgs(ArgsRaw).Num() > Found->Params.Num(); if (!Found || bRawHelperOverload) { 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; } // ===================================================================================================== // 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; case EShaderLabPropertyType::Texture2DArray: OutParams.Add(FString::Printf(TEXT("Texture2DArray %s"), *Name)); OutParams.Add(FString::Printf(TEXT("SamplerState %sSampler"), *Name)); break; case EShaderLabPropertyType::Texture3D: OutParams.Add(FString::Printf(TEXT("Texture3D %s"), *Name)); OutParams.Add(FString::Printf(TEXT("SamplerState %sSampler"), *Name)); break; case EShaderLabPropertyType::TextureCubeArray: OutParams.Add(FString::Printf(TEXT("TextureCubeArray %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 (ShaderLabIsTextureType(Prop.Type)) { 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 (const FShaderLabIntrinsicDesc* Desc = 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(); // Overloaded intrinsic called with args: it's the same-named raw HLSL helper, not the wired // intrinsic — leave it and keep scanning its arguments (like an unknown UE_ helper below). if (Desc->bAllowRawHelperWithArgs && SplitArgs(ArgsRaw).Num() > Desc->Params.Num()) { i = e; continue; } 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 (const FShaderLabIntrinsicDesc* Desc = 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(); // Overloaded intrinsic called with args -> the same-named raw HLSL helper form: emit the // identifier verbatim (do NOT rewrite to an SLI_ input) and keep scanning its arguments. if (Desc->bAllowRawHelperWithArgs && SplitArgs(ArgsRaw).Num() > Desc->Params.Num()) { P1 += Ident; i = e; continue; } 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, const FSampleNodes& Samples, 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); } // Pre-sampled VT / RVT reads (pixel-only): wire each referenced sample node's output into the body. // Done before EmitIntrinsics so the RVT member rewrite doesn't disturb intrinsic column padding of // UE_ substitutions (RVT rewrite only touches `.`, never `UE_...`). if (Samples.VT || Samples.RVT) { if (Stage != EShaderLabIntrinsicFrequency::PixelOnly) { // Reject VT/RVT reads outside a pixel body (vertex/interpolator). Only flag when actually used. auto FirstUsed = [&](const TMap* Map, const TCHAR* What) -> bool { if (!Map) { return false; } for (const TPair& Pair : *Map) { if (ReferencesToken(InOutBody, Pair.Key.ToString())) { OutErrors.Add(FString::Printf(TEXT("%s(%d,1): error: %s read '%s' is only allowed in a pixel body"), *SrcPath, FMath::Max(BodyLine - 1, 1), What, *Pair.Key.ToString())); return true; } } return false; }; bool bMisuse = FirstUsed(Samples.VT, TEXT("Virtual texture")); bMisuse |= FirstUsed(Samples.RVT, TEXT("Runtime virtual texture")); if (bMisuse) { return false; } } else { // Streaming VT: `` used verbatim as a float4 -> wire the sample node's RGBA output (pin 5). if (Samples.VT) { for (const TPair& Pair : *Samples.VT) { if (ReferencesToken(InOutBody, Pair.Key.ToString())) { OutWires.Add(FIntrinsicWire{ Pair.Key, Pair.Value, /*RGBA*/ 5 }); } } } // RVT: rewrite `.` -> `SLRVT__` and wire the matching output pin. if (Samples.RVT) { for (const TPair& Pair : *Samples.RVT) { const FString NameStr = Pair.Key.ToString(); for (const FRVTMemberDef& M : GRVTMembers) { const FString Access = NameStr + TEXT(".") + M.Member; if (!ReferencesToken(InOutBody, Access)) { continue; } const FString InputVar = FString(TEXT("SLRVT_")) + NameStr + TEXT("_") + M.Member; InOutBody.ReplaceInline(*Access, *InputVar, ESearchCase::CaseSensitive); OutWires.Add(FIntrinsicWire{ FName(*InputVar), Pair.Value, M.PinIndex }); } } } } } // 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 { UMaterialExpression* Expr = nullptr; 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, const TArray& Collections) { 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); } } // Material Parameter Collection reads: same wiring as a property (deterministic declaration order). for (const FShaderLabCollectionParam& C : Collections) { if (!ReferencesToken(InBody, C.Name.ToString())) { continue; } const FParamNode* Node = PropertyNodes.Find(C.Name); if (Node && Node->Expr) { FCustomInput In; In.InputName = C.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) { if (ReturnType == TEXT("float2")) { return CMOT_Float2; } if (ReturnType == TEXT("float3")) { return CMOT_Float3; } if (ReturnType == TEXT("float4")) { return CMOT_Float4; } return CMOT_Float1; // "float" } /** File-name-safe stem from the shader name (identity), matching the registry's illegal->'_' rule. */ static FString SanitizeShaderFileStem(const FString& Name) { FString Out; for (const TCHAR C : Name) { Out.AppendChar((FChar::IsAlnum(C) || C == TEXT('_') || C == TEXT('-')) ? C : TEXT('_')); } return Out.IsEmpty() ? FString(TEXT("Unnamed")) : Out; } /** * 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 && Model.Imports.Num() == 0) { return FString(); } return FString::Printf(TEXT("%s/%s.gen.ush"), FShaderLabGraphBuilder::GetGeneratedVirtualRoot(), *SanitizeShaderFileStem(Model.ShaderName)); } /** * Guard: reject UE_ node intrinsics / UE_Interpolator inside free local code. Local functions are pure * HLSL emitted at file scope — they can't reach material-graph nodes or per-primitive parameters, so a * `UE_Time()` there would fail with an obscure "undefined symbol". Report it clearly, mapped to the .usl. * (HLSL library helpers like UE_Noise are NOT in the registry and remain allowed.) */ static bool CheckLocalCodeIntrinsics(const FShaderLabModel& Model, TArray& OutErrors) { const FShaderLabIntrinsicRegistry& Registry = FShaderLabIntrinsicRegistry::Get(); const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); auto IsIdent = [](TCHAR C) { return FChar::IsAlnum(C) || C == TEXT('_'); }; bool bOk = true; for (const FShaderLabLocalCode& LC : Model.LocalCode) { const FString& B = LC.Text; const int32 Len = B.Len(); int32 i = 0; while (i < Len) { const bool bBoundary = (i == 0) || !IsIdent(B[i - 1]); if (bBoundary && i + 3 <= Len && B[i] == TEXT('U') && B[i + 1] == TEXT('E') && B[i + 2] == TEXT('_')) { int32 j = i + 3; while (j < Len && IsIdent(B[j])) { ++j; } const FString Name = B.Mid(i + 3, j - (i + 3)); int32 k = j; while (k < Len && FChar::IsWhitespace(B[k])) { ++k; } if (!Name.IsEmpty() && k < Len && B[k] == TEXT('(') && (Name == TEXT("Interpolator") || Registry.Find(FName(*Name)))) { int32 Line = LC.Line; for (int32 p = 0; p < i; ++p) { if (B[p] == TEXT('\n')) { ++Line; } } OutErrors.Add(FString::Printf( TEXT("%s(%d,1): error: UE_%s is a material-graph intrinsic and cannot be used inside a local function (pass its value in as a parameter)"), *SrcPath, FMath::Max(Line, 1), *Name)); bOk = false; } i = j; continue; } ++i; } } return bOk; } /** * Write the shader's free top-level HLSL (LocalCode) to its generated `.gen.ush` on disk, at file * scope (in source order, each chunk `#line`-mapped back to the .usl). Every generated Custom node * #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. */ /** Join an author signature with the trailing context params/args (handles the empty-signature case). */ static FString JoinSignature(const FString& SignatureInner, const FString& Trailing) { 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 - 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); Content += LC.Text; 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()) { OutErrors.Add(TEXT("ShaderLab: cannot locate the plugin directory for the generated local-code include")); return false; } IFileManager::Get().MakeDirectory(*GenDir, /*Tree*/ true); const FString DiskPath = FPaths::Combine(GenDir, SanitizeShaderFileStem(Model.ShaderName) + TEXT(".gen.ush")); // Shader source must be UTF-8 (no BOM) for the shader preprocessor, not the default UTF-16. if (!FFileHelper::SaveStringToFile(Content, *DiskPath, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) { OutErrors.Add(FString::Printf(TEXT("ShaderLab: failed to write generated local-code include '%s'"), *DiskPath)); return false; } // Drop any cached copy so the shader preprocessor re-reads the freshly written file (hot reload / re-cook). FlushShaderFileCache(); return true; } /** Add the shared struct + function-library includes, the generated local-code header (if any), and * 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); // User-authored local functions/structs/globals live in a generated header at file scope. const FString LocalInclude = LocalCodeVirtualPath(Model); if (!LocalInclude.IsEmpty()) { Custom.IncludeFilePaths.AddUnique(LocalInclude); } for (const FString& Include : Model.Includes) { if (!Include.IsEmpty()) { Custom.IncludeFilePaths.AddUnique(Include); } } } /** Map a SubSurfaceType token to the engine enum. */ static bool ParseSubSurfaceType(const FString& Token, EMaterialSubSurfaceType& Out) { if (Token == TEXT("None")) { Out = MSS_None; return true; } if (Token == TEXT("Wrap")) { Out = MSS_Wrap; return true; } if (Token == TEXT("TwoSidedWrap")) { Out = MSS_TwoSidedWrap; return true; } if (Token == TEXT("Diffusion")) { Out = MSS_Diffusion; return true; } if (Token == TEXT("SimpleVolume")) { Out = MSS_SimpleVolume; return true; } return false; } /** * Apply non-pin BSDF modifiers (asset profiles + SubSurfaceType) onto the concrete node. Only modifiers * applicable to the node type are consumed; others are ignored. Returns false (with an error) on a bad * asset path or enum token. */ template static bool LoadProfileAsset(const FString& Path, const TCHAR* What, TObjectPtr& OutPtr, TArray& OutErrors) { if (Path.IsEmpty()) { return true; } TProfile* P = LoadObject(nullptr, *Path); if (!P) { OutErrors.Add(FString::Printf(TEXT("%s: cannot load '%s'"), What, *Path)); return false; } OutPtr = P; return true; } static bool ApplyBsdfModifiers(UMaterialExpression* Bsdf, const FShaderLabBsdfModifiers& M, TArray& OutErrors) { if (UMaterialExpressionSubstrateSlabBSDF* Slab = Cast(Bsdf)) { if (!LoadProfileAsset(M.SubsurfaceProfilePath, TEXT("SubsurfaceProfile"), Slab->SubsurfaceProfile, OutErrors)) { return false; } if (!LoadProfileAsset(M.SpecularProfilePath, TEXT("SpecularProfile"), Slab->SpecularProfile, OutErrors)) { return false; } if (!M.SubSurfaceType.IsEmpty()) { EMaterialSubSurfaceType T; if (!ParseSubSurfaceType(M.SubSurfaceType, T)) { OutErrors.Add(FString::Printf(TEXT("Unknown SubSurfaceType '%s' (use None/Wrap/TwoSidedWrap/Diffusion/SimpleVolume)"), *M.SubSurfaceType)); return false; } Slab->SubSurfaceType = T; } } else if (UMaterialExpressionSubstrateEyeBSDF* Eye = Cast(Bsdf)) { if (!LoadProfileAsset(M.SubsurfaceProfilePath, TEXT("SubsurfaceProfile"), Eye->SubsurfaceProfile, OutErrors)) { return false; } } else if (UMaterialExpressionSubstrateToonBSDF* Toon = Cast(Bsdf)) { if (!LoadProfileAsset(M.ToonProfilePath, TEXT("ToonProfile"), Toon->ToonProfile, OutErrors)) { return false; } } return true; } /** * Emit the Custom node for a pixel-stage body that writes the given BSDF's output-struct fields and wire * it into a fresh Substrate BSDF node (chosen by Desc). Returns the BSDF node (nullptr only on error). * When bAllowMaterialOutputs, S.Opacity / S.OpacityMask are wired to the material-level pins (single-entry * sugar path only). Generic across all Substrate BSDFs via the FBsdfDesc field->pin table. */ static UMaterialExpression* BuildBsdf( const FBsdfDesc& Desc, const FShaderLabBsdfModifiers& Modifiers, 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, const FSampleNodes& Samples, bool bAllowMaterialOutputs, int32& IoY, TArray& OutErrors) { UMaterialExpression* Bsdf = Desc.MakeNode(Material, IoY); if (!ApplyBsdfModifiers(Bsdf, Modifiers, OutErrors)) { return nullptr; } // Single Layer Water additionally requires a companion SingleLayerWaterMaterialOutput node — the // lighting / ray-tracing shaders call GetSingleLayerWaterMaterialOutputN, which only exists when that // node is in the graph. Add it up-front (so even an empty water body compiles); wire it below. UMaterialExpressionSingleLayerWaterMaterialOutput* WaterOut = nullptr; if (Desc.Type == EShaderLabBsdfType::Water) { WaterOut = NewExpr(Material, IoY, -600); } TArray UsedSlab; for (int32 i = 0; i < Desc.NumFields; ++i) { const FSlabFieldDef& F = Desc.Fields[i]; 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")); const bool bUsesRefraction = bAllowMaterialOutputs && ReferencesToken(InBody, OutParamName + TEXT(".Refraction")); const bool bUsesPDO = bAllowMaterialOutputs && ReferencesToken(InBody, OutParamName + TEXT(".PixelDepthOffset")); if (UsedSlab.Num() == 0 && !bUsesOpacity && !bUsesOpacityMask && !bUsesRefraction && !bUsesPDO) { return Bsdf; // Empty body: a default Substrate BSDF. } UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); Custom->Description = Desc.CustomDesc; Custom->OutputType = CMOT_Float1; AddIncludes(*Custom, Model); FString Body = InBody; TArray Wires; TSet ReqProps; if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, Wires, ReqProps, InterpByName, UsedInterps, Samples, 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); } WirePropInputs(*Custom, Program, PropertyNodes, InBody, ReqProps, Model.Collections); const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); FString Code = FString::Printf(TEXT("%s %s = %s();\n{\n%s}\n"), Desc.StructName, *OutParamName, Desc.DefaultFn, *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++; } int32 RefractionOutIdx = INDEX_NONE; int32 PDOOutIdx = INDEX_NONE; if (bUsesRefraction) { FCustomOutput Out; Out.OutputName = TEXT("SLO_Refraction"); Out.OutputType = CMOT_Float1; Custom->AdditionalOutputs.Add(Out); Code += FString::Printf(TEXT("SLO_Refraction = %s.Refraction;\n"), *OutParamName); RefractionOutIdx = OutputIndex++; } if (bUsesPDO) { FCustomOutput Out; Out.OutputName = TEXT("SLO_PixelDepthOffset"); Out.OutputType = CMOT_Float1; Custom->AdditionalOutputs.Add(Out); Code += FString::Printf(TEXT("SLO_PixelDepthOffset = %s.PixelDepthOffset;\n"), *OutParamName); PDOOutIdx = OutputIndex++; } Code += TEXT("return 0.0f;\n"); Custom->Code = Code; Custom->RebuildOutputs(); for (const TPair& Pair : SlabOutputs) { if (FExpressionInput* Pin = Desc.GetPin(Bsdf, 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); } if (RefractionOutIdx != INDEX_NONE) { EditorOnly.Refraction.Connect(RefractionOutIdx, Custom); } if (PDOOutIdx != INDEX_NONE) { EditorOnly.PixelDepthOffset.Connect(PDOOutIdx, Custom); } // Wire the water volume fields into the companion output node (by matching Custom output field name). if (WaterOut) { auto ConnectWaterOut = [&](FExpressionInput& Pin, const TCHAR* FieldName) { for (const TPair& P : SlabOutputs) { if (FCString::Strcmp(P.Key->Field, FieldName) == 0) { Pin.Connect(P.Value, Custom); return; } } }; ConnectWaterOut(WaterOut->ScatteringCoefficients, TEXT("WaterAlbedo")); ConnectWaterOut(WaterOut->AbsorptionCoefficients, TEXT("WaterExtinction")); ConnectWaterOut(WaterOut->PhaseG, TEXT("WaterPhaseG")); ConnectWaterOut(WaterOut->ColorScaleBehindWater, TEXT("ColorScaleBehindWater")); } return Bsdf; } /** * 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 FShaderLabResolvedProgram& Program, const FLibraryEmit& Emit, const TMap& PropertyNodes, const TMap& InterpByName, TSet& UsedInterps, const FSampleNodes& Samples, 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; TSet ReqProps; if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, Wires, ReqProps, InterpByName, UsedInterps, Samples, 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); } WirePropInputs(*Custom, Program, PropertyNodes, InBody, ReqProps, Model.Collections); 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 the Runtime Virtual Texture WRITE output (SL_RVTOUTPUT). Mirrors BuildEmissiveEntry: a Custom node * runs the body filling an FShaderLabRVTOutput, its written fields become AdditionalOutputs, and each is * wired to the matching pin of a UMaterialExpressionRuntimeVirtualTextureOutput (a custom output whose mere * presence makes the material write into an RVT when a mesh renders into one). Unwritten fields keep the * node's own defaults. */ static bool BuildRVTOutput( UMaterial& Material, 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, const FSampleNodes& Samples, int32& IoY, TArray& OutErrors) { UMaterialExpressionRuntimeVirtualTextureOutput* RVTOut = NewExpr(Material, IoY, -600); TArray Used; for (const FRVTOutFieldDef& F : GRVTOutFields) { if (ReferencesToken(InBody, OutParamName + TEXT(".") + F.Field)) { Used.Add(&F); } } if (Used.Num() == 0) { return true; // Empty body: the RVT output node stays at its pin defaults. } UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); Custom->Description = TEXT("ShaderLab RVT Output"); Custom->OutputType = CMOT_Float1; AddIncludes(*Custom, Model); FString Body = InBody; TArray Wires; TSet ReqProps; if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, Wires, ReqProps, InterpByName, UsedInterps, Samples, 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); } WirePropInputs(*Custom, Program, PropertyNodes, InBody, ReqProps, Model.Collections); const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); FString Code = FString::Printf(TEXT("FShaderLabRVTOutput %s = ShaderLabDefaultRVTOutput();\n{\n%s}\n"), *OutParamName, *WrapBodyWithLineMapping(Body, BodyLine, SrcPath)); int32 OutputIndex = 1; TArray> Outs; for (const FRVTOutFieldDef* F : Used) { 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); Outs.Add(TPair(F, OutputIndex)); ++OutputIndex; } Code += TEXT("return 0.0f;\n"); Custom->Code = Code; Custom->RebuildOutputs(); for (const TPair& Pair : Outs) { if (FExpressionInput* Pin = GetRVTOutputPin(RVTOut, Pair.Key->Field)) { Pin->Connect(Pair.Value, 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 FShaderLabResolvedProgram& Program, const FLibraryEmit& Emit, const TMap& PropertyNodes, const TMap& InterpByName, TSet& UsedInterps, const FSampleNodes& Samples, int32& IoY, TArray& OutErrors) { UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); Custom->Description = FString::Printf(TEXT("ShaderLab Value %s"), *Value.Name.ToString()); Custom->OutputType = (Value.ReturnType == TEXT("float2")) ? CMOT_Float2 : CMOT_Float1; AddIncludes(*Custom, Model); FString Body = Value.Body; TArray Wires; TSet ReqProps; if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::PixelOnly, Body, Value.BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, Wires, ReqProps, InterpByName, UsedInterps, Samples, 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); } WirePropInputs(*Custom, Program, PropertyNodes, Value.Body, ReqProps, Model.Collections); // 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; } /** * Build a Vertex Interpolator: a Custom node computing the SL_INTERPOLATOR body at vertex frequency, * feeding a UMaterialExpressionVertexInterpolator. Returns the interpolator node (its output 0 is the * interpolated value, readable from pixel bodies via UE_Interpolator). nullptr on error. */ 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); Custom->Description = FString::Printf(TEXT("ShaderLab Interpolator %s"), *Interp.Name.ToString()); Custom->OutputType = InterpolatorOutputType(Interp.ReturnType); AddIncludes(*Custom, Model); // 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; const FSampleNodes NoSamples; // VT/RVT reads are pixel-only; not available in a vertex-frequency interpolator body. if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::VertexOnly, Body, Interp.BodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, Wires, ReqProps, EmptyInterp, IgnoredUsed, NoSamples, 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); } WirePropInputs(*Custom, Program, PropertyNodes, Interp.Body, ReqProps, Model.Collections); // The body contains `return ;`, so it is the Custom function's body directly. Custom->Code = WrapBodyWithLineMapping(Body, Interp.BodyLine, MakeLineDirectivePath(Model.SourceFilePath)); Custom->RebuildOutputs(); UMaterialExpressionVertexInterpolator* VI = NewExpr(Material, IoY, -450); VI->Input.Connect(0, Custom); return VI; } /** Connect a topology mix factor (literal / Value block / Scalar property / scalar Interpolator) to a scalar pin. */ static bool ConnectFactor( UMaterial& Material, FExpressionInput& Target, const FShaderLabFactor& Factor, const TMap& ValueByName, const TMap& PropertyNodes, const TMap& InterpByName, const FShaderLabModel& Model, TSet& UsedInterps, 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; } } if (UMaterialExpression* const* InterpNode = InterpByName.Find(Factor.Name)) { const FShaderLabInterpolator* Interp = Model.FindInterpolator(Factor.Name); if (Interp && Interp->ReturnType == TEXT("float")) { Target.Connect(0, *InterpNode); UsedInterps.Add(Factor.Name); return true; } OutErrors.Add(FString::Printf( TEXT("Topology factor '%s' is a Vertex Interpolator but not scalar (float); only float interpolators can be a mix factor"), *Factor.Name.ToString())); return false; } OutErrors.Add(FString::Printf( TEXT("Topology factor '%s' is neither a Value block, a Scalar property, nor a scalar Interpolator"), *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, const TMap& InterpByName, TSet& UsedInterps, 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 (UMaterialExpression* 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, InterpByName, UsedInterps, IoY, OutErrors); UMaterialExpression* ChildB = (Node.ChildB != INDEX_NONE) ? BuildTopologyNode(Material, Node.ChildB, Model, SlabByName, ValueByName, PropertyNodes, InterpByName, UsedInterps, 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, InterpByName, Model, UsedInterps, 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, InterpByName, Model, UsedInterps, 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, InterpByName, Model, UsedInterps, 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, InterpByName, Model, UsedInterps, IoY, OutErrors) ? N : nullptr; } default: OutErrors.Add(TEXT("Unhandled topology operator")); return nullptr; } } } bool FShaderLabGraphBuilder::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; } 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; } // 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; } // 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; } } for (const FShaderLabInterpolator& Interp : Model.Interpolators) { if (ReferencesToken(Interp.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; }; // 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 // `#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; // Vertex Interpolators: VS-frequency values interpolated to the pixel shader. Built up-front so pixel // bodies can reference them via UE_Interpolator(Name); UsedInterps tracks which get consumed (contract: // a declared-but-unused interpolator is an error, mirroring the no-dead-slabs rule). TMap InterpByName; TSet UsedInterps; // CustomPrimitiveData contract: author-specified slot indices must fit the fixed float budget and must // not overlap. Scalar occupies 1 float, Vector 4 (starting at PrimitiveDataIndex). Validated across all // declared CPD properties (parser already guarantees Scalar/Vector-only + a non-negative index). { constexpr int32 NumFloats = FCustomPrimitiveData::NumCustomPrimitiveDataFloats; int32 SlotOwner[NumFloats]; for (int32 i = 0; i < NumFloats; ++i) { SlotOwner[i] = INDEX_NONE; } bool bCpdError = false; for (int32 PropIdx = 0; PropIdx < Program.Properties.Num(); ++PropIdx) { const FShaderLabProperty& Prop = Program.Properties[PropIdx]; if (!Prop.bUseCustomPrimitiveData) { continue; } const int32 Size = (Prop.Type == EShaderLabPropertyType::Scalar) ? 1 : 4; const int32 Start = Prop.PrimitiveDataIndex; if (Start + Size > NumFloats) { OutErrors.Add(FString::Printf( TEXT("Property '%s': CustomPrimitiveData index %d + %d float(s) exceeds the %d-float budget"), *Prop.Name.ToString(), Start, Size, NumFloats)); bCpdError = true; continue; } for (int32 s = Start; s < Start + Size; ++s) { if (SlotOwner[s] != INDEX_NONE) { OutErrors.Add(FString::Printf( TEXT("Property '%s': CustomPrimitiveData slot %d overlaps property '%s'"), *Prop.Name.ToString(), s, *Program.Properties[SlotOwner[s]].Name.ToString())); bCpdError = true; break; } SlotOwner[s] = PropIdx; } } if (bCpdError) { return false; // Contract-style hard failure (surfaced through the standard error path). } } for (const FShaderLabProperty& Prop : Program.Properties) { const FString NameStr = Prop.Name.ToString(); if (Prop.Type == EShaderLabPropertyType::StaticBool) { 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). 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 (!IsPropUsed(Prop)) { 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.bHasClampMin) { E->SliderMin = Prop.ClampMin; } if (Prop.bHasClampMax) { E->SliderMax = Prop.ClampMax; } if (Prop.bUseCustomPrimitiveData) { E->bUseCustomPrimitiveData = true; E->PrimitiveDataIndex = static_cast(Prop.PrimitiveDataIndex); } 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; if (Prop.bUseCustomPrimitiveData) { E->bUseCustomPrimitiveData = true; E->PrimitiveDataIndex = static_cast(Prop.PrimitiveDataIndex); } 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); // Explicit SamplerType wins; otherwise infer (normal default token -> Normal, else Color). EMaterialSamplerType Sampler = bIsNormal ? SAMPLERTYPE_Normal : SAMPLERTYPE_Color; if (!Prop.SamplerType.IsEmpty()) { const bool bMapped = MapSamplerType(Prop.SamplerType, Sampler); checkf(bMapped, TEXT("ShaderLab property '%s': invalid SamplerType '%s' (parser should have rejected)."), *Prop.Name.ToString(), *Prop.SamplerType); } E->SamplerType = Sampler; Node.Expr = E; Node.bIsTexture = true; break; } case EShaderLabPropertyType::Texture2DArray: case EShaderLabPropertyType::Texture3D: case EShaderLabPropertyType::TextureCubeArray: { // Array / volume texture object parameter. Built-in white/black/grey/normal default tokens are // Texture2D-only and must NOT be applied here, but an explicit asset-path default (e.g. // "/Engine/EngineResources/DefaultVolumeTexture") of the matching dimension is honored. Left null, // the engine assigns a matching-dimension default and the artist binds a real asset on the instance. UMaterialExpressionTextureObjectParameter* E = NewExpr(Material, ParamY, -1000); E->ParameterName = Prop.Name; E->Group = FName(*Prop.Group); E->SortPriority = Prop.SortPriority; EMaterialSamplerType Sampler = SAMPLERTYPE_Color; if (!Prop.SamplerType.IsEmpty()) { const bool bMapped = MapSamplerType(Prop.SamplerType, Sampler); checkf(bMapped, TEXT("ShaderLab property '%s': invalid SamplerType '%s' (parser should have rejected)."), *Prop.Name.ToString(), *Prop.SamplerType); } E->SamplerType = Sampler; if (Prop.TextureDefault.StartsWith(TEXT("/"))) { UTexture* DefaultTex = LoadObject(nullptr, *Prop.TextureDefault); checkf(DefaultTex, TEXT("ShaderLab property '%s': array/volume DefaultTexture '%s' failed to load."), *Prop.Name.ToString(), *Prop.TextureDefault); E->Texture = DefaultTex; } Node.Expr = E; Node.bIsTexture = true; break; } default: break; } if (Node.Expr) { PropertyNodes.Add(Prop.Name, Node); } } // 1a2) Material Parameter Collection reads (SL_COLLECTION): a CollectionParameter node per referenced // declaration, registered in PropertyNodes so WirePropInputs wires it into bodies exactly like a property. for (const FShaderLabCollectionParam& C : Model.Collections) { const FString NameStr = C.Name.ToString(); bool bRef = Model.bHasSurface && ReferencesToken(Model.SurfaceBody, NameStr); for (const FShaderLabSlab& Slab : Model.Slabs) { bRef |= ReferencesToken(Slab.Body, NameStr); } for (const FShaderLabValue& Value : Model.Values) { bRef |= ReferencesToken(Value.Body, NameStr); } for (const FShaderLabInterpolator& Interp : Model.Interpolators) { bRef |= ReferencesToken(Interp.Body, NameStr); } if (Model.bHasVertex) { bRef |= ReferencesToken(Model.VertexBody, NameStr); } if (!bRef) { continue; // Declared but unreferenced: skip (keeps the graph minimal/deterministic). } UMaterialParameterCollection* Coll = LoadObject(nullptr, *C.CollectionPath); if (!Coll) { OutErrors.Add(FString::Printf(TEXT("SL_COLLECTION '%s': cannot load Material Parameter Collection '%s'"), *NameStr, *C.CollectionPath)); return false; } const FGuid ParamId = Coll->GetParameterId(C.ParameterName); if (!ParamId.IsValid()) { OutErrors.Add(FString::Printf(TEXT("SL_COLLECTION '%s': parameter '%s' not found in '%s'"), *NameStr, *C.ParameterName.ToString(), *C.CollectionPath)); return false; } UMaterialExpressionCollectionParameter* E = NewExpr(Material, ParamY, -1000); E->Collection = Coll; E->ParameterName = C.ParameterName; E->ParameterId = ParamId; FParamNode Node; Node.Expr = E; PropertyNodes.Add(C.Name, Node); } // 1b) Build a Vertex Interpolator node per declared interpolator (Custom @ vertex freq -> VertexInterpolator). for (const FShaderLabInterpolator& Interp : Model.Interpolators) { if (InterpByName.Contains(Interp.Name)) { OutErrors.Add(FString::Printf(TEXT("Duplicate interpolator name '%s'"), *Interp.Name.ToString())); return false; } UMaterialExpression* Node = BuildInterpolatorNode(Material, Interp, Model, Program, Emit, PropertyNodes, ParamY, OutErrors); if (!Node) { return false; } InterpByName.Add(Interp.Name, Node); } // 1c) Pre-sampled VT / RVT read nodes (SL_VTSAMPLE / SL_RVTSAMPLE). A virtual texture cannot be sampled // inside a Custom node, so we build a real sample node here (sampling at the author-chosen UV) and let // PrepareBody wire its output(s) into the pixel bodies. Only build a node when a pixel body references it. TMap VTNodeByName; TMap RVTNodeByName; { auto ReferencedInPixelBodies = [&Model](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.bHasRVTOutput && ReferencesToken(Model.RVTOutputBody, NameStr)) { return true; } return false; }; // Build the coordinate expression feeding a sample node's Coordinates pin. Returns true on success; // OutCoord is null for UV=World (leave Coordinates unconnected — the node derives UV from world pos). auto BuildUVNode = [&](const FShaderLabUV& UV, const TCHAR* What, FName Owner, UMaterialExpression*& OutCoord) -> bool { OutCoord = nullptr; switch (UV.Kind) { case FShaderLabUV::EKind::TexCoord: { UMaterialExpressionTextureCoordinate* TC = NewExpr(Material, ParamY, -800); TC->CoordinateIndex = UV.TexCoordIndex; OutCoord = TC; return true; } case FShaderLabUV::EKind::World: return true; // Coordinates unconnected. case FShaderLabUV::EKind::ValueBlock: { const FShaderLabValue* Block = Model.Values.FindByPredicate( [&](const FShaderLabValue& V) { return V.Name == UV.ValueBlockName; }); if (!Block) { OutErrors.Add(FString::Printf(TEXT("%s '%s': UV references unknown Value block '%s'"), What, *Owner.ToString(), *UV.ValueBlockName.ToString())); return false; } if (Block->ReturnType != TEXT("float2")) { OutErrors.Add(FString::Printf(TEXT("%s '%s': UV Value block '%s' must return float2 (got '%s')"), What, *Owner.ToString(), *UV.ValueBlockName.ToString(), *Block->ReturnType)); return false; } const FSampleNodes NoSamples; // A UV block is itself a coordinate source; it must not sample VT/RVT. UMaterialExpressionCustom* UVNode = BuildValueNode( Material, *Block, Model, Program, Emit, PropertyNodes, InterpByName, UsedInterps, NoSamples, ParamY, OutErrors); if (!UVNode) { return false; } OutCoord = UVNode; return true; } } return false; }; for (const FShaderLabVTSample& VT : Model.VTSamples) { if (!ReferencedInPixelBodies(VT.Name.ToString())) { continue; } EMaterialSamplerType Sampler = SAMPLERTYPE_VirtualColor; const FString Token = VT.SamplerType.IsEmpty() ? FString(TEXT("VirtualColor")) : VT.SamplerType; const bool bMapped = MapVirtualSamplerType(Token, Sampler); checkf(bMapped, TEXT("SL_VTSAMPLE '%s': invalid virtual SamplerType '%s' (parser should have rejected)."), *VT.Name.ToString(), *Token); UMaterialExpressionTextureSampleParameter2D* S = NewExpr(Material, ParamY, -1000); S->ParameterName = VT.Name; S->SamplerType = Sampler; if (!VT.DefaultTexture.IsEmpty()) { // Graceful: a streaming VT default is often a project asset created later; if absent, leave null // (the artist binds it on the MIC). Unlike array/volume defaults, this is not a hard contract. S->Texture = LoadObject(nullptr, *VT.DefaultTexture); } UMaterialExpression* Coord = nullptr; if (!BuildUVNode(VT.UV, TEXT("SL_VTSAMPLE"), VT.Name, Coord)) { return false; } if (Coord) { S->Coordinates.Connect(0, Coord); } VTNodeByName.Add(VT.Name, S); } for (const FShaderLabRVTSample& RVT : Model.RVTSamples) { if (!ReferencedInPixelBodies(RVT.Name.ToString())) { continue; } ERuntimeVirtualTextureMaterialType MatType = ERuntimeVirtualTextureMaterialType::BaseColor_Normal_Roughness; const bool bMapped = MapRVTMaterialType(RVT.MaterialType, MatType); checkf(bMapped, TEXT("SL_RVTSAMPLE '%s': invalid MaterialType '%s' (parser should have rejected)."), *RVT.Name.ToString(), *RVT.MaterialType); UMaterialExpressionRuntimeVirtualTextureSampleParameter* S = NewExpr(Material, ParamY, -1000); S->ParameterName = RVT.Name; S->MaterialType = MatType; if (!RVT.VirtualTexture.IsEmpty()) { // Graceful: the RVT asset is a project asset (created with the level); if absent, leave null // (the sample compiles to constants; the artist binds the RVT on the MIC). S->VirtualTexture = LoadObject(nullptr, *RVT.VirtualTexture); } // Outputs (BaseColor..Mask4, all 8 pins) are populated by the node constructor's InitOutputs(); // they exist regardless of MaterialType, so member->pin indices in GRVTMembers are stable. UMaterialExpression* Coord = nullptr; if (!BuildUVNode(RVT.UV, TEXT("SL_RVTSAMPLE"), RVT.Name, Coord)) { return false; } if (Coord) { S->Coordinates.Connect(0, Coord); } RVTNodeByName.Add(RVT.Name, S); } } FSampleNodes Samples; Samples.VT = &VTNodeByName; Samples.RVT = &RVTNodeByName; // 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, Program, Emit, PropertyNodes, InterpByName, UsedInterps, Samples, 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, Program, Emit, PropertyNodes, InterpByName, UsedInterps, Samples, 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. UMaterialExpression* Slab = BuildBsdf( *FindBsdfDesc(EShaderLabBsdfType::Slab), Model.SurfaceModifiers, Material, *EditorOnly, SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, Model, Program, Emit, PropertyNodes, InterpByName, UsedInterps, Samples, /*bAllowMaterialOutputs*/ true, ParamY, OutErrors); if (!Slab) { return false; } EditorOnly->FrontMaterial.Connect(0, WrapBsdfForDecal(Material, Model, Slab, ParamY)); } else { // Multi-BSDF: each named block -> its own Substrate BSDF node (type from FShaderLabSlab.BsdfType); // 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; } const FBsdfDesc* Desc = FindBsdfDesc(SlabDecl.BsdfType); if (!Desc) { OutErrors.Add(FString::Printf(TEXT("Slab '%s' has an unsupported BSDF type"), *SlabDecl.Name.ToString())); return false; } UMaterialExpression* Slab = BuildBsdf( *Desc, SlabDecl.Modifiers, Material, *EditorOnly, SlabDecl.OutParamName, SlabDecl.Body, SlabDecl.BodyLine, Model, Program, Emit, PropertyNodes, InterpByName, UsedInterps, Samples, /*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, Program, Emit, PropertyNodes, InterpByName, UsedInterps, Samples, ParamY, OutErrors); if (!ValueNode) { return false; } ValueByName.Add(ValueDecl.Name, ValueNode); } // Composition legality: validate operator/BSDF-type combinations up-front (mirrors the engine's Substrate // allow-lists) so an illegal combo errors with a .usl line instead of a generic engine node-name error. if (!Model.ValidateTopology(OutErrors)) { return false; } // 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, InterpByName, UsedInterps, ParamY, OutErrors); if (!Root) { return false; } EditorOnly->FrontMaterial.Connect(0, WrapBsdfForDecal(Material, Model, Root, ParamY)); // 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; } if (!ConnectMaterialOutput(EditorOnly->Refraction, Model.RefractionValueName, TEXT("Refraction"))) { return false; } if (!ConnectMaterialOutput(EditorOnly->PixelDepthOffset, Model.PixelDepthOffsetValueName, TEXT("PixelDepthOffset"))) { 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) + 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; const FSampleNodes NoSamples; // VT/RVT reads are pixel-only; not available in a vertex body. if (!PrepareBody(Material, EShaderLabIntrinsicFrequency::VertexOnly, VertexBody, Model.VertexBodyLine, MakeLineDirectivePath(Model.SourceFilePath), Emit, VtxIntrinsicWires, VtxReqProps, EmptyInterp, IgnoredUsedInterps, NoSamples, 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); } WirePropInputs(*VCustom, Program, PropertyNodes, Model.VertexBody, VtxReqProps, Model.Collections); 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); } } } } } // 3b) Optional Runtime Virtual Texture WRITE (SL_RVTOUTPUT): an additive custom output alongside the // pixel entry. Uses the same VT/RVT sample context as the pixel bodies (a write body may also read). if (Model.bHasRVTOutput) { if (!BuildRVTOutput(Material, Model.RVTOutputParamName, Model.RVTOutputBody, Model.RVTOutputBodyLine, Model, Program, Emit, PropertyNodes, InterpByName, UsedInterps, Samples, ParamY, OutErrors)) { return false; } } // Contract: every declared interpolator must be consumed (read via UE_Interpolator, or used as a // topology mix factor). A dead interpolator would silently waste a scarce interpolant slot. for (const FShaderLabInterpolator& Interp : Model.Interpolators) { if (!UsedInterps.Contains(Interp.Name)) { OutErrors.Add(FString::Printf( TEXT("Interpolator '%s' is declared but never used (read it with UE_Interpolator(%s) or use it as a topology factor)"), *Interp.Name.ToString(), *Interp.Name.ToString())); return false; } } // Read-only permutation macros (SHADERLAB_QUALITY / _FEATURELEVEL / _SHADINGPATH). When a body references // one, wire the matching engine switch (Quality/FeatureLevel/ShadingPath) with per-branch `#define` Custom // nodes into the ParameterAnchor. The switch compiles ONLY the branch for the current permutation (verified // for the real, non-validating compile), so exactly one ordered value leaks ahead of every body `#if`. { auto AnyBodyUses = [&](const TCHAR* Token) -> bool { if (Model.bHasSurface && ReferencesToken(Model.SurfaceBody, Token)) { return true; } for (const FShaderLabSlab& Slab : Model.Slabs) { if (ReferencesToken(Slab.Body, Token)) { return true; } } for (const FShaderLabValue& Value : Model.Values) { if (ReferencesToken(Value.Body, Token)) { return true; } } for (const FShaderLabInterpolator& Interp : Model.Interpolators) { if (ReferencesToken(Interp.Body, Token)) { return true; } } if (Model.bHasVertex && ReferencesToken(Model.VertexBody, Token)) { return true; } return false; }; if (AnyBodyUses(TEXT("SHADERLAB_QUALITY"))) { UMaterialExpressionQualitySwitch* Sw = NewExpr(Material, ParamY, -1150); // Ordered values: LowDefault.Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_QUALITY"), TEXT("2"))); Sw->Inputs[EMaterialQualityLevel::Low].Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_QUALITY"), TEXT("0"))); Sw->Inputs[EMaterialQualityLevel::Medium].Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_QUALITY"), TEXT("1"))); Sw->Inputs[EMaterialQualityLevel::High].Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_QUALITY"), TEXT("2"))); Sw->Inputs[EMaterialQualityLevel::Epic].Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_QUALITY"), TEXT("3"))); AnchorInputs.Add(Sw); } if (AnyBodyUses(TEXT("SHADERLAB_FEATURELEVEL"))) { UMaterialExpressionFeatureLevelSwitch* Sw = NewExpr(Material, ParamY, -1150); Sw->Default.Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_FEATURELEVEL"), TEXT("1"))); Sw->Inputs[ERHIFeatureLevel::ES3_1].Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_FEATURELEVEL"), TEXT("0"))); Sw->Inputs[ERHIFeatureLevel::SM5].Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_FEATURELEVEL"), TEXT("1"))); Sw->Inputs[ERHIFeatureLevel::SM6].Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_FEATURELEVEL"), TEXT("2"))); AnchorInputs.Add(Sw); } if (AnyBodyUses(TEXT("SHADERLAB_SHADINGPATH"))) { UMaterialExpressionShadingPathSwitch* Sw = NewExpr(Material, ParamY, -1150); Sw->Default.Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_SHADINGPATH"), TEXT("0"))); Sw->Inputs[ERHIShadingPath::Deferred].Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_SHADINGPATH"), TEXT("0"))); Sw->Inputs[ERHIShadingPath::Forward].Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_SHADINGPATH"), TEXT("1"))); Sw->Inputs[ERHIShadingPath::Mobile].Connect(0, MakeDefineNode(Material, ParamY, TEXT("SHADERLAB_SHADINGPATH"), TEXT("2"))); AnchorInputs.Add(Sw); } } // Anchor side-effect capabilities: raw-HLSL body helpers (SceneTexture reads, global distance field // reads, ...) that call an engine HLSL function directly from a Custom node. The helper compiles as bare // HLSL, but the runtime resource binding it needs is only established as a side effect of compiling a // matching UMaterialExpression (UseSceneTextureId / bUsesGlobalDistanceField / ...). Because the Custom // body bypasses that node, we synthesize hidden expression(s) wired into the before-attributes // ParameterAnchor purely for the side effect. See FShaderLabAnchorCapabilityRegistry. { // Bodies to scan: the shader's own pixel/vertex bodies AND every reachable `.uslfunc` library-function // body (a library function is inlined into the consuming Custom node, so it can equally reference a raw // helper and must contribute to binding). Emit.Ctx holds exactly the reachable library functions. TArray Bodies; if (Model.bHasSurface) { Bodies.Add({ &Model.SurfaceBody, Model.SurfaceBodyLine }); } for (const FShaderLabSlab& Slab : Model.Slabs) { Bodies.Add({ &Slab.Body, Slab.BodyLine }); } for (const FShaderLabValue& Value : Model.Values) { Bodies.Add({ &Value.Body, Value.BodyLine }); } for (const FShaderLabInterpolator& Interp : Model.Interpolators) { Bodies.Add({ &Interp.Body, Interp.BodyLine }); } if (Model.bHasVertex) { Bodies.Add({ &Model.VertexBody, Model.VertexBodyLine }); } if (Emit.HasLibraries()) { for (const TPair& Pair : Emit.Ctx) { int32 LibIdx = INDEX_NONE; if (const FShaderLabFunction* Fn = Program.FindFunction(Pair.Key, LibIdx)) { Bodies.Add({ &Fn->Body, Fn->BodyLine }); } } } const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); bool bCapabilityFailed = false; FShaderLabAnchorCapabilityRegistry::Get().ForEach( [&](const FShaderLabAnchorCapability& Cap) { if (bCapabilityFailed) { return; } bool bActive = false; for (const FShaderLabScannedBody& B : Bodies) { for (const FString& Token : Cap.TriggerTokens) { if (ReferencesToken(*B.Text, Token)) { bActive = true; break; } } if (bActive) { break; } } if (!bActive) { return; } FShaderLabAnchorCapabilityContext CapCtx{ Material, Model, Bodies, SrcPath, [&Material, &ParamY](UClass* Class) -> UMaterialExpression* { UMaterialExpression* Expr = NewObject(&Material, Class); Material.GetExpressionCollection().AddExpression(Expr); Expr->MaterialExpressionEditorX = -1300; Expr->MaterialExpressionEditorY = ParamY; ParamY += 120; return Expr; } }; TArray CapNodes; if (!Cap.Emit(CapCtx, CapNodes, OutErrors)) { bCapabilityFailed = true; return; } AnchorInputs.Append(CapNodes); }); if (bCapabilityFailed) { return false; } } // 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; } void FShaderLabGraphBuilder::BuildPoisonInto(UMaterial& Material, const TArray& Diagnostics, const FString& SrcPath) { using namespace ShaderLabGraph; // A minimal, structurally-valid Substrate material: one Custom node -> Slab.DiffuseAlbedo -> FrontMaterial, // so the Custom code is reached by translation. The code `#error`s with the diagnostics; that aborts the // shader preprocessor, so the material fails to compile with our messages — surfacing everywhere real // shader errors do (MIC editor red text, FMaterialResource::GetCompileErrors, cook). DiffuseAlbedo (a core // BSDF pin) is used deliberately: EmissiveColor gets dead-stripped from the compiled permutation, which // would drop the Custom function (and its #error) before the preprocessor ever sees it. Material.AssignExpressionCollection(FMaterialExpressionCollection()); Material.MaterialDomain = MD_Surface; Material.BlendMode = BLEND_Opaque; Material.TwoSided = 0; Material.bUseMaterialAttributes = false; UMaterialEditorOnlyData* EditorOnly = Material.GetEditorOnlyData(); check(EditorOnly); // A UMaterial always has editor-only data in the editor (contract). // `#error` halts at the first hit, so merge every diagnostic into one directive (newlines stripped to // keep it a single preprocessor line). The `#line` makes the compiler error click through to the .usl; // each merged message still carries its own precise `(line,col)` as text. FString Combined; for (int32 Index = 0; Index < Diagnostics.Num(); ++Index) { if (Index > 0) { Combined += TEXT(" ; "); } Combined += Diagnostics[Index]; } Combined.ReplaceInline(TEXT("\r"), TEXT("")); Combined.ReplaceInline(TEXT("\n"), TEXT(" ")); if (Combined.IsEmpty()) { Combined = TEXT("ShaderLab: shader failed to build"); } int32 IoY = 0; UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); Custom->Description = TEXT("ShaderLab Error"); Custom->OutputType = CMOT_Float3; Custom->Code = FString::Printf( TEXT("#line 1 \"%s\"\n#error ShaderLab: %s\n#line 1 \"ShaderLabGenerated.ush\"\nreturn float3(1,0,1);\n"), *SrcPath, *Combined); Custom->RebuildOutputs(); UMaterialExpressionSubstrateSlabBSDF* Slab = NewExpr(Material, IoY, 0); Slab->DiffuseAlbedo.Connect(0, Custom); EditorOnly->FrontMaterial.Connect(0, Slab); Material.UpdateCachedExpressionData(); } const TCHAR* FShaderLabGraphBuilder::GetGeneratedVirtualRoot() { return TEXT("/UShaderLabGen"); } FString FShaderLabGraphBuilder::GetGeneratedShaderDir() { const TSharedPtr Plugin = IPluginManager::Get().FindPlugin(TEXT("UShaderLab")); if (!Plugin.IsValid()) { return FString(); } return FPaths::Combine(Plugin->GetBaseDir(), TEXT("Intermediate"), TEXT("ShaderLabGen")); }