diff --git a/Shaders/Private/ShaderLabCommon.ush b/Shaders/Private/ShaderLabCommon.ush index 036886a..d738ea2 100644 --- a/Shaders/Private/ShaderLabCommon.ush +++ b/Shaders/Private/ShaderLabCommon.ush @@ -7,6 +7,40 @@ // Per-pixel/vertex inputs are read through `UE_NodeName(...)` intrinsics (resolved by the graph // builder to real material expression nodes), not through a context struct. +// --------------------------------------------------------------------------- +// Read-only project/engine-driven permutation macros. The graph builder leaks SHADERLAB_QUALITY / +// SHADERLAB_FEATURELEVEL / SHADERLAB_SHADINGPATH (via a before-attributes QualitySwitch/FeatureLevelSwitch/ +// ShadingPathSwitch, same mechanism as static switches) so a body can `#if` on them to adapt per permutation. +// These are NOT author-settable — the engine picks the permutation. Compare against the ordered constants +// below (ordered low->high so `<=`/`>=` are meaningful). +// --------------------------------------------------------------------------- +#define QUALITY_LOW 0 +#define QUALITY_MEDIUM 1 +#define QUALITY_HIGH 2 +#define QUALITY_EPIC 3 + +#define FEATURELEVEL_ES31 0 +#define FEATURELEVEL_SM5 1 +#define FEATURELEVEL_SM6 2 + +#define SHADINGPATH_DEFERRED 0 +#define SHADINGPATH_FORWARD 1 +#define SHADINGPATH_MOBILE 2 + +#ifdef SHADERLAB_IDE + // IDE only: give the leaked macros a default so `#if SHADERLAB_QUALITY ...` completes/checks. At real + // compile these come from the graph's before-attributes switch nodes (SHADERLAB_IDE is never defined then). + #ifndef SHADERLAB_QUALITY + #define SHADERLAB_QUALITY QUALITY_HIGH + #endif + #ifndef SHADERLAB_FEATURELEVEL + #define SHADERLAB_FEATURELEVEL FEATURELEVEL_SM6 + #endif + #ifndef SHADERLAB_SHADINGPATH + #define SHADERLAB_SHADINGPATH SHADINGPATH_DEFERRED + #endif +#endif + // Surface description filled by the Surface(...) body. Defaults mirror the // UMaterialExpressionSubstrateSlabBSDF pin defaults so that fields the body does not // touch keep Substrate's native behavior. @@ -32,8 +66,204 @@ struct FShaderLabSurface float2 GlintUV; float Opacity; float OpacityMask; + // Material-level outputs (single-Surface sugar): wired to the main node's Refraction / PixelDepthOffset pins. + float Refraction; // scalar IOR (RefractionMethod = IndexOfRefraction); 1.0 = no bending + float PixelDepthOffset; // world-unit depth push toward camera }; +// --- Additional Substrate BSDF output structs. Each mirrors the pins of its engine BSDF node; the graph +// builder wires only the fields a body writes, leaving the rest at the node's native default. --- + +// Unlit BSDF (SL_UNLIT): pure emissive / transmittance, no lighting. +struct FShaderLabUnlit +{ + float3 EmissiveColor; + float3 TransmittanceColor; + float3 Normal; +}; + +// Hair BSDF (SL_HAIR): hair-fiber shading. +struct FShaderLabHair +{ + float3 BaseColor; + float Scatter; + float Specular; + float Roughness; + float3 Backlit; + float3 Tangent; + float3 EmissiveColor; +}; + +// Eye BSDF (SL_EYE): cornea / iris / sclera shading. +struct FShaderLabEye +{ + float3 DiffuseColor; + float Roughness; + float3 CorneaNormal; + float3 IrisNormal; + float3 IrisPlaneNormal; + float IrisMask; + float IrisDistance; + float3 EmissiveColor; +}; + +// Single Layer Water BSDF (SL_WATER): water surface + underwater scattering. +struct FShaderLabWater +{ + float3 BaseColor; + float Metallic; + float Specular; + float Roughness; + float3 Normal; + float3 EmissiveColor; + float3 TopMaterialOpacity; + float3 WaterAlbedo; + float3 WaterExtinction; + float WaterPhaseG; + float3 ColorScaleBehindWater; +}; + +// Volumetric-Fog-Cloud BSDF (SL_VOLUME, Domain = Volume): participating media. +struct FShaderLabVolume +{ + float3 Albedo; + float3 Extinction; + float3 EmissiveColor; + float AmbientOcclusion; +}; + +// Simple Clear Coat BSDF (SL_CLEARCOAT): bottom layer + clear coat top. +struct FShaderLabClearCoat +{ + float3 DiffuseAlbedo; + float3 F0; + float Roughness; + float ClearCoatCoverage; + float ClearCoatRoughness; + float3 Normal; + float3 EmissiveColor; + float3 BottomNormal; +}; + +// Toon BSDF (SL_TOON, experimental): stylized shading. +struct FShaderLabToon +{ + float3 BaseColor; + float Metallic; + float Specular; + float Roughness; + float3 Normal; + float3 EmissiveColor; + float2 PatternUVs; + float Anisotropy; + float3 Tangent; +}; + +// Light Function (SL_LIGHTFUNCTION, Domain = LightFunction): per-light modulation color. +struct FShaderLabLightFunction +{ + float3 Color; +}; + +FShaderLabUnlit ShaderLabDefaultUnlit() +{ + FShaderLabUnlit U; + U.EmissiveColor = float3(0, 0, 0); + U.TransmittanceColor = float3(1, 1, 1); + U.Normal = float3(0, 0, 1); + return U; +} + +FShaderLabHair ShaderLabDefaultHair() +{ + FShaderLabHair H; + H.BaseColor = float3(0, 0, 0); + H.Scatter = 0; + H.Specular = 0.5; + H.Roughness = 0.5; + H.Backlit = float3(0, 0, 0); + H.Tangent = float3(1, 0, 0); + H.EmissiveColor = float3(0, 0, 0); + return H; +} + +FShaderLabEye ShaderLabDefaultEye() +{ + FShaderLabEye E; + E.DiffuseColor = float3(0, 0, 0); + E.Roughness = 0.5; + E.CorneaNormal = float3(0, 0, 1); + E.IrisNormal = float3(0, 0, 1); + E.IrisPlaneNormal = float3(0, 0, 1); + E.IrisMask = 0; + E.IrisDistance = 0; + E.EmissiveColor = float3(0, 0, 0); + return E; +} + +FShaderLabWater ShaderLabDefaultWater() +{ + FShaderLabWater W; + W.BaseColor = float3(0, 0, 0); + W.Metallic = 0; + W.Specular = 0.5; + W.Roughness = 0.5; + W.Normal = float3(0, 0, 1); + W.EmissiveColor = float3(0, 0, 0); + W.TopMaterialOpacity = float3(0, 0, 0); + W.WaterAlbedo = float3(0, 0, 0); + W.WaterExtinction = float3(0, 0, 0); + W.WaterPhaseG = 0; + W.ColorScaleBehindWater = float3(1, 1, 1); + return W; +} + +FShaderLabVolume ShaderLabDefaultVolume() +{ + FShaderLabVolume V; + V.Albedo = float3(0, 0, 0); + V.Extinction = float3(0, 0, 0); + V.EmissiveColor = float3(0, 0, 0); + V.AmbientOcclusion = 1; + return V; +} + +FShaderLabClearCoat ShaderLabDefaultClearCoat() +{ + FShaderLabClearCoat C; + C.DiffuseAlbedo = float3(0.18, 0.18, 0.18); + C.F0 = float3(0.04, 0.04, 0.04); + C.Roughness = 0.5; + C.ClearCoatCoverage = 0.5; + C.ClearCoatRoughness = 0.5; + C.Normal = float3(0, 0, 1); + C.EmissiveColor = float3(0, 0, 0); + C.BottomNormal = float3(0, 0, 1); + return C; +} + +FShaderLabToon ShaderLabDefaultToon() +{ + FShaderLabToon T; + T.BaseColor = float3(0.18, 0.18, 0.18); + T.Metallic = 0; + T.Specular = 0.5; + T.Roughness = 0.5; + T.Normal = float3(0, 0, 1); + T.EmissiveColor = float3(0, 0, 0); + T.PatternUVs = float2(0, 0); + T.Anisotropy = 0; + T.Tangent = float3(1, 0, 0); + return T; +} + +FShaderLabLightFunction ShaderLabDefaultLightFunction() +{ + FShaderLabLightFunction L; + L.Color = float3(1, 1, 1); + return L; +} + // Output for the PostProcess(...) entry (Domain = PostProcess). Color feeds the material EmissiveColor. struct FShaderLabPostProcess { @@ -82,6 +312,8 @@ FShaderLabSurface ShaderLabDefaultSurface() S.GlintUV = float2(0, 0); S.Opacity = 1; S.OpacityMask = 1; + S.Refraction = 1.0; + S.PixelDepthOffset = 0.0; return S; } diff --git a/Shaders/Private/ShaderLabDSL.ush b/Shaders/Private/ShaderLabDSL.ush index fe56b2e..8d56d60 100644 --- a/Shaders/Private/ShaderLabDSL.ush +++ b/Shaders/Private/ShaderLabDSL.ush @@ -32,6 +32,12 @@ // #include "/Project/Lib/DetailLib.uslfunc" #define SL_IMPORT(...) +// Material Parameter Collection read. Precedes a `floatN ;` declaration; reads a global MPC value +// (not a per-instance knob). Path is the collection asset; optional Parameter overrides the in-collection name. +// SL_COLLECTION(Path = "/Game/MPC/MPC_Weather", Parameter = "WindVector") +// float3 WindVector; +#define SL_COLLECTION(...) + // Library function (`.uslfunc` files only). Precedes a normal HLSL function; it may use UE_ intrinsics, // reference the library's own properties, and call other library functions. // SL_FUNCTION() @@ -39,20 +45,40 @@ #define SL_FUNCTION(...) // Pixel/vertex entry points. Precede a `void Name(inout F... X) { ... }` (Vertex takes FShaderLabVertex). +// Optional material context: to call the UE_Transform* helpers (which need the material `Parameters`), add +// a leading `FMaterialPixelParameters Parameters` (pixel entries) or `FMaterialVertexParameters Parameters` +// (SL_VERTEX) BEFORE the output-struct parameter, named literally `Parameters`, e.g. +// SL_SURFACE() void Surface(FMaterialPixelParameters Parameters, inout FShaderLabSurface S) { ... } #define SL_SURFACE(...) #define SL_POSTPROCESS(...) #define SL_UI(...) #define SL_VERTEX(...) +// Additional Substrate BSDF entries. Each precedes a `void Name(inout F X) { ... }` whose output struct +// is the matching FShaderLab (see ShaderLabCommon.ush). Used either as the sole entry (whole material = +// that BSDF) or as a named block referenced by SL_FRONTMATERIAL. Composition legality is enforced by the +// builder (mirroring Substrate's operator allow-lists). SL_VOLUME requires Domain = Volume; SL_LIGHTFUNCTION +// requires Domain = LightFunction; the rest are Surface-domain BSDFs. +#define SL_UNLIT(...) // void Name(inout FShaderLabUnlit U) +#define SL_HAIR(...) // void Name(inout FShaderLabHair H) +#define SL_EYE(...) // void Name(inout FShaderLabEye E) +#define SL_WATER(...) // void Name(inout FShaderLabWater W) +#define SL_CLEARCOAT(...) // void Name(inout FShaderLabClearCoat C) +#define SL_TOON(...) // void Name(inout FShaderLabToon T) +#define SL_VOLUME(...) // void Name(inout FShaderLabVolume V) — Domain = Volume +#define SL_LIGHTFUNCTION(...) // void Name(inout FShaderLabLightFunction L) — Domain = LightFunction + // Multi-slab building blocks. -#define SL_SLAB(...) // precedes `void Name(inout FShaderLabSurface S) { ... }` -#define SL_VALUE(...) // precedes `float Name() { return ; }` +#define SL_SLAB(...) // precedes `void Name([FMaterialPixelParameters Parameters,] inout FShaderLabSurface S) { ... }` +#define SL_VALUE(...) // precedes `float Name([FMaterialPixelParameters Parameters]) { return ; }` #define SL_FRONTMATERIAL(...) // standalone: SL_FRONTMATERIAL(VerticalLayer(Coat, Metal, Thickness)) #define SL_OPACITY(...) // standalone: SL_OPACITY(SomeValueName) #define SL_OPACITY_MASK(...) // standalone: SL_OPACITY_MASK(SomeValueName) +#define SL_REFRACTION(...) // standalone: SL_REFRACTION(SomeValueName) — feeds the material Refraction pin +#define SL_PIXEL_DEPTH_OFFSET(...)// standalone: SL_PIXEL_DEPTH_OFFSET(SomeValueName) — feeds the material PixelDepthOffset pin -// Vertex Interpolator: precedes `floatN Name() { return ; }` — a value computed -// per-vertex and interpolated to the pixel shader (backed by a UMaterialExpressionVertexInterpolator). +// Vertex Interpolator: precedes `floatN Name([FMaterialVertexParameters Parameters]) { return ; }` +// — a value computed per-vertex and interpolated to the pixel shader (backed by a UMaterialExpressionVertexInterpolator). // Read it in a pixel body with `UE_Interpolator(Name)`, or use a scalar one as a topology mix factor. #define SL_INTERPOLATOR(...) @@ -70,7 +96,10 @@ // SL_SETTINGS : Domain, BlendMode, TwoSided, OpacityMaskClipValue, // Domain = Surface | PostProcess | UI | Decal // BlendMode = Opaque | Masked | Translucent | Additive | Modulate -// SL_PROPERTY : DisplayName, Category, SortPriority, Range (Scalar only), DefaultTexture (textures only) +// SL_PROPERTY : Category, SortPriority, DefaultTexture (textures only), +// ClampMin / ClampMax (Scalar slider bounds; each independent), +// CustomPrimitiveData = (Scalar/Vector only; per-instance via Custom Primitive Data; +// mutually exclusive with ClampMin/ClampMax) // // Topology operators (used inside SL_FRONTMATERIAL): // VerticalLayer(Top, Base, Thickness) | HorizontalMix(Background, Foreground, Mix) diff --git a/Shaders/Private/ShaderLabUEFunctions.ush b/Shaders/Private/ShaderLabUEFunctions.ush index 93ee784..74a7e21 100644 --- a/Shaders/Private/ShaderLabUEFunctions.ush +++ b/Shaders/Private/ShaderLabUEFunctions.ush @@ -8,5 +8,6 @@ #include "/Plugin/ShaderLab/Private/UEFunctions/Noise.ush" #include "/Plugin/ShaderLab/Private/UEFunctions/Rotation.ush" +#include "/Plugin/ShaderLab/Private/UEFunctions/SceneTexture.ush" #include "/Plugin/ShaderLab/Private/UEFunctions/Transform.ush" // generated (UE_Transform*VectorTo*) #include "/Plugin/ShaderLab/Private/UEFunctions/TransformPosition.ush" // generated (UE_Transform*PositionTo*) diff --git a/Shaders/Private/UEFunctions/SceneTexture.ush b/Shaders/Private/UEFunctions/SceneTexture.ush new file mode 100644 index 0000000..1b2a253 --- /dev/null +++ b/Shaders/Private/UEFunctions/SceneTexture.ush @@ -0,0 +1,57 @@ +// Copyright UShaderLab. All Rights Reserved. +// +// UE_ scene-texture reads — forward to the engine's SceneTextureLookup (MaterialTemplate.ush). Because +// these take a dynamic UV they are emitted as HLSL helpers (like Noise), NOT wired intrinsics. The graph +// builder places a hidden UMaterialExpressionSceneTexture node (wired to the ParameterAnchor) whenever a +// body uses one of these, so the engine sets bNeedsSceneTextures and binds the scene-texture SRVs. +// +// Enum scheme B (see Noise.ush): under SHADERLAB_IDE the id is a real HLSL `enum` for completion/semantics; +// at real compile it degrades to `int` + `#define`s aliasing the engine's PPI_* ids (defined in the material +// environment), so no magic numbers are hard-coded here. +// +// Domain rules (enforced by the builder, mirroring the engine): SceneDepth/CustomDepth/CustomStencil work in +// a translucent Surface, Decal, or PostProcess material; the full GBuffer / PostProcessInput ids require the +// PostProcess (or Decal) domain. (Scene-color-behind-translucency uses a different engine path and is not +// exposed here yet — read it as PostProcessInput0 in a PostProcess material instead.) + +#pragma once + +#ifdef SHADERLAB_IDE + enum ESLSceneTexture + { + SL_ST_SceneColor, + SL_ST_SceneDepth, + SL_ST_CustomDepth, + SL_ST_CustomStencil, + SL_ST_WorldNormal, + SL_ST_PostProcessInput0, + SL_ST_PostProcessInput1, + SL_ST_PostProcessInput2, + SL_ST_PostProcessInput3, + SL_ST_PostProcessInput4, + }; + float4 SceneTextureLookup(float2 UV, int SceneTextureIndex, bool bFiltered) { return (float4)0; } +#else + #define ESLSceneTexture int + #define SL_ST_SceneColor PPI_SceneColor + #define SL_ST_SceneDepth PPI_SceneDepth + #define SL_ST_CustomDepth PPI_CustomDepth + #define SL_ST_CustomStencil PPI_CustomStencil + #define SL_ST_WorldNormal PPI_WorldNormal + #define SL_ST_PostProcessInput0 PPI_PostProcessInput0 + #define SL_ST_PostProcessInput1 PPI_PostProcessInput1 + #define SL_ST_PostProcessInput2 PPI_PostProcessInput2 + #define SL_ST_PostProcessInput3 PPI_PostProcessInput3 + #define SL_ST_PostProcessInput4 PPI_PostProcessInput4 +#endif + +// Full-control lookup: read any scene-texture id at UV (id is an ESLSceneTexture token). +float4 UE_SceneTexture(ESLSceneTexture Id, float2 UV) +{ + return SceneTextureLookup(UV, Id, false); +} + +// Convenience wrappers for the common reads. +float UE_SceneDepth(float2 UV) { return SceneTextureLookup(UV, SL_ST_SceneDepth, false).r; } +float UE_CustomDepth(float2 UV) { return SceneTextureLookup(UV, SL_ST_CustomDepth, false).r; } +float UE_CustomStencil(float2 UV) { return SceneTextureLookup(UV, SL_ST_CustomStencil, false).r; } diff --git a/Source/UShaderLab/Private/ShaderLabModel.cpp b/Source/UShaderLab/Private/ShaderLabModel.cpp index 6c40fc8..93d8f4c 100644 --- a/Source/UShaderLab/Private/ShaderLabModel.cpp +++ b/Source/UShaderLab/Private/ShaderLabModel.cpp @@ -2,6 +2,8 @@ #include "ShaderLabModel.h" +#include "Templates/Function.h" + const FShaderLabProperty* FShaderLabModel::FindProperty(FName InName) const { return Properties.FindByPredicate([InName](const FShaderLabProperty& P) { return P.Name == InName; }); @@ -22,3 +24,114 @@ bool FShaderLabModel::HasStaticSwitches() const return Properties.ContainsByPredicate( [](const FShaderLabProperty& P) { return P.Type == EShaderLabPropertyType::StaticBool; }); } + +namespace +{ + // One-hot flag per BSDF type; a subtree's flowing-type set is the OR of its leaves. + uint32 BsdfTypeFlag(EShaderLabBsdfType T) { return 1u << static_cast(T); } + + const TCHAR* BsdfTypeName(EShaderLabBsdfType T) + { + switch (T) + { + case EShaderLabBsdfType::Slab: return TEXT("Slab"); + case EShaderLabBsdfType::Unlit: return TEXT("Unlit"); + case EShaderLabBsdfType::Hair: return TEXT("Hair"); + case EShaderLabBsdfType::Eye: return TEXT("Eye"); + case EShaderLabBsdfType::Water: return TEXT("Water"); + case EShaderLabBsdfType::Volume: return TEXT("Volume"); + case EShaderLabBsdfType::ClearCoat: return TEXT("ClearCoat"); + case EShaderLabBsdfType::Toon: return TEXT("Toon"); + case EShaderLabBsdfType::LightFunction: return TEXT("LightFunction"); + default: return TEXT("?"); + } + } + + const TCHAR* OpName(EShaderLabOp Op) + { + switch (Op) + { + case EShaderLabOp::VerticalLayer: return TEXT("VerticalLayer"); + case EShaderLabOp::HorizontalMix: return TEXT("HorizontalMix"); + case EShaderLabOp::Add: return TEXT("Add"); + case EShaderLabOp::Weight: return TEXT("Weight"); + case EShaderLabOp::Select: return TEXT("Select"); + default: return TEXT("SlabRef"); + } + } + + // Allowed flowing-type mask per operator (mirrors SubstrateTranslatorCommon.cpp allow-lists). + uint32 AllowedMask(EShaderLabOp Op) + { + const uint32 Slab = BsdfTypeFlag(EShaderLabBsdfType::Slab); + const uint32 Unlit = BsdfTypeFlag(EShaderLabBsdfType::Unlit); + const uint32 Toon = BsdfTypeFlag(EShaderLabBsdfType::Toon); + const uint32 Hair = BsdfTypeFlag(EShaderLabBsdfType::Hair); + const uint32 Eye = BsdfTypeFlag(EShaderLabBsdfType::Eye); + switch (Op) + { + case EShaderLabOp::VerticalLayer: + case EShaderLabOp::HorizontalMix: + case EShaderLabOp::Add: return Slab; + case EShaderLabOp::Weight: return Unlit | Slab | Toon; + case EShaderLabOp::Select: return Slab | Hair | Eye | Toon; + default: return 0xFFFFFFFFu; // SlabRef: leaf, unconstrained here + } + } +} + +bool FShaderLabModel::ValidateTopology(TArray& OutErrors) const +{ + if (TopologyRoot == INDEX_NONE || Topology.Num() == 0) + { + return true; // Single-Surface sugar / non-Substrate entries: nothing to validate. + } + + auto SlabTypeByName = [this](FName SlabName, EShaderLabBsdfType& OutType) -> bool + { + for (const FShaderLabSlab& S : Slabs) + { + if (S.Name == SlabName) { OutType = S.BsdfType; return true; } + } + return false; + }; + + bool bOk = true; + // Recursively compute a subtree's flowing-type mask and validate each operator against its allow-list. + TFunction Visit = [&](int32 Index) -> uint32 + { + if (!Topology.IsValidIndex(Index)) { return 0; } + const FShaderLabTopoNode& Node = Topology[Index]; + if (Node.Op == EShaderLabOp::SlabRef) + { + EShaderLabBsdfType T; + return SlabTypeByName(Node.SlabRef, T) ? BsdfTypeFlag(T) : 0; + } + uint32 Mask = 0; + if (Node.ChildA != INDEX_NONE) { Mask |= Visit(Node.ChildA); } + if (Node.ChildB != INDEX_NONE) { Mask |= Visit(Node.ChildB); } + + const uint32 Allowed = AllowedMask(Node.Op); + const uint32 Illegal = Mask & ~Allowed; + if (Illegal != 0) + { + // List the offending flowing types. + FString Types; + for (uint32 i = 0; i <= static_cast(EShaderLabBsdfType::LightFunction); ++i) + { + if (Illegal & (1u << i)) + { + if (!Types.IsEmpty()) { Types += TEXT(", "); } + Types += BsdfTypeName(static_cast(i)); + } + } + OutErrors.Add(FString::Printf( + TEXT("%s(%d): Substrate error: operator '%s' cannot combine BSDF type(s) {%s} (only Slab layers/mixes/adds; Weight allows Unlit/Slab/Toon; Select allows Slab/Hair/Eye/Toon)."), + *SourceFilePath, Node.SourceLine, OpName(Node.Op), *Types)); + bOk = false; + } + return Mask; + }; + Visit(TopologyRoot); + return bOk; +} diff --git a/Source/UShaderLab/Private/ShaderLabParser.cpp b/Source/UShaderLab/Private/ShaderLabParser.cpp index 74db248..6c3fd89 100644 --- a/Source/UShaderLab/Private/ShaderLabParser.cpp +++ b/Source/UShaderLab/Private/ShaderLabParser.cpp @@ -384,6 +384,8 @@ namespace ShaderLabParser_Private if (Token == TEXT("PostProcess")) { Out = EShaderLabDomain::PostProcess; return true; } if (Token == TEXT("UI")) { Out = EShaderLabDomain::UI; return true; } if (Token == TEXT("Decal")) { Out = EShaderLabDomain::Decal; return true; } + if (Token == TEXT("Volume")) { Out = EShaderLabDomain::Volume; return true; } + if (Token == TEXT("LightFunction")) { Out = EShaderLabDomain::LightFunction; return true; } return false; } @@ -394,6 +396,8 @@ namespace ShaderLabParser_Private if (Token == TEXT("Translucent")) { Out = EShaderLabBlendMode::Translucent; return true; } if (Token == TEXT("Additive")) { Out = EShaderLabBlendMode::Additive; return true; } if (Token == TEXT("Modulate")) { Out = EShaderLabBlendMode::Modulate; return true; } + if (Token == TEXT("AlphaComposite")) { Out = EShaderLabBlendMode::AlphaComposite; return true; } + if (Token == TEXT("AlphaHoldout")) { Out = EShaderLabBlendMode::AlphaHoldout; return true; } return false; } @@ -404,6 +408,23 @@ namespace ShaderLabParser_Private return false; } + /** True if Token is a non-empty run of ASCII digits (a non-negative integer literal). */ + static bool IsNonNegativeInteger(const FString& Token) + { + if (Token.IsEmpty()) + { + return false; + } + for (const TCHAR C : Token) + { + if (!FChar::IsDigit(C)) + { + return false; + } + } + return true; + } + /** Map an HLSL declaration type token to a DSL property type. */ static bool InferPropertyType(const FString& HlslType, EShaderLabPropertyType& Out) { @@ -412,6 +433,9 @@ namespace ShaderLabParser_Private if (HlslType == TEXT("float4")) { Out = EShaderLabPropertyType::Vector; return true; } if (HlslType == TEXT("Texture2D")) { Out = EShaderLabPropertyType::Texture2D; return true; } if (HlslType == TEXT("TextureCube")) { Out = EShaderLabPropertyType::TextureCube; return true; } + if (HlslType == TEXT("Texture2DArray")) { Out = EShaderLabPropertyType::Texture2DArray; return true; } + if (HlslType == TEXT("Texture3D")) { Out = EShaderLabPropertyType::Texture3D; return true; } + if (HlslType == TEXT("TextureCubeArray")) { Out = EShaderLabPropertyType::TextureCubeArray; return true; } return false; } @@ -514,6 +538,96 @@ namespace return true; } + /** True if `Body` references `Ident` as a whole identifier token (not as a substring of a longer word). */ + bool BodyReferencesIdentifier(const FString& Body, const TCHAR* Ident) + { + const FString Needle(Ident); + const int32 NeedleLen = Needle.Len(); + auto IsIdentChar = [](TCHAR C) { return FChar::IsAlnum(C) || C == TEXT('_'); }; + int32 From = 0; + for (;;) + { + const int32 At = Body.Find(Needle, ESearchCase::CaseSensitive, ESearchDir::FromStart, From); + if (At == INDEX_NONE) + { + return false; + } + const bool bLeftOk = (At == 0) || !IsIdentChar(Body[At - 1]); + const int32 EndIdx = At + NeedleLen; + const bool bRightOk = (EndIdx >= Body.Len()) || !IsIdentChar(Body[EndIdx]); + if (bLeftOk && bRightOk) + { + return true; + } + From = At + 1; + } + } + + /** + * Validate the optional material `Parameters` context parameter on a body-bearing construct, and check + * the body's use of it. Contract: + * - When bRequireStruct, the output-struct parameter (its type starts with "FShaderLab") must be the + * LAST parameter, so the graph builder's `Last()` selects it as the output struct. + * - Any non-struct parameter must be a single `Parameters` of type `ParamsType` (the frequency-correct + * FMaterial{Pixel,Vertex}Parameters), named literally `Parameters` — matching the ambient variable UE + * injects into the generated Custom node (its declared signature is dropped at real compile). + * - If the body references the `Parameters` identifier it MUST be declared, so the IDE (which sees the + * declaration) and the real compile (which uses the ambient `Parameters`) agree. + * Reports the first violation at (ErrLine, ErrCol) and returns false. + */ + bool ValidateBodyParameters(FScanner& S, const TArray& Params, const FString& Body, + const TCHAR* ParamsType, bool bRequireStruct, const TCHAR* What, int32 ErrLine, int32 ErrCol) + { + bool bHasParameters = false; + for (int32 i = 0; i < Params.Num(); ++i) + { + const FShaderLabEntryParam& P = Params[i]; + const bool bIsStruct = P.Type.StartsWith(TEXT("FShaderLab")); + if (bIsStruct) + { + if (!bRequireStruct) + { + S.Error(FString::Printf(TEXT("%s takes no output-struct parameter"), What), ErrLine, ErrCol); + return false; + } + if (i != Params.Num() - 1) + { + S.Error(FString::Printf(TEXT("%s: the output-struct parameter must be the last parameter (put `Parameters` before it)"), What), ErrLine, ErrCol); + return false; + } + continue; + } + // A non-struct parameter is only allowed as the optional material context. + if (P.Name != TEXT("Parameters")) + { + S.Error(FString::Printf(TEXT("%s: unexpected parameter '%s' (only an optional `Parameters` context parameter is allowed)"), What, *P.Name), ErrLine, ErrCol); + return false; + } + if (P.Type != ParamsType) + { + S.Error(FString::Printf(TEXT("%s: `Parameters` must be of type %s here"), What, ParamsType), ErrLine, ErrCol); + return false; + } + if (bHasParameters) + { + S.Error(FString::Printf(TEXT("%s: duplicate `Parameters` parameter"), What), ErrLine, ErrCol); + return false; + } + bHasParameters = true; + } + if (bRequireStruct && (Params.Num() == 0 || !Params.Last().Type.StartsWith(TEXT("FShaderLab")))) + { + S.Error(FString::Printf(TEXT("%s must take an (inout FShaderLab...) output parameter as its last parameter"), What), ErrLine, ErrCol); + return false; + } + if (!bHasParameters && BodyReferencesIdentifier(Body, TEXT("Parameters"))) + { + S.Error(FString::Printf(TEXT("%s uses `Parameters` but does not declare it; add a `%s Parameters` parameter to the signature"), What, ParamsType), ErrLine, ErrCol); + return false; + } + return true; + } + void ParseSettings(FScanner& S, FShaderLabModel& Model) { FShaderLabSettings& Out = Model.Settings; @@ -589,11 +703,7 @@ namespace Key = Key.TrimStartAndEnd(); Value = Value.TrimStartAndEnd(); - if (Key == TEXT("DisplayName")) - { - Prop.DisplayName = Value.TrimQuotes(); - } - else if (Key == TEXT("Category")) + if (Key == TEXT("Category")) { Prop.Group = Value.TrimQuotes(); } @@ -605,24 +715,28 @@ namespace { Prop.TextureDefault = Value.TrimQuotes(); } - else if (Key == TEXT("Range")) + else if (Key == TEXT("ClampMin")) { - // Trim exactly the one wrapping (...) then split — preserves paren-awareness of the interior. - if (!Value.StartsWith(TEXT("(")) || !Value.EndsWith(TEXT(")"))) + // UE UPROPERTY-style slider lower bound (maps to ScalarParameter SliderMin). + Prop.bHasClampMin = true; + Prop.ClampMin = FCString::Atof(*Value); + } + else if (Key == TEXT("ClampMax")) + { + // UE UPROPERTY-style slider upper bound (maps to ScalarParameter SliderMax). + Prop.bHasClampMax = true; + Prop.ClampMax = FCString::Atof(*Value); + } + else if (Key == TEXT("CustomPrimitiveData")) + { + // Value is the mandatory, author-specified float slot index (no auto-assignment). + if (!IsNonNegativeInteger(Value)) { - S.Error(FString::Printf(TEXT("Property '%s' Range expects (min,max)"), *Prop.Name.ToString()), ErrLine, ErrCol); + S.Error(FString::Printf(TEXT("Property '%s' CustomPrimitiveData expects a non-negative integer index"), *Prop.Name.ToString()), ErrLine, ErrCol); return; } - const FString Inner = Value.Mid(1, Value.Len() - 2); - const TArray Comps = SplitTopLevel(Inner, TEXT(',')); - if (Comps.Num() != 2) - { - S.Error(FString::Printf(TEXT("Property '%s' Range expects (min,max)"), *Prop.Name.ToString()), ErrLine, ErrCol); - return; - } - Prop.bHasRange = true; - Prop.RangeMin = FCString::Atof(*Comps[0]); - Prop.RangeMax = FCString::Atof(*Comps[1]); + Prop.bUseCustomPrimitiveData = true; + Prop.PrimitiveDataIndex = FCString::Atoi(*Value); } else { @@ -697,7 +811,7 @@ namespace } if (!InferPropertyType(TypeTok, Prop.Type)) { - S.Error(FString::Printf(TEXT("Unsupported property type '%s' (use float/float3/float4/Texture2D/TextureCube or `#define` for StaticBool)"), *TypeTok)); + S.Error(FString::Printf(TEXT("Unsupported property type '%s' (use float/float3/float4/Texture2D/TextureCube/Texture2DArray/Texture3D/TextureCubeArray or `#define` for StaticBool)"), *TypeTok)); return; } FString Name; @@ -710,7 +824,7 @@ namespace NameCol = S.Column; Prop.Name = FName(*Name); - const bool bIsTexture = (Prop.Type == EShaderLabPropertyType::Texture2D || Prop.Type == EShaderLabPropertyType::TextureCube); + const bool bIsTexture = ShaderLabIsTextureType(Prop.Type); if (bIsTexture) { // `Texture2D Name;` (no initializer) optionally followed by `SamplerState NameSampler;`. @@ -774,9 +888,20 @@ namespace return; } } - if (Prop.DisplayName.IsEmpty()) + // CustomPrimitiveData contract: only Scalar/Vector support it in UE, and it is mutually exclusive + // with the slider clamps (the MIC editor disables the slider under bUseCustomPrimitiveData). + if (Prop.bUseCustomPrimitiveData) { - Prop.DisplayName = Prop.Name.ToString(); + if (Prop.Type != EShaderLabPropertyType::Scalar && Prop.Type != EShaderLabPropertyType::Vector) + { + S.Error(FString::Printf(TEXT("Property '%s': CustomPrimitiveData is only valid on Scalar (float) or Vector (float4) properties"), *Prop.Name.ToString()), NameLine, NameCol); + return; + } + if (Prop.bHasClampMin || Prop.bHasClampMax) + { + S.Error(FString::Printf(TEXT("Property '%s': ClampMin/ClampMax cannot be combined with CustomPrimitiveData"), *Prop.Name.ToString()), NameLine, NameCol); + return; + } } if (Model.FindProperty(Prop.Name) != nullptr) { @@ -912,14 +1037,139 @@ namespace Model.Functions.Add(MoveTemp(Fn)); } - void ParseEntry(FScanner& S, FShaderLabModel& Model, const FString& Marker) + /** + * Parse BSDF node modifiers from an SL_SURFACE/SL_SLAB(...) specifier list. Empty list is fine. Each modifier + * is validated for applicability to BsdfType (contract-style: a modifier the target BSDF can't consume is a + * hard error, not a silent no-op): SubsurfaceProfile -> Slab/Eye; SpecularProfile/SubSurfaceType -> Slab; + * ToonProfile -> Toon. + */ + bool ParseBsdfModifiers(FScanner& S, const FString& Inner, EShaderLabBsdfType BsdfType, FShaderLabBsdfModifiers& Out, const TCHAR* What) { - // Marker macro takes no args: SL_SURFACE()/SL_POSTPROCESS()/SL_UI()/SL_VERTEX(). - FString Ignored; - if (!S.ReadBalanced(TEXT('('), TEXT(')'), Ignored)) + const bool bIsSlab = (BsdfType == EShaderLabBsdfType::Slab); + const bool bIsEye = (BsdfType == EShaderLabBsdfType::Eye); + const bool bIsToon = (BsdfType == EShaderLabBsdfType::Toon); + for (const FString& StmtRaw : SplitTopLevel(Inner, TEXT(','))) + { + const FString Stmt = StmtRaw.TrimStartAndEnd(); + if (Stmt.IsEmpty()) { continue; } + FString Key, Value; + if (!Stmt.Split(TEXT("="), &Key, &Value)) + { + S.Error(FString::Printf(TEXT("%s has malformed specifier '%s' (expected Key = Value)"), What, *Stmt)); + return false; + } + Key = Key.TrimStartAndEnd(); + Value = Value.TrimStartAndEnd().TrimQuotes(); + if (Key == TEXT("SubsurfaceProfile")) + { + if (!bIsSlab && !bIsEye) { S.Error(FString::Printf(TEXT("%s: SubsurfaceProfile only applies to SL_SURFACE/SL_SLAB or SL_EYE"), What)); return false; } + Out.SubsurfaceProfilePath = Value; + } + else if (Key == TEXT("SpecularProfile")) + { + if (!bIsSlab) { S.Error(FString::Printf(TEXT("%s: SpecularProfile only applies to SL_SURFACE/SL_SLAB"), What)); return false; } + Out.SpecularProfilePath = Value; + } + else if (Key == TEXT("SubSurfaceType")) + { + if (!bIsSlab) { S.Error(FString::Printf(TEXT("%s: SubSurfaceType only applies to SL_SURFACE/SL_SLAB"), What)); return false; } + Out.SubSurfaceType = Value; + } + else if (Key == TEXT("ToonProfile")) + { + if (!bIsToon) { S.Error(FString::Printf(TEXT("%s: ToonProfile only applies to SL_TOON"), What)); return false; } + Out.ToonProfilePath = Value; + } + else { S.Error(FString::Printf(TEXT("%s has unknown specifier '%s' (use SubsurfaceProfile/SpecularProfile/ToonProfile/SubSurfaceType)"), What, *Key)); return false; } + } + return true; + } + + /** SL_COLLECTION(Path="...", [Parameter="..."]) floatN ; — a Material Parameter Collection read. */ + void ParseCollection(FScanner& S, FShaderLabModel& Model) + { + FString Inner; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), Inner)) { return; } + FShaderLabCollectionParam C; + for (const FString& StmtRaw : SplitTopLevel(Inner, TEXT(','))) + { + const FString Stmt = StmtRaw.TrimStartAndEnd(); + if (Stmt.IsEmpty()) { continue; } + FString Key, Value; + if (!Stmt.Split(TEXT("="), &Key, &Value)) + { + S.Error(FString::Printf(TEXT("Malformed SL_COLLECTION specifier '%s' (expected Key = Value)"), *Stmt)); + return; + } + Key = Key.TrimStartAndEnd(); + Value = Value.TrimStartAndEnd().TrimQuotes(); + if (Key == TEXT("Path")) { C.CollectionPath = Value; } + else if (Key == TEXT("Parameter")) { C.ParameterName = FName(*Value); } + else { S.Error(FString::Printf(TEXT("SL_COLLECTION has unknown specifier '%s' (use Path / Parameter)"), *Key)); return; } + } + S.SkipTrivia(); + const int32 DeclLine = S.Line, DeclCol = S.Column; + FString TypeTok; + if (!S.ReadIdentifier(TypeTok)) + { + S.Error(TEXT("SL_COLLECTION must be followed by a `float/float3/float4 ;` declaration"), DeclLine, DeclCol); + return; + } + if (!InferPropertyType(TypeTok, C.Type) + || !(C.Type == EShaderLabPropertyType::Scalar || C.Type == EShaderLabPropertyType::Color || C.Type == EShaderLabPropertyType::Vector)) + { + S.Error(FString::Printf(TEXT("SL_COLLECTION type must be float/float3/float4, got '%s'"), *TypeTok), DeclLine, DeclCol); + return; + } + FString Name; + if (!S.ReadIdentifier(Name)) + { + S.Error(TEXT("SL_COLLECTION declaration is missing a name"), DeclLine, DeclCol); + return; + } + if (!S.Expect(TEXT(';'), TEXT("after an SL_COLLECTION declaration"))) + { + return; + } + if (C.CollectionPath.IsEmpty()) + { + S.Error(FString::Printf(TEXT("SL_COLLECTION '%s' requires Path = \"/Game/...\""), *Name), DeclLine, DeclCol); + return; + } + C.Name = FName(*Name); + C.Line = DeclLine; + if (C.ParameterName.IsNone()) { C.ParameterName = C.Name; } + if (Model.Collections.ContainsByPredicate([&C](const FShaderLabCollectionParam& E) { return E.Name == C.Name; })) + { + S.Error(FString::Printf(TEXT("Duplicate SL_COLLECTION name '%s'"), *Name), DeclLine, DeclCol); + return; + } + Model.Collections.Add(MoveTemp(C)); + } + + void ParseEntry(FScanner& S, FShaderLabModel& Model, const FString& Marker) + { + // SL_SURFACE(...) accepts BSDF modifier specifiers; the other entries take no args. + FString SpecInner; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), SpecInner)) + { + return; + } + if (Marker != TEXT("SL_SURFACE") && !SpecInner.TrimStartAndEnd().IsEmpty()) + { + S.Error(FString::Printf(TEXT("%s takes no specifiers (only SL_SURFACE/SL_SLAB accept BSDF modifiers)"), *Marker)); + return; + } + FShaderLabBsdfModifiers EntryMods; + if (Marker == TEXT("SL_SURFACE") && !ParseBsdfModifiers(S, SpecInner, EShaderLabBsdfType::Slab, EntryMods, *Marker)) + { + return; + } + S.SkipTrivia(); + const int32 DeclLine = S.Line, DeclCol = S.Column; FString Name, SigInner, Body; int32 BodyLine = 0; if (!ParseAnnotatedFunction(S, Name, SigInner, Body, BodyLine)) @@ -933,6 +1183,15 @@ namespace return; } + // Vertex/Interpolator run at vertex frequency; Surface/PostProcess/UI at pixel frequency. An optional + // explicit `Parameters` context parameter must match that frequency (see ValidateBodyParameters). + const bool bVertexEntry = (Marker == TEXT("SL_VERTEX")); + const TCHAR* ParamsType = bVertexEntry ? TEXT("FMaterialVertexParameters") : TEXT("FMaterialPixelParameters"); + if (!ValidateBodyParameters(S, Params, Body, ParamsType, /*bRequireStruct*/ true, *Marker, DeclLine, DeclCol)) + { + return; + } + if (Marker == TEXT("SL_VERTEX")) { Model.VertexParams = MoveTemp(Params); @@ -954,12 +1213,26 @@ namespace Model.SurfaceEntry = (Marker == TEXT("SL_POSTPROCESS")) ? EShaderLabEntry::PostProcess : (Marker == TEXT("SL_UI")) ? EShaderLabEntry::UI : EShaderLabEntry::Surface; + if (Model.SurfaceEntry == EShaderLabEntry::Surface) + { + Model.SurfaceModifiers = MoveTemp(EntryMods); + } } - void ParseSlab(FScanner& S, FShaderLabModel& Model) + /** + * Parse a named BSDF block: `SL_SLAB()`/`SL_HAIR()`/... `void (inout F X){...}`. BsdfType comes + * from the marker; ExpectedStruct is the required output-struct type name (e.g. "FShaderLabHair"). Blocks go + * into Model.Slabs; a single dedicated (non-Slab) block with no SL_FRONTMATERIAL is auto-rooted at end-of-parse. + */ + void ParseBsdfBlock(FScanner& S, FShaderLabModel& Model, EShaderLabBsdfType BsdfType, const TCHAR* Marker, const TCHAR* ExpectedStruct) { - FString Ignored; - if (!S.ReadBalanced(TEXT('('), TEXT(')'), Ignored)) // SL_SLAB() + FString SpecInner; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), SpecInner)) // marker(...) + { + return; + } + FShaderLabBsdfModifiers BlockMods; + if (!ParseBsdfModifiers(S, SpecInner, BsdfType, BlockMods, Marker)) { return; } @@ -974,7 +1247,17 @@ namespace TArray Params; if (!ParseEntryParams(SigInner, Params) || Params.Num() < 1) { - S.Error(FString::Printf(TEXT("Slab '%s' must take an (inout FShaderLabSurface) parameter"), *Name), DeclLine, DeclCol); + S.Error(FString::Printf(TEXT("%s '%s' must take an (inout %s) parameter"), Marker, *Name, ExpectedStruct), DeclLine, DeclCol); + return; + } + if (!ValidateBodyParameters(S, Params, Body, TEXT("FMaterialPixelParameters"), /*bRequireStruct*/ true, + *FString::Printf(TEXT("%s '%s'"), Marker, *Name), DeclLine, DeclCol)) + { + return; + } + if (Params.Last().Type != ExpectedStruct) + { + S.Error(FString::Printf(TEXT("%s '%s' must take an (inout %s), got '%s'"), Marker, *Name, ExpectedStruct, *Params.Last().Type), DeclLine, DeclCol); return; } const FName SlabName(*Name); @@ -985,9 +1268,11 @@ namespace } FShaderLabSlab Slab; Slab.Name = SlabName; + Slab.BsdfType = BsdfType; Slab.OutParamName = Params.Last().Name; Slab.Body = MoveTemp(Body); Slab.BodyLine = BodyLine; + Slab.Modifiers = MoveTemp(BlockMods); Model.Slabs.Add(MoveTemp(Slab)); } @@ -1006,6 +1291,17 @@ namespace { return; } + TArray Params; + if (!ParseEntryParams(SigInner, Params)) + { + S.Error(FString::Printf(TEXT("Value '%s' has a malformed parameter signature"), *Name), DeclLine, DeclCol); + return; + } + if (!ValidateBodyParameters(S, Params, Body, TEXT("FMaterialPixelParameters"), /*bRequireStruct*/ false, + *FString::Printf(TEXT("Value '%s'"), *Name), DeclLine, DeclCol)) + { + return; + } const FName ValueName(*Name); if (Model.Values.ContainsByPredicate([&ValueName](const FShaderLabValue& E) { return E.Name == ValueName; })) { @@ -1066,6 +1362,17 @@ namespace { return; } + TArray Params; + if (!ParseEntryParams(SigInner, Params)) + { + S.Error(FString::Printf(TEXT("Interpolator '%s' has a malformed parameter signature"), *Name), DeclLine, DeclCol); + return; + } + if (!ValidateBodyParameters(S, Params, Body, TEXT("FMaterialVertexParameters"), /*bRequireStruct*/ false, + *FString::Printf(TEXT("Interpolator '%s'"), *Name), DeclLine, DeclCol)) + { + return; + } const FName IName(*Name); if (Model.Interpolators.ContainsByPredicate([&IName](const FShaderLabInterpolator& E) { return E.Name == IName; })) { @@ -1221,6 +1528,8 @@ namespace /** Recursively parse a FrontMaterial topology expression; returns the node index (INDEX_NONE on error). */ int32 ParseTopoExpr(FScanner& S, FShaderLabModel& Model) { + S.SkipTrivia(); + const int32 NodeLine = S.Line; // source line of this operator/leaf token, for composition-error mapping FString Name; if (!S.ReadIdentifier(Name)) { @@ -1234,6 +1543,7 @@ namespace FShaderLabTopoNode Leaf; Leaf.Op = EShaderLabOp::SlabRef; Leaf.SlabRef = FName(*Name); + Leaf.SourceLine = NodeLine; return Model.Topology.Add(Leaf); } @@ -1257,6 +1567,7 @@ namespace } FShaderLabTopoNode Node; Node.Op = Op; + Node.SourceLine = NodeLine; Node.ChildA = ParseTopoExpr(S, Model); if (S.bFailed) { return INDEX_NONE; } if (NumExprChildren >= 2) @@ -1433,6 +1744,11 @@ bool FShaderLabParser::Parse( { ParseProperty(S, OutModel); } + else if (Token == TEXT("SL_COLLECTION")) + { + if (RejectInLibrary(TEXT("SL_COLLECTION"))) { return false; } + ParseCollection(S, OutModel); + } else if (Token == TEXT("SL_SURFACE") || Token == TEXT("SL_POSTPROCESS") || Token == TEXT("SL_UI") || Token == TEXT("SL_VERTEX")) { if (RejectInLibrary(TEXT("A pixel/vertex entry"))) { return false; } @@ -1441,7 +1757,47 @@ bool FShaderLabParser::Parse( else if (Token == TEXT("SL_SLAB")) { if (RejectInLibrary(TEXT("SL_SLAB"))) { return false; } - ParseSlab(S, OutModel); + ParseBsdfBlock(S, OutModel, EShaderLabBsdfType::Slab, TEXT("SL_SLAB"), TEXT("FShaderLabSurface")); + } + else if (Token == TEXT("SL_UNLIT")) + { + if (RejectInLibrary(TEXT("SL_UNLIT"))) { return false; } + ParseBsdfBlock(S, OutModel, EShaderLabBsdfType::Unlit, TEXT("SL_UNLIT"), TEXT("FShaderLabUnlit")); + } + else if (Token == TEXT("SL_HAIR")) + { + if (RejectInLibrary(TEXT("SL_HAIR"))) { return false; } + ParseBsdfBlock(S, OutModel, EShaderLabBsdfType::Hair, TEXT("SL_HAIR"), TEXT("FShaderLabHair")); + } + else if (Token == TEXT("SL_EYE")) + { + if (RejectInLibrary(TEXT("SL_EYE"))) { return false; } + ParseBsdfBlock(S, OutModel, EShaderLabBsdfType::Eye, TEXT("SL_EYE"), TEXT("FShaderLabEye")); + } + else if (Token == TEXT("SL_WATER")) + { + if (RejectInLibrary(TEXT("SL_WATER"))) { return false; } + ParseBsdfBlock(S, OutModel, EShaderLabBsdfType::Water, TEXT("SL_WATER"), TEXT("FShaderLabWater")); + } + else if (Token == TEXT("SL_CLEARCOAT")) + { + if (RejectInLibrary(TEXT("SL_CLEARCOAT"))) { return false; } + ParseBsdfBlock(S, OutModel, EShaderLabBsdfType::ClearCoat, TEXT("SL_CLEARCOAT"), TEXT("FShaderLabClearCoat")); + } + else if (Token == TEXT("SL_TOON")) + { + if (RejectInLibrary(TEXT("SL_TOON"))) { return false; } + ParseBsdfBlock(S, OutModel, EShaderLabBsdfType::Toon, TEXT("SL_TOON"), TEXT("FShaderLabToon")); + } + else if (Token == TEXT("SL_VOLUME")) + { + if (RejectInLibrary(TEXT("SL_VOLUME"))) { return false; } + ParseBsdfBlock(S, OutModel, EShaderLabBsdfType::Volume, TEXT("SL_VOLUME"), TEXT("FShaderLabVolume")); + } + else if (Token == TEXT("SL_LIGHTFUNCTION")) + { + if (RejectInLibrary(TEXT("SL_LIGHTFUNCTION"))) { return false; } + ParseBsdfBlock(S, OutModel, EShaderLabBsdfType::LightFunction, TEXT("SL_LIGHTFUNCTION"), TEXT("FShaderLabLightFunction")); } else if (Token == TEXT("SL_VALUE")) { @@ -1468,6 +1824,16 @@ bool FShaderLabParser::Parse( if (RejectInLibrary(TEXT("SL_OPACITY_MASK"))) { return false; } ParseMaterialOutput(S, OutModel.OpacityMaskValueName, TEXT("SL_OPACITY_MASK")); } + else if (Token == TEXT("SL_REFRACTION")) + { + if (RejectInLibrary(TEXT("SL_REFRACTION"))) { return false; } + ParseMaterialOutput(S, OutModel.RefractionValueName, TEXT("SL_REFRACTION")); + } + else if (Token == TEXT("SL_PIXEL_DEPTH_OFFSET")) + { + if (RejectInLibrary(TEXT("SL_PIXEL_DEPTH_OFFSET"))) { return false; } + ParseMaterialOutput(S, OutModel.PixelDepthOffsetValueName, TEXT("SL_PIXEL_DEPTH_OFFSET")); + } else { // Not a ShaderLab macro: a free top-level HLSL declaration (helper function / struct / @@ -1499,6 +1865,17 @@ bool FShaderLabParser::Parse( return true; } + // Names must be unique across SL_PROPERTY and SL_COLLECTION — both resolve to the same in-body identifier + // and are wired through the same PropertyNodes map, so a collision would overwrite one and double-wire. + for (const FShaderLabCollectionParam& C : OutModel.Collections) + { + if (OutModel.FindProperty(C.Name)) + { + S.Error(FString::Printf(TEXT("'%s' is declared as both an SL_PROPERTY and an SL_COLLECTION"), *C.Name.ToString())); + return false; + } + } + // Pixel stage: exactly one of { single Surface } or { named Slabs + FrontMaterial }. const bool bHasMultiSlab = OutModel.Slabs.Num() > 0 || OutModel.TopologyRoot != INDEX_NONE; if (OutModel.bHasSurface && bHasMultiSlab) @@ -1511,17 +1888,41 @@ bool FShaderLabParser::Parse( S.Error(TEXT("Shader must declare a SL_SURFACE(...) entry point, or SL_SLAB blocks with a SL_FRONTMATERIAL expression")); return false; } + + // Material-level output markers only apply to the multi-Slab path (they name a Value block). In a single + // pixel entry they would be silently dropped — reject with guidance to use the S.* fields instead. + if (OutModel.bHasSurface + && (!OutModel.OpacityValueName.IsNone() || !OutModel.OpacityMaskValueName.IsNone() + || !OutModel.RefractionValueName.IsNone() || !OutModel.PixelDepthOffsetValueName.IsNone())) + { + S.Error(TEXT("SL_OPACITY / SL_OPACITY_MASK / SL_REFRACTION / SL_PIXEL_DEPTH_OFFSET are for multi-Slab shaders (with SL_FRONTMATERIAL). In a single SL_SURFACE, set S.Opacity / S.OpacityMask / S.Refraction / S.PixelDepthOffset instead.")); + return false; + } if (bHasMultiSlab) { if (OutModel.Slabs.Num() == 0) { - S.Error(TEXT("SL_FRONTMATERIAL requires at least one SL_SLAB block")); + S.Error(TEXT("SL_FRONTMATERIAL requires at least one BSDF block")); return false; } if (OutModel.TopologyRoot == INDEX_NONE) { - S.Error(TEXT("SL_SLAB blocks require a SL_FRONTMATERIAL(...) topology expression")); - return false; + // A single dedicated BSDF block (SL_UNLIT/HAIR/EYE/WATER/CLEARCOAT/TOON/VOLUME/LIGHTFUNCTION) is + // the whole material: auto-root it. A lone SL_SLAB (BsdfType == Slab) still requires SL_FRONTMATERIAL, + // as do multiple blocks (which need a topology expression to combine them). + if (OutModel.Slabs.Num() == 1 && OutModel.Slabs[0].BsdfType != EShaderLabBsdfType::Slab) + { + FShaderLabTopoNode Leaf; + Leaf.Op = EShaderLabOp::SlabRef; + Leaf.SlabRef = OutModel.Slabs[0].Name; + Leaf.SourceLine = OutModel.Slabs[0].BodyLine; + OutModel.TopologyRoot = OutModel.Topology.Add(Leaf); + } + else + { + S.Error(TEXT("SL_SLAB blocks require a SL_FRONTMATERIAL(...) topology expression")); + return false; + } } } @@ -1545,5 +1946,21 @@ bool FShaderLabParser::Parse( return false; } + // Volume / LightFunction domains use their dedicated BSDF entry as the (sole) root block. + auto HasSoleBsdf = [&OutModel](EShaderLabBsdfType Type) -> bool + { + return !OutModel.bHasSurface && OutModel.Slabs.Num() == 1 && OutModel.Slabs[0].BsdfType == Type; + }; + if (Domain == EShaderLabDomain::Volume && !HasSoleBsdf(EShaderLabBsdfType::Volume)) + { + S.Error(TEXT("Domain = Volume requires a single SL_VOLUME(inout FShaderLabVolume) entry")); + return false; + } + if (Domain == EShaderLabDomain::LightFunction && !HasSoleBsdf(EShaderLabBsdfType::LightFunction)) + { + S.Error(TEXT("Domain = LightFunction requires a single SL_LIGHTFUNCTION(inout FShaderLabLightFunction) entry")); + return false; + } + return true; } diff --git a/Source/UShaderLab/Private/ShaderLabRuntimeBuilder.cpp b/Source/UShaderLab/Private/ShaderLabRuntimeBuilder.cpp index 25af586..07e53a7 100644 --- a/Source/UShaderLab/Private/ShaderLabRuntimeBuilder.cpp +++ b/Source/UShaderLab/Private/ShaderLabRuntimeBuilder.cpp @@ -17,6 +17,8 @@ void FShaderLabRuntimeBuilder::ApplySettings(UMaterial& Material, const FShaderL case EShaderLabDomain::PostProcess: Material.MaterialDomain = MD_PostProcess; break; case EShaderLabDomain::UI: Material.MaterialDomain = MD_UI; break; case EShaderLabDomain::Decal: Material.MaterialDomain = MD_DeferredDecal; break; + case EShaderLabDomain::Volume: Material.MaterialDomain = MD_Volume; break; + case EShaderLabDomain::LightFunction: Material.MaterialDomain = MD_LightFunction; break; case EShaderLabDomain::Surface: default: Material.MaterialDomain = MD_Surface; break; } @@ -27,6 +29,8 @@ void FShaderLabRuntimeBuilder::ApplySettings(UMaterial& Material, const FShaderL case EShaderLabBlendMode::Translucent: Material.BlendMode = BLEND_Translucent; break; case EShaderLabBlendMode::Additive: Material.BlendMode = BLEND_Additive; break; case EShaderLabBlendMode::Modulate: Material.BlendMode = BLEND_Modulate; break; + case EShaderLabBlendMode::AlphaComposite: Material.BlendMode = BLEND_AlphaComposite; break; + case EShaderLabBlendMode::AlphaHoldout: Material.BlendMode = BLEND_AlphaHoldout; break; case EShaderLabBlendMode::Opaque: default: Material.BlendMode = BLEND_Opaque; break; } diff --git a/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp b/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp index fe38313..8b0b892 100644 --- a/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp +++ b/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp @@ -25,6 +25,7 @@ namespace ShaderLabSettings_Private FName(TEXT("bContactShadows")), FName(TEXT("bIsThinSurface")), FName(TEXT("DitheredLODTransition")), + FName(TEXT("RefractionMethod")), }; return Names; } diff --git a/Source/UShaderLab/Public/ShaderLabModel.h b/Source/UShaderLab/Public/ShaderLabModel.h index 69a8c0b..ec7f2ae 100644 --- a/Source/UShaderLab/Public/ShaderLabModel.h +++ b/Source/UShaderLab/Public/ShaderLabModel.h @@ -21,15 +21,28 @@ enum class EShaderLabPropertyType : uint8 Vector, Texture2D, TextureCube, + Texture2DArray, + Texture3D, // volume texture + TextureCubeArray, StaticBool, }; +/** True for any texture-object property type (2D/Cube/2DArray/3D/CubeArray). */ +inline bool ShaderLabIsTextureType(EShaderLabPropertyType T) +{ + return T == EShaderLabPropertyType::Texture2D || T == EShaderLabPropertyType::TextureCube + || T == EShaderLabPropertyType::Texture2DArray || T == EShaderLabPropertyType::Texture3D + || T == EShaderLabPropertyType::TextureCubeArray; +} + enum class EShaderLabDomain : uint8 { Surface, PostProcess, UI, Decal, + Volume, // MD_Volume — volumetric fog/cloud materials; requires an SL_VOLUME entry + LightFunction, // MD_LightFunction — light gobo/pattern; requires an SL_LIGHTFUNCTION entry }; /** Which single pixel-entry a shader declared (when bHasSurface). Selects the output struct + wiring. */ @@ -40,6 +53,26 @@ enum class EShaderLabEntry : uint8 UI, // FShaderLabUI -> EmissiveColor/Opacity }; +/** + * Which Substrate BSDF a named block (`FShaderLabSlab`) or single-entry sugar builds. Slab is the classic + * composable path (SL_SURFACE / SL_SLAB); the others are dedicated BSDFs (SL_UNLIT/HAIR/EYE/WATER/CLEARCOAT/ + * TOON/VOLUME/LIGHTFUNCTION). Each maps to a concrete UMaterialExpressionSubstrate*BSDF node + output struct. + * The graph builder keys a per-BSDF descriptor table on this. Composition legality (which types may feed which + * topology operator) is validated by FShaderLabModel::ValidateTopology mirroring the engine allow-lists. + */ +enum class EShaderLabBsdfType : uint8 +{ + Slab, // FShaderLabSurface -> UMaterialExpressionSubstrateSlabBSDF + Unlit, // FShaderLabUnlit -> UMaterialExpressionSubstrateUnlitBSDF + Hair, // FShaderLabHair -> UMaterialExpressionSubstrateHairBSDF + Eye, // FShaderLabEye -> UMaterialExpressionSubstrateEyeBSDF + Water, // FShaderLabWater -> UMaterialExpressionSubstrateSingleLayerWaterBSDF + Volume, // FShaderLabVolume -> UMaterialExpressionSubstrateVolumetricFogCloudBSDF + ClearCoat, // FShaderLabClearCoat -> UMaterialExpressionSubstrateSimpleClearCoatBSDF + Toon, // FShaderLabToon -> UMaterialExpressionSubstrateToonBSDF + LightFunction, // FShaderLabLightFunction -> UMaterialExpressionSubstrateLightFunction +}; + enum class EShaderLabBlendMode : uint8 { Opaque, @@ -47,6 +80,8 @@ enum class EShaderLabBlendMode : uint8 Translucent, Additive, Modulate, + AlphaComposite, // BLEND_AlphaComposite — premultiplied alpha + AlphaHoldout, // BLEND_AlphaHoldout }; /** A single declared shader property -> becomes a standard UE material parameter. */ @@ -55,14 +90,23 @@ struct USHADERLAB_API FShaderLabProperty FName Name; EShaderLabPropertyType Type = EShaderLabPropertyType::Scalar; - FString DisplayName; FString Group; int32 SortPriority = 0; - /** Range(min,max) meta — only meaningful for Scalar. */ - bool bHasRange = false; - float RangeMin = 0.0f; - float RangeMax = 1.0f; + /** ClampMin/ClampMax slider bounds (UE UPROPERTY style) — only meaningful for Scalar. Each is + * independent; a set field maps to the ScalarParameter's SliderMin/SliderMax respectively. */ + bool bHasClampMin = false; + float ClampMin = 0.0f; + bool bHasClampMax = false; + float ClampMax = 1.0f; + + /** + * bUseCustomPrimitiveData: read this parameter from the component's Custom Primitive Data instead of a + * material uniform (per-instance data without a Material Instance). Only Scalar/Vector support it in UE. + * PrimitiveDataIndex is the mandatory, author-specified float slot (Scalar uses 1 float, Vector uses 4). + */ + bool bUseCustomPrimitiveData = false; + int32 PrimitiveDataIndex = INDEX_NONE; // Defaults (only the field matching Type is meaningful). float ScalarDefault = 0.0f; @@ -72,6 +116,20 @@ struct USHADERLAB_API FShaderLabProperty bool bStaticBoolDefault = false; }; +/** + * A `SL_COLLECTION(Path="...", [Parameter="..."]) floatN ;` declaration: a read of a Material Parameter + * Collection (MPC) value. Unlike SL_PROPERTY it is NOT a per-instance knob — it reads a global collection + * value (UMaterialExpressionCollectionParameter). Type (Scalar/Color/Vector) is inferred from the HLSL type. + */ +struct USHADERLAB_API FShaderLabCollectionParam +{ + FName Name; // in-HLSL identifier the body references + FString CollectionPath; // asset path of the UMaterialParameterCollection + FName ParameterName; // parameter name within the collection (defaults to Name) + EShaderLabPropertyType Type = EShaderLabPropertyType::Scalar; // Scalar / Color / Vector + int32 Line = 0; +}; + /** A parameter in a `Surface(...)`/`Vertex(...)` entry-point signature. */ struct USHADERLAB_API FShaderLabEntryParam { @@ -80,14 +138,34 @@ struct USHADERLAB_API FShaderLabEntryParam bool bInout = false; }; -/** A named `Slab (inout FShaderLabSurface S){...}` block in a multi-slab shader. */ +/** + * Non-pin BSDF node modifiers set via marker specifiers (SL_SURFACE/SL_SLAB(...), etc.): asset references and + * enum selectors that are node properties, not connectable float pins. Empty/None means "leave the node default". + * SubsurfaceProfile/SpecularProfile apply to Slab (SubsurfaceProfile also to Eye); ToonProfile applies to Toon; + * SubSurfaceType (Slab) is one of None/Wrap/TwoSidedWrap/Diffusion/SimpleVolume. + */ +struct USHADERLAB_API FShaderLabBsdfModifiers +{ + FString SubsurfaceProfilePath; + FString SpecularProfilePath; + FString ToonProfilePath; + FString SubSurfaceType; // enum token; empty = leave default +}; + +/** + * A named BSDF block in a multi-slab / multi-BSDF shader, e.g. `SL_SLAB() void (inout FShaderLabSurface S){...}` + * or `SL_HAIR() void (inout FShaderLabHair H){...}`. BsdfType (set by the marker) selects the engine node + * and output struct; the graph builder keys its per-BSDF descriptor table on it. + */ struct USHADERLAB_API FShaderLabSlab { FName Name; + EShaderLabBsdfType BsdfType = EShaderLabBsdfType::Slab; /** Name of the inout output-struct parameter (e.g. "S"). */ FString OutParamName; FString Body; int32 BodyLine = 0; + FShaderLabBsdfModifiers Modifiers; }; /** A named `Value (){ return ; }` block, used as a topology mix factor. */ @@ -190,6 +268,8 @@ struct USHADERLAB_API FShaderLabTopoNode int32 ChildB = INDEX_NONE; // second material input (binary ops) bool bHasFactor = false; FShaderLabFactor Factor; // for VerticalLayer/HorizontalMix/Weight/Select + /** 1-based source line of this operator/leaf token inside SL_FRONTMATERIAL, for composition-error mapping. */ + int32 SourceLine = 0; }; struct USHADERLAB_API FShaderLabSettings @@ -227,6 +307,8 @@ struct USHADERLAB_API FShaderLabModel TArray> RawSettings; TArray Properties; + /** Material Parameter Collection reads (SL_COLLECTION). Global values, not per-instance knobs. */ + TArray Collections; TArray Includes; /** @@ -258,15 +340,19 @@ struct USHADERLAB_API FShaderLabModel bool bHasSurface = false; /** Which single pixel-entry was declared (valid when bHasSurface). */ EShaderLabEntry SurfaceEntry = EShaderLabEntry::Surface; + /** BSDF node modifiers from SL_SURFACE(...) specifiers (only meaningful for the Surface entry). */ + FShaderLabBsdfModifiers SurfaceModifiers; // Multi-slab topology (used when !bHasSurface). TArray Slabs; TArray Values; TArray Topology; int32 TopologyRoot = INDEX_NONE; - /** Optional material-level outputs (multi-slab): names of Value blocks feeding Opacity/OpacityMask. */ + /** Optional material-level outputs (multi-slab): names of Value blocks feeding Opacity/OpacityMask/Refraction/PDO. */ FName OpacityValueName; FName OpacityMaskValueName; + FName RefractionValueName; + FName PixelDepthOffsetValueName; // Vertex stage (optional). bool bHasVertex = false; @@ -291,4 +377,12 @@ struct USHADERLAB_API FShaderLabModel /** True if any property is a StaticBool (drives shader-map permutation count). */ bool HasStaticSwitches() const; + + /** + * Validate the FrontMaterial topology against the Substrate operator BSDF-type allow-lists (mirrors the + * engine's SubstrateTranslatorCommon allow-lists): VerticalLayer/HorizontalMix/Add accept only Slab; Weight + * accepts Unlit/Slab/Toon; Select accepts Slab/Hair/Eye/Toon. On an illegal combination, appends an error + * mapped to the offending operator's .usl line. Returns true when the topology is legal (or empty). + */ + bool ValidateTopology(TArray& OutErrors) const; }; diff --git a/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp b/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp index 81afe2b..edc6c7f 100644 --- a/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp +++ b/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp @@ -11,11 +11,23 @@ #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/MaterialExpressionVectorParameter.h" #include "Materials/MaterialExpressionVertexInterpolator.h" @@ -30,6 +42,7 @@ #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") @@ -206,6 +219,8 @@ namespace ShaderLabGraph 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; } @@ -219,6 +234,8 @@ namespace ShaderLabGraph 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; } @@ -235,6 +252,225 @@ namespace ShaderLabGraph 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 { @@ -631,6 +867,18 @@ namespace ShaderLabGraph 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). } } @@ -640,7 +888,7 @@ namespace ShaderLabGraph { const FString Name = Prop.Name.ToString(); OutArgs.Add(Name); - if (Prop.Type == EShaderLabPropertyType::Texture2D || Prop.Type == EShaderLabPropertyType::TextureCube) + if (ShaderLabIsTextureType(Prop.Type)) { OutArgs.Add(Name + TEXT("Sampler")); } @@ -1079,7 +1327,8 @@ namespace ShaderLabGraph * 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 TMap& PropertyNodes, const FString& InBody, const TSet& ReqProps, + const TArray& Collections) { for (const FShaderLabProperty& Prop : Program.Properties) { @@ -1100,6 +1349,22 @@ namespace ShaderLabGraph 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). */ @@ -1319,12 +1584,68 @@ namespace ShaderLabGraph } } + /** 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; + } + /** - * Emit the Custom node for a pixel-stage body that writes FShaderLabSurface fields and wire it into - * a fresh Substrate Slab BSDF. Returns the slab (nullptr only on error). When bAllowMaterialOutputs, - * S.Opacity / S.OpacityMask are wired to the material-level pins (single-Surface sugar path only). + * 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. */ - static UMaterialExpressionSubstrateSlabBSDF* BuildSlab( + 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, @@ -1332,11 +1653,25 @@ namespace ShaderLabGraph const TMap& InterpByName, TSet& UsedInterps, bool bAllowMaterialOutputs, int32& IoY, TArray& OutErrors) { - UMaterialExpressionSubstrateSlabBSDF* Slab = NewExpr(Material, IoY, 0); + 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 (const FSlabFieldDef& F : GSlabFields) + for (int32 i = 0; i < Desc.NumFields; ++i) { + const FSlabFieldDef& F = Desc.Fields[i]; if (ReferencesToken(InBody, OutParamName + TEXT(".") + F.Field)) { UsedSlab.Add(&F); @@ -1344,14 +1679,16 @@ namespace ShaderLabGraph } 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) + if (UsedSlab.Num() == 0 && !bUsesOpacity && !bUsesOpacityMask && !bUsesRefraction && !bUsesPDO) { - return Slab; // Empty body: a default Substrate slab. + return Bsdf; // Empty body: a default Substrate BSDF. } UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); - Custom->Description = TEXT("ShaderLab Surface"); + Custom->Description = Desc.CustomDesc; Custom->OutputType = CMOT_Float1; AddIncludes(*Custom, Model); @@ -1369,11 +1706,11 @@ namespace ShaderLabGraph In.Input.Connect(Wire.OutputIndex, Wire.Expr); Custom->Inputs.Add(In); } - WirePropInputs(*Custom, Program, PropertyNodes, InBody, ReqProps); + WirePropInputs(*Custom, Program, PropertyNodes, InBody, ReqProps, Model.Collections); const FString SrcPath = MakeLineDirectivePath(Model.SourceFilePath); - FString Code = FString::Printf(TEXT("FShaderLabSurface %s = ShaderLabDefaultSurface();\n{\n%s}\n"), - *OutParamName, *WrapBodyWithLineMapping(Body, BodyLine, SrcPath)); + 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; @@ -1403,20 +1740,54 @@ namespace ShaderLabGraph 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 = GetSlabPin(Slab, Pair.Key->Field)) + 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); } - return Slab; + 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; } /** @@ -1450,7 +1821,7 @@ namespace ShaderLabGraph In.Input.Connect(Wire.OutputIndex, Wire.Expr); Custom->Inputs.Add(In); } - WirePropInputs(*Custom, Program, PropertyNodes, InBody, ReqProps); + WirePropInputs(*Custom, Program, PropertyNodes, InBody, ReqProps, Model.Collections); const bool bUsesColor = ReferencesToken(InBody, OutParamName + TEXT(".Color")); const bool bUsesOpacity = ReferencesToken(InBody, OutParamName + TEXT(".Opacity")); @@ -1512,7 +1883,7 @@ namespace ShaderLabGraph In.Input.Connect(Wire.OutputIndex, Wire.Expr); Custom->Inputs.Add(In); } - WirePropInputs(*Custom, Program, PropertyNodes, Value.Body, ReqProps); + 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); @@ -1553,7 +1924,7 @@ namespace ShaderLabGraph In.Input.Connect(Wire.OutputIndex, Wire.Expr); Custom->Inputs.Add(In); } - WirePropInputs(*Custom, Program, PropertyNodes, Interp.Body, ReqProps); + 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)); @@ -1613,7 +1984,7 @@ namespace ShaderLabGraph /** 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& SlabByName, const TMap& ValueByName, const TMap& PropertyNodes, const TMap& InterpByName, TSet& UsedInterps, @@ -1628,7 +1999,7 @@ namespace ShaderLabGraph if (Node.Op == EShaderLabOp::SlabRef) { - if (UMaterialExpressionSubstrateSlabBSDF* const* Found = SlabByName.Find(Node.SlabRef)) + if (UMaterialExpression* const* Found = SlabByName.Find(Node.SlabRef)) { return *Found; } @@ -1862,6 +2233,50 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode 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(); @@ -1918,10 +2333,18 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode E->DefaultValue = Prop.ScalarDefault; E->Group = FName(*Prop.Group); E->SortPriority = Prop.SortPriority; - if (Prop.bHasRange) + if (Prop.bHasClampMin) { - E->SliderMin = Prop.RangeMin; - E->SliderMax = Prop.RangeMax; + 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; @@ -1934,6 +2357,11 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode 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; } @@ -1951,6 +2379,22 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode Node.bIsTexture = true; break; } + case EShaderLabPropertyType::Texture2DArray: + case EShaderLabPropertyType::Texture3D: + case EShaderLabPropertyType::TextureCubeArray: + { + // Array / volume texture object parameter. The engine assigns a matching-dimension default when + // Texture is left null; the artist binds a real asset on the Material Instance. (Built-in white/ + // black/grey/normal default tokens only exist for Texture2D, so they are not applied here.) + UMaterialExpressionTextureObjectParameter* E = NewExpr(Material, ParamY, -1000); + E->ParameterName = Prop.Name; + E->Group = FName(*Prop.Group); + E->SortPriority = Prop.SortPriority; + E->SamplerType = SAMPLERTYPE_Color; + Node.Expr = E; + Node.bIsTexture = true; + break; + } default: break; } @@ -1960,6 +2404,41 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode } } + // 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) { @@ -1998,7 +2477,8 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode { // Single-Surface sugar: one slab straight to FrontMaterial, with S.Opacity/S.OpacityMask // allowed as material-level outputs. - UMaterialExpressionSubstrateSlabBSDF* Slab = BuildSlab( + UMaterialExpression* Slab = BuildBsdf( + *FindBsdfDesc(EShaderLabBsdfType::Slab), Model.SurfaceModifiers, Material, *EditorOnly, SurfaceOutParam->Name, Model.SurfaceBody, Model.SurfaceBodyLine, Model, Program, Emit, PropertyNodes, InterpByName, UsedInterps, /*bAllowMaterialOutputs*/ true, ParamY, OutErrors); if (!Slab) @@ -2009,9 +2489,10 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode } else { - // Multi-slab: each named Slab -> its own slab node; Value blocks -> scalar Custom nodes; the - // FrontMaterial topology tree mixes them; Opacity/OpacityMask come from named Value blocks. - TMap SlabByName; + // 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)) @@ -2019,7 +2500,14 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode OutErrors.Add(FString::Printf(TEXT("Duplicate Slab name '%s'"), *SlabDecl.Name.ToString())); return false; } - UMaterialExpressionSubstrateSlabBSDF* Slab = BuildSlab( + 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, /*bAllowMaterialOutputs*/ false, ParamY, OutErrors); if (!Slab) @@ -2046,6 +2534,13 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode 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) @@ -2084,6 +2579,8 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode }; 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 @@ -2128,7 +2625,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode In.Input.Connect(Wire.OutputIndex, Wire.Expr); VCustom->Inputs.Add(In); } - WirePropInputs(*VCustom, Program, PropertyNodes, Model.VertexBody, VtxReqProps); + 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"), @@ -2186,6 +2683,88 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode } } + // 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); + } + } + + // Scene-texture reads: the UE_SceneColor/SceneDepth/CustomDepth/CustomStencil/SceneTexture helpers call the + // engine's SceneTextureLookup in HLSL. That only works when the material sets bNeedsSceneTextures, which the + // engine derives from a UMaterialExpressionSceneTexture node being present + compiled. We add one hidden node + // wired into the ParameterAnchor (compiled before attributes, so its Compile always runs and flips the flag). + { + auto BodyUsesScene = [](const FString& Body) -> bool + { + return ReferencesToken(Body, TEXT("UE_SceneColor")) || ReferencesToken(Body, TEXT("UE_SceneDepth")) + || ReferencesToken(Body, TEXT("UE_CustomDepth")) || ReferencesToken(Body, TEXT("UE_CustomStencil")) + || ReferencesToken(Body, TEXT("UE_SceneTexture")); + }; + bool bUsesScene = Model.bHasSurface && BodyUsesScene(Model.SurfaceBody); + for (const FShaderLabSlab& Slab : Model.Slabs) { bUsesScene |= BodyUsesScene(Slab.Body); } + for (const FShaderLabValue& Value : Model.Values) { bUsesScene |= BodyUsesScene(Value.Body); } + + if (bUsesScene) + { + const EShaderLabDomain Dom = Model.Settings.Domain; + const bool bNonOpaqueSurface = (Dom == EShaderLabDomain::Surface) + && Model.Settings.BlendMode != EShaderLabBlendMode::Opaque + && Model.Settings.BlendMode != EShaderLabBlendMode::Masked; + const bool bDomainOk = (Dom == EShaderLabDomain::PostProcess) || (Dom == EShaderLabDomain::Decal) || bNonOpaqueSurface; + if (!bDomainOk) + { + OutErrors.Add(TEXT("Scene-texture reads (UE_SceneColor/UE_SceneDepth/UE_SceneTexture/...) require a PostProcess or Decal material, or a translucent (non-opaque) Surface material.")); + return false; + } + // The hidden node flips bNeedsSceneTextures. In PostProcess use PostProcessInput0; elsewhere use + // SceneDepth — the only scene-texture family the engine permits outside PostProcess/Decal (scene + // color via SceneTexture is disallowed there, so we don't request it). + UMaterialExpressionSceneTexture* Hidden = NewExpr(Material, ParamY, -1300); + Hidden->SceneTextureId = (Dom == EShaderLabDomain::PostProcess) ? PPI_PostProcessInput0 : PPI_SceneDepth; + AnchorInputs.Add(Hidden); + } + } + // 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