diff --git a/Shaders/ShaderLab.ush b/Shaders/ShaderLab.ush new file mode 100644 index 0000000..1fd4b06 --- /dev/null +++ b/Shaders/ShaderLab.ush @@ -0,0 +1,44 @@ +// GENERATED by `ShaderLab.IDE.Prepare` — do not edit by hand. +// Authoring umbrella header: include this from every .usl for IDE completion. +// At real shader-compile time this header is NOT included (the parser strips it); +// the UE:: stubs below are for the editor only. +#pragma once + +#include "/Plugin/ShaderLab/ShaderLabDSL.ush" +#include "/Plugin/ShaderLab/Private/ShaderLabCommon.ush" + +namespace UE +{ + float3 ActorPositionWS() { return (float3)0; } + float3 CameraPositionWS() { return (float3)0; } + float3 CameraVectorWS() { return (float3)0; } + float DeltaTime() { return (float)0; } + float2 LightmapUVs() { return (float2)0; } + float3 LightVector() { return (float3)0; } + float3 LocalPosition() { return (float3)0; } + float3 ObjectBounds() { return (float3)0; } + float3 ObjectLocalBounds() { return (float3)0; } + float3 ObjectOrientation() { return (float3)0; } + float3 ObjectPositionWS() { return (float3)0; } + float ObjectRadius() { return (float)0; } + float4 ParticleColor() { return (float4)0; } + float PerInstanceCustomData(int DataIndex, float ConstDefaultValue = 0) { return (float)0; } + float PerInstanceFadeAmount() { return (float)0; } + float PerInstanceRandom() { return (float)0; } + float PixelDepth() { return (float)0; } + float3 PixelNormalWS() { return (float3)0; } + float3 PreSkinnedLocalBounds() { return (float3)0; } + float3 PreSkinnedNormal() { return (float3)0; } + float3 PreSkinnedPosition() { return (float3)0; } + float2 ScreenPosition() { return (float2)0; } + // UV channel `Index`, optionally tiled. + float2 TextureCoordinate(int Index, float UTiling = 1, float VTiling = 1) { return (float2)0; } + float Time() { return (float)0; } + float TwoSidedSign() { return (float)0; } + float4 VertexColor() { return (float4)0; } + float3 VertexNormalWS() { return (float3)0; } + float3 VertexTangentWS() { return (float3)0; } + // Property is an EMaterialExposedViewProperty token (e.g. MEVP_FieldOfView). + float4 ViewProperty(int Property) { return (float4)0; } + float3 WorldPosition() { return (float3)0; } +} diff --git a/Shaders/ShaderLabDSL.ush b/Shaders/ShaderLabDSL.ush new file mode 100644 index 0000000..c71ee55 --- /dev/null +++ b/Shaders/ShaderLabDSL.ush @@ -0,0 +1,54 @@ +// Copyright FlecsProj. All Rights Reserved. +// +// ShaderLab DSL vocabulary — the UFUNCTION/UPROPERTY-style annotation macros that mark up an +// otherwise-plain-HLSL `.usl` file. Every macro expands to NOTHING for the preprocessor (and for +// HLSL Tools / Rider), so the file stays valid HLSL and gets free completion; the ShaderLab C++ +// parser reads these macro invocations + the HLSL declaration that follows to rebuild its model. +// +// Included (transitively, via ShaderLab.ush) by every `.usl`. This file is static and checked in; +// the sibling ShaderLab.ush is generated by the `ShaderLab.IDE.Prepare` console command. + +#pragma once + +// --------------------------------------------------------------------------- +// Annotation macros. Two-line UFUNCTION style: the macro sits on its own line +// directly above the HLSL declaration it annotates. +// --------------------------------------------------------------------------- + +// Material settings. Standalone (no following declaration). +// SL_SETTINGS(Domain = Surface, BlendMode = Opaque, TwoSided = false, OpacityMaskClipValue = 0.4) +#define SL_SETTINGS(...) + +// Material parameter. Precedes the HLSL declaration whose type selects the DSL type: +// float -> Scalar | float3 -> Color | float4 -> Vector | +// Texture2D/TextureCube -> texture | "#define X v" -> StaticBool. +// Scalar/Color/Vector defaults come from the HLSL initializer; texture default from DefaultTexture. +#define SL_PROPERTY(...) + +// Pixel/vertex entry points. Precede a `void Name(inout F... X) { ... }` (Vertex takes FShaderLabVertex). +#define SL_SURFACE(...) +#define SL_POSTPROCESS(...) +#define SL_UI(...) +#define SL_VERTEX(...) + +// Multi-slab building blocks. +#define SL_SLAB(...) // precedes `void Name(inout FShaderLabSurface S) { ... }` +#define SL_VALUE(...) // precedes `float Name() { 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) + +// --------------------------------------------------------------------------- +// Specifier vocabulary (canonical, PascalCase — aligned with UE conventions). +// These are the keys accepted inside the macros above. +// +// SL_SETTINGS : Domain, BlendMode, TwoSided, OpacityMaskClipValue, +// Domain = Surface | PostProcess | UI | Decal +// BlendMode = Opaque | Masked | Translucent | Additive | Modulate +// SL_PROPERTY : DisplayName, Group, SortPriority, Range (Scalar only), DefaultTexture (textures only) +// +// Topology operators (used inside SL_FRONTMATERIAL): +// VerticalLayer(Top, Base, Thickness) | HorizontalMix(Background, Foreground, Mix) +// Add(A, B) | Weight(A, Weight) | Select(A, B, Threshold) +// A mix factor is a float literal, a Scalar property name, or an SL_VALUE block name. +// --------------------------------------------------------------------------- diff --git a/Source/UShaderLab/Private/ShaderLabParser.cpp b/Source/UShaderLab/Private/ShaderLabParser.cpp index 33aa4be..9674b07 100644 --- a/Source/UShaderLab/Private/ShaderLabParser.cpp +++ b/Source/UShaderLab/Private/ShaderLabParser.cpp @@ -5,6 +5,15 @@ #include "Misc/Paths.h" #include "ShaderLabModel.h" +// A `.usl` is plain HLSL annotated with UFUNCTION/UPROPERTY-style `SL_*` macros (defined in +// /Plugin/ShaderLab/ShaderLabDSL.ush as empty expansions). This parser scans the top-level +// constructs — `#include`s, `SL_SETTINGS`, `SL_PROPERTY` + a following HLSL declaration, the +// `SL_SURFACE/POSTPROCESS/UI/VERTEX/SLAB/VALUE` markers + their functions, and the standalone +// `SL_FRONTMATERIAL/OPACITY/OPACITY_MASK` — and rebuilds the same FShaderLabModel the old DSL did. +// Everything not annotated (the authoring-shim `#include`, the macros, the property globals' editor +// presence) never reaches the shader compiler: the graph builder only consumes entry-function bodies +// and rewrites UE:: calls into expression-node inputs. + namespace ShaderLabParser_Private { /** Character scanner with line/column tracking and comment-aware trivia skipping. */ @@ -254,60 +263,36 @@ namespace ShaderLabParser_Private Error(FString::Printf(TEXT("Unterminated block opened with '%c'"), Open), OpenLine, OpenCol); return false; } - }; - /** - * Remove `//` line and block comments, preserving quoted strings. Used on the structured blocks - * (Settings/Usage/Properties) before splitting them into statements — HLSL bodies keep their - * comments verbatim and must NOT pass through here. - */ - static FString StripComments(const FString& In) - { - FString Out; - Out.Reserve(In.Len()); - const int32 N = In.Len(); - int32 i = 0; - while (i < N) + /** Consume the remainder of the current physical line (used to skip non-DSL preprocessor lines). */ + void SkipToEndOfLine() { - const TCHAR C = In[i]; - if (C == TEXT('"') || C == TEXT('\'')) + while (!IsEnd() && Peek() != TEXT('\n')) { - const TCHAR Quote = C; - Out.AppendChar(C); - ++i; - while (i < N) - { - if (In[i] == TEXT('\\') && i + 1 < N) - { - Out.AppendChar(In[i]); - Out.AppendChar(In[i + 1]); - i += 2; - continue; - } - Out.AppendChar(In[i]); - const bool bClose = (In[i] == Quote); - ++i; - if (bClose) { break; } - } - continue; + Advance(); } - if (C == TEXT('/') && i + 1 < N && In[i + 1] == TEXT('/')) - { - while (i < N && In[i] != TEXT('\n')) { ++i; } - continue; - } - if (C == TEXT('/') && i + 1 < N && In[i + 1] == TEXT('*')) - { - i += 2; - while (i + 1 < N && !(In[i] == TEXT('*') && In[i + 1] == TEXT('/'))) { ++i; } - i += 2; - continue; - } - Out.AppendChar(C); - ++i; } - return Out; - } + + /** Read verbatim up to a top-level ';' (respecting parens); does not consume the ';'. */ + bool ReadUntilSemicolon(FString& Out) + { + FString T; + int32 Paren = 0; + while (!IsEnd()) + { + const TCHAR C = Peek(); + if (C == TEXT(';') && Paren == 0) + { + break; + } + if (C == TEXT('(')) { ++Paren; } + else if (C == TEXT(')')) { --Paren; } + T.AppendChar(Advance()); + } + Out = T.TrimStartAndEnd(); + return !IsEnd(); + } + }; /** Split `Inner` on top-level occurrences of `Delim`, respecting (), [] and quotes. */ static TArray SplitTopLevel(const FString& Inner, TCHAR Delim) @@ -364,17 +349,6 @@ namespace ShaderLabParser_Private return Parts; } - static bool ParsePropertyType(const FString& Token, EShaderLabPropertyType& Out) - { - if (Token == TEXT("Scalar")) { Out = EShaderLabPropertyType::Scalar; return true; } - if (Token == TEXT("Color")) { Out = EShaderLabPropertyType::Color; return true; } - if (Token == TEXT("Vector")) { Out = EShaderLabPropertyType::Vector; return true; } - if (Token == TEXT("Texture2D")) { Out = EShaderLabPropertyType::Texture2D; return true; } - if (Token == TEXT("TextureCube")) { Out = EShaderLabPropertyType::TextureCube; return true; } - if (Token == TEXT("StaticBool")) { Out = EShaderLabPropertyType::StaticBool; return true; } - return false; - } - static bool ParseDomain(const FString& Token, EShaderLabDomain& Out) { if (Token == TEXT("Surface")) { Out = EShaderLabDomain::Surface; return true; } @@ -396,30 +370,43 @@ namespace ShaderLabParser_Private static bool ParseBool(const FString& Token, bool& Out) { - if (Token == TEXT("true")) { Out = true; return true; } - if (Token == TEXT("false")) { Out = false; return true; } + if (Token == TEXT("true") || Token == TEXT("1")) { Out = true; return true; } + if (Token == TEXT("false") || Token == TEXT("0")) { Out = false; return true; } return false; } - /** Parse a parenthesized float list "(r, g, b[, a])" into a color (alpha defaults to 1). */ - static bool ParseColorLiteral(const FString& InRaw, FLinearColor& Out) + /** Map an HLSL declaration type token to a DSL property type. */ + static bool InferPropertyType(const FString& HlslType, EShaderLabPropertyType& Out) + { + if (HlslType == TEXT("float")) { Out = EShaderLabPropertyType::Scalar; return true; } + if (HlslType == TEXT("float3")) { Out = EShaderLabPropertyType::Color; return true; } + 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; } + return false; + } + + /** Parse a `floatN(a, b, c[, d])` initializer (or a bare parenthesized list) into a color. */ + static bool ParseVectorInitializer(const FString& InRaw, FLinearColor& Out) { FString Raw = InRaw.TrimStartAndEnd(); - if (!Raw.StartsWith(TEXT("(")) || !Raw.EndsWith(TEXT(")"))) + const int32 Open = Raw.Find(TEXT("(")); + const int32 Close = Raw.Find(TEXT(")"), ESearchCase::CaseSensitive, ESearchDir::FromEnd); + if (Open == INDEX_NONE || Close == INDEX_NONE || Close <= Open) { return false; } - Raw = Raw.Mid(1, Raw.Len() - 2); - const TArray Comps = SplitTopLevel(Raw, TEXT(',')); - if (Comps.Num() < 3 || Comps.Num() > 4) + const FString ListInner = Raw.Mid(Open + 1, Close - Open - 1); + const TArray Comps = SplitTopLevel(ListInner, TEXT(',')); + if (Comps.Num() < 1 || Comps.Num() > 4) { return false; } Out = FLinearColor( - FCString::Atof(*Comps[0]), - FCString::Atof(*Comps[1]), - FCString::Atof(*Comps[2]), - Comps.Num() == 4 ? FCString::Atof(*Comps[3]) : 1.0f); + Comps.Num() > 0 ? FCString::Atof(*Comps[0]) : 0.0f, + Comps.Num() > 1 ? FCString::Atof(*Comps[1]) : 0.0f, + Comps.Num() > 2 ? FCString::Atof(*Comps[2]) : 0.0f, + Comps.Num() > 3 ? FCString::Atof(*Comps[3]) : 1.0f); return true; } @@ -456,22 +443,55 @@ namespace ShaderLabParser_Private } return true; } + + /** True if `IncludePath` names a ShaderLab authoring-shim header (excluded from the compiled includes). */ + static bool IsAuthoringShimInclude(const FString& IncludePath) + { + const FString Leaf = FPaths::GetCleanFilename(IncludePath); + return Leaf == TEXT("ShaderLab.ush") + || Leaf == TEXT("ShaderLabDSL.ush") + || Leaf == TEXT("ShaderLabCommon.ush"); + } } using namespace ShaderLabParser_Private; namespace { + /** Read ` ( ) { }` after a marker macro. Returns false on error. */ + bool ParseAnnotatedFunction(FScanner& S, FString& OutName, FString& OutSigInner, FString& OutBody, int32& OutBodyLine) + { + FString RetType; + if (!S.ReadIdentifier(RetType)) + { + S.Error(TEXT("Expected a function declaration after the ShaderLab marker")); + return false; + } + if (!S.ReadIdentifier(OutName)) + { + S.Error(TEXT("Expected a function name")); + return false; + } + if (!S.ReadBalanced(TEXT('('), TEXT(')'), OutSigInner)) + { + return false; + } + if (!S.ReadBalanced(TEXT('{'), TEXT('}'), OutBody, &OutBodyLine)) + { + return false; + } + return true; + } + void ParseSettings(FScanner& S, FShaderLabModel& Model) { FShaderLabSettings& Out = Model.Settings; FString Inner; - if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Inner)) + if (!S.ReadBalanced(TEXT('('), TEXT(')'), Inner)) { return; } - // Settings are simple `Key = Value;` pairs; tolerate trailing items. - for (const FString& StmtRaw : SplitTopLevel(StripComments(Inner), TEXT(';'))) + for (const FString& StmtRaw : SplitTopLevel(Inner, TEXT(','))) { const FString Stmt = StmtRaw.TrimStartAndEnd(); if (Stmt.IsEmpty()) @@ -520,220 +540,271 @@ namespace } } - void ParseOnePropertyDecl(FScanner& S, const FString& Stmt, FShaderLabModel& Model) + /** Apply one `Key = Value` SL_PROPERTY specifier. */ + void ApplyPropertySpecifier(FScanner& S, const FString& ArgRaw, FShaderLabProperty& Prop) { - // Form: Type Name ( "Disp", "Group" [, Range(a,b)] ) = default - FString TypeName; - int32 Cursor = 0; - const FString Trimmed = Stmt.TrimStartAndEnd(); - - // Read type token. - while (Cursor < Trimmed.Len() && !FChar::IsWhitespace(Trimmed[Cursor])) + const FString Arg = ArgRaw.TrimStartAndEnd(); + if (Arg.IsEmpty()) { - ++Cursor; - } - TypeName = Trimmed.Left(Cursor).TrimStartAndEnd(); - - EShaderLabPropertyType Type; - if (!ParsePropertyType(TypeName, Type)) - { - S.Error(FString::Printf(TEXT("Unknown property type '%s'"), *TypeName)); return; } - - // Read name token up to '('. - const int32 ParenOpen = Trimmed.Find(TEXT("("), ESearchCase::CaseSensitive, ESearchDir::FromStart, Cursor); - if (ParenOpen == INDEX_NONE) + FString Key, Value; + if (!Arg.Split(TEXT("="), &Key, &Value)) { - S.Error(FString::Printf(TEXT("Property '%s' missing '(' metadata block"), *TypeName)); - return; - } - const FString NameToken = Trimmed.Mid(Cursor, ParenOpen - Cursor).TrimStartAndEnd(); - if (NameToken.IsEmpty()) - { - S.Error(TEXT("Property is missing a name")); + S.Error(FString::Printf(TEXT("Property '%s' has malformed specifier '%s' (expected Key = Value)"), + *Prop.Name.ToString(), *Arg)); return; } + Key = Key.TrimStartAndEnd(); + Value = Value.TrimStartAndEnd(); - // Find matching ')' for the metadata block (no nested parens except Range(...)). - int32 Depth = 0; - int32 ParenClose = INDEX_NONE; - for (int32 i = ParenOpen; i < Trimmed.Len(); ++i) + if (Key == TEXT("DisplayName")) { - if (Trimmed[i] == TEXT('(')) { ++Depth; } - else if (Trimmed[i] == TEXT(')')) { --Depth; if (Depth == 0) { ParenClose = i; break; } } + Prop.DisplayName = Value.TrimQuotes(); } - if (ParenClose == INDEX_NONE) + else if (Key == TEXT("Group")) + { + Prop.Group = Value.TrimQuotes(); + } + else if (Key == TEXT("SortPriority")) + { + Prop.SortPriority = FCString::Atoi(*Value); + } + else if (Key == TEXT("DefaultTexture")) + { + Prop.TextureDefault = Value.TrimQuotes(); + } + else if (Key == TEXT("Range")) + { + const TArray Comps = SplitTopLevel( + Value.Replace(TEXT("("), TEXT("")).Replace(TEXT(")"), TEXT("")), TEXT(',')); + if (Comps.Num() != 2) + { + S.Error(FString::Printf(TEXT("Property '%s' Range expects (min,max)"), *Prop.Name.ToString())); + return; + } + Prop.bHasRange = true; + Prop.RangeMin = FCString::Atof(*Comps[0]); + Prop.RangeMax = FCString::Atof(*Comps[1]); + } + else + { + S.Error(FString::Printf(TEXT("Property '%s' has unknown specifier '%s'"), *Prop.Name.ToString(), *Key)); + } + } + + void ParseProperty(FScanner& S, FShaderLabModel& Model) + { + // SL_PROPERTY( ) + FString SpecInner; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), SpecInner)) { - S.Error(FString::Printf(TEXT("Property '%s' has an unterminated '(' block"), *NameToken)); return; } FShaderLabProperty Prop; - Prop.Name = FName(*NameToken); - Prop.Type = Type; + Prop.SortPriority = Model.Properties.Num(); - const FString MetaInner = Trimmed.Mid(ParenOpen + 1, ParenClose - ParenOpen - 1); - const TArray MetaArgs = SplitTopLevel(MetaInner, TEXT(',')); - int32 StringArgIndex = 0; - for (const FString& ArgRaw : MetaArgs) + // The HLSL declaration following the macro determines name + type + default. + S.SkipTrivia(); + if (S.Peek() == TEXT('#')) { - const FString Arg = ArgRaw.TrimStartAndEnd(); - if (Arg.IsEmpty()) + // `#define NAME VALUE` => StaticBool. + S.Advance(); // '#' + FString Directive; + if (!S.ReadIdentifier(Directive) || Directive != TEXT("define")) { - continue; + S.Error(TEXT("SL_PROPERTY on a preprocessor line expects `#define Name 0|1`")); + return; } - if (Arg.StartsWith(TEXT("\""))) + FString Name; + if (!S.ReadIdentifier(Name)) { - const FString Unquoted = Arg.TrimQuotes(); - if (StringArgIndex == 0) { Prop.DisplayName = Unquoted; } - else if (StringArgIndex == 1) { Prop.Group = Unquoted; } - ++StringArgIndex; + S.Error(TEXT("StaticBool `#define` is missing a name")); + return; } - else if (Arg.StartsWith(TEXT("Range"))) + // Remainder of the line is the value (1/0/true/false). + FString ValueLine; + while (!S.IsEnd() && S.Peek() != TEXT('\n')) { - FString RangeInner; - const int32 RO = Arg.Find(TEXT("(")); - const int32 RC = Arg.Find(TEXT(")"), ESearchCase::CaseSensitive, ESearchDir::FromEnd); - if (RO == INDEX_NONE || RC == INDEX_NONE || RC <= RO) + ValueLine.AppendChar(S.Advance()); + } + ValueLine = ValueLine.TrimStartAndEnd(); + Prop.Name = FName(*Name); + Prop.Type = EShaderLabPropertyType::StaticBool; + if (!ParseBool(ValueLine, Prop.bStaticBoolDefault)) + { + S.Error(FString::Printf(TEXT("StaticBool '%s' default must be 0/1 or true/false"), *Name)); + return; + } + } + else + { + FString TypeTok; + if (!S.ReadIdentifier(TypeTok)) + { + S.Error(TEXT("SL_PROPERTY must be followed by an HLSL declaration")); + return; + } + 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)); + return; + } + FString Name; + if (!S.ReadIdentifier(Name)) + { + S.Error(FString::Printf(TEXT("Property of type '%s' is missing a name"), *TypeTok)); + return; + } + Prop.Name = FName(*Name); + + const bool bIsTexture = (Prop.Type == EShaderLabPropertyType::Texture2D || Prop.Type == EShaderLabPropertyType::TextureCube); + if (bIsTexture) + { + // `Texture2D Name;` (no initializer) optionally followed by `SamplerState NameSampler;`. + if (!S.Expect(TEXT(';'), TEXT("after a texture property"))) { - S.Error(FString::Printf(TEXT("Property '%s' has malformed Range(...)"), *NameToken)); return; } - RangeInner = Arg.Mid(RO + 1, RC - RO - 1); - const TArray RangeComps = SplitTopLevel(RangeInner, TEXT(',')); - if (RangeComps.Num() != 2) + S.SkipTrivia(); + const TCHAR* Save = S.Ptr; + const int32 SaveLine = S.Line, SaveCol = S.Column; + FString Maybe; + if (S.ReadIdentifier(Maybe) && Maybe == TEXT("SamplerState")) { - S.Error(FString::Printf(TEXT("Property '%s' Range expects (min,max)"), *NameToken)); - return; + FString SamplerName; + S.ReadIdentifier(SamplerName); + S.Expect(TEXT(';'), TEXT("after a SamplerState declaration")); + } + else + { + // Not a sampler — rewind so the main loop sees this construct. + S.Ptr = Save; S.Line = SaveLine; S.Column = SaveCol; } - Prop.bHasRange = true; - Prop.RangeMin = FCString::Atof(*RangeComps[0]); - Prop.RangeMax = FCString::Atof(*RangeComps[1]); } else { - S.Error(FString::Printf(TEXT("Property '%s' has unknown metadata '%s'"), *NameToken, *Arg)); - return; + // Scalar/Color/Vector: read the `= ;` for the default value. + if (S.Match(TEXT('='))) + { + FString Init; + S.ReadUntilSemicolon(Init); + switch (Prop.Type) + { + case EShaderLabPropertyType::Scalar: + Prop.ScalarDefault = FCString::Atof(*Init); + break; + case EShaderLabPropertyType::Color: + case EShaderLabPropertyType::Vector: + if (!ParseVectorInitializer(Init, Prop.VectorDefault)) + { + S.Error(FString::Printf(TEXT("Property '%s' default must be floatN(...)"), *Name)); + return; + } + break; + default: + break; + } + } + if (!S.Expect(TEXT(';'), TEXT("after a property declaration"))) + { + return; + } } } - if (Prop.DisplayName.IsEmpty()) - { - Prop.DisplayName = NameToken; - } - // Default value after '='. - const FString AfterParen = Trimmed.Mid(ParenClose + 1).TrimStartAndEnd(); - if (!AfterParen.StartsWith(TEXT("="))) + // Specifiers (override SortPriority/DisplayName/etc.). + for (const FString& Arg : SplitTopLevel(SpecInner, TEXT(','))) { - S.Error(FString::Printf(TEXT("Property '%s' is missing '= default'"), *NameToken)); - return; - } - const FString DefaultRaw = AfterParen.Mid(1).TrimStartAndEnd(); - - switch (Type) - { - case EShaderLabPropertyType::Scalar: - Prop.ScalarDefault = FCString::Atof(*DefaultRaw); - break; - case EShaderLabPropertyType::Color: - case EShaderLabPropertyType::Vector: - if (!ParseColorLiteral(DefaultRaw, Prop.VectorDefault)) - { - S.Error(FString::Printf(TEXT("Property '%s' default must be (r,g,b[,a])"), *NameToken)); - return; - } - break; - case EShaderLabPropertyType::Texture2D: - case EShaderLabPropertyType::TextureCube: - if (!DefaultRaw.StartsWith(TEXT("\""))) - { - S.Error(FString::Printf(TEXT("Property '%s' default must be a quoted texture token"), *NameToken)); - return; - } - Prop.TextureDefault = DefaultRaw.TrimQuotes(); - break; - case EShaderLabPropertyType::StaticBool: - if (!ParseBool(DefaultRaw, Prop.bStaticBoolDefault)) - { - S.Error(FString::Printf(TEXT("Property '%s' default must be true/false"), *NameToken)); - return; - } - break; - } - - Prop.SortPriority = Model.Properties.Num(); - if (Model.FindProperty(Prop.Name) != nullptr) - { - S.Error(FString::Printf(TEXT("Duplicate property name '%s'"), *NameToken)); - return; - } - Model.Properties.Add(MoveTemp(Prop)); - } - - void ParseProperties(FScanner& S, FShaderLabModel& Model) - { - FString Inner; - if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Inner)) - { - return; - } - for (const FString& StmtRaw : SplitTopLevel(StripComments(Inner), TEXT(';'))) - { - const FString Stmt = StmtRaw.TrimStartAndEnd(); - if (Stmt.IsEmpty()) - { - continue; - } - ParseOnePropertyDecl(S, Stmt, Model); + ApplyPropertySpecifier(S, Arg, Prop); if (S.bFailed) { return; } } - } - - void ParseIncludes(FScanner& S, FShaderLabModel& Model) - { - FString Inner; - if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Inner)) + if (Prop.DisplayName.IsEmpty()) { + Prop.DisplayName = Prop.Name.ToString(); + } + if (Model.FindProperty(Prop.Name) != nullptr) + { + S.Error(FString::Printf(TEXT("Duplicate property name '%s'"), *Prop.Name.ToString())); return; } - // Accept whitespace/comma/semicolon separated quoted strings. - FScanner Sub(Inner, S.Errors); - while (true) + Model.Properties.Add(MoveTemp(Prop)); + } + + void ParseInclude(FScanner& S, FShaderLabModel& Model) + { + // At a '#include' directive; '#' already consumed, 'include' identifier already read. + FString Path; + if (!S.ReadString(Path)) + { + // Tolerate angle-bracket includes by skipping the line; ShaderLab uses quoted virtual paths. + S.SkipToEndOfLine(); + return; + } + if (!IsAuthoringShimInclude(Path)) { - Sub.SkipTrivia(); - if (Sub.IsEnd()) - { - break; - } - if (Sub.Peek() == TEXT(',') || Sub.Peek() == TEXT(';')) - { - Sub.Advance(); - continue; - } - FString Path; - if (!Sub.ReadString(Path)) - { - S.Error(TEXT("Includes block expects quoted paths")); - return; - } Model.Includes.Add(Path); } } - void ParseSlab(FScanner& S, FShaderLabModel& Model) + void ParseEntry(FScanner& S, FShaderLabModel& Model, const FString& Marker) { - FString Name; - if (!S.ReadIdentifier(Name)) + // Marker macro takes no args: SL_SURFACE()/SL_POSTPROCESS()/SL_UI()/SL_VERTEX(). + FString Ignored; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), Ignored)) { - S.Error(TEXT("Slab must be followed by a name")); return; } - FString SigInner; - if (!S.ReadBalanced(TEXT('('), TEXT(')'), SigInner)) + FString Name, SigInner, Body; + int32 BodyLine = 0; + if (!ParseAnnotatedFunction(S, Name, SigInner, Body, BodyLine)) + { + return; + } + TArray Params; + if (!ParseEntryParams(SigInner, Params)) + { + S.Error(FString::Printf(TEXT("%s has a malformed parameter signature"), *Marker)); + return; + } + + if (Marker == TEXT("SL_VERTEX")) + { + Model.VertexParams = MoveTemp(Params); + Model.VertexBody = MoveTemp(Body); + Model.VertexBodyLine = BodyLine; + Model.bHasVertex = true; + return; + } + + if (Model.bHasSurface) + { + S.Error(TEXT("A shader may declare only one pixel entry (SL_SURFACE/SL_POSTPROCESS/SL_UI)")); + return; + } + Model.SurfaceParams = MoveTemp(Params); + Model.SurfaceBody = MoveTemp(Body); + Model.SurfaceBodyLine = BodyLine; + Model.bHasSurface = true; + Model.SurfaceEntry = (Marker == TEXT("SL_POSTPROCESS")) ? EShaderLabEntry::PostProcess + : (Marker == TEXT("SL_UI")) ? EShaderLabEntry::UI + : EShaderLabEntry::Surface; + } + + void ParseSlab(FScanner& S, FShaderLabModel& Model) + { + FString Ignored; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), Ignored)) // SL_SLAB() + { + return; + } + FString Name, SigInner, Body; + int32 BodyLine = 0; + if (!ParseAnnotatedFunction(S, Name, SigInner, Body, BodyLine)) { return; } @@ -743,12 +814,6 @@ namespace S.Error(FString::Printf(TEXT("Slab '%s' must take an (inout FShaderLabSurface) parameter"), *Name)); return; } - FString Body; - int32 BodyLine = 0; - if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Body, &BodyLine)) - { - return; - } FShaderLabSlab Slab; Slab.Name = FName(*Name); Slab.OutParamName = Params.Last().Name; @@ -759,20 +824,14 @@ namespace void ParseValue(FScanner& S, FShaderLabModel& Model) { - FString Name; - if (!S.ReadIdentifier(Name)) - { - S.Error(TEXT("Value must be followed by a name")); - return; - } - FString SigInner; - if (!S.ReadBalanced(TEXT('('), TEXT(')'), SigInner)) // signature is empty: Value Name() { ... } + FString Ignored; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), Ignored)) // SL_VALUE() { return; } - FString Body; + FString Name, SigInner, Body; int32 BodyLine = 0; - if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Body, &BodyLine)) + if (!ParseAnnotatedFunction(S, Name, SigInner, Body, BodyLine)) { return; } @@ -874,33 +933,35 @@ namespace void ParseFrontMaterial(FScanner& S, FShaderLabModel& Model) { - if (!S.Expect(TEXT('='), TEXT("after FrontMaterial"))) + // SL_FRONTMATERIAL( ) + FString Inner; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), Inner)) { return; } - Model.TopologyRoot = ParseTopoExpr(S, Model); - if (S.bFailed) + FScanner Sub(Inner, S.Errors); + Model.TopologyRoot = ParseTopoExpr(Sub, Model); + if (Sub.bFailed) { - return; + S.bFailed = true; } - S.Expect(TEXT(';'), TEXT("after FrontMaterial expression")); } - /** Parse `Opacity = ;` / `OpacityMask = ;` (material-level outputs). */ + /** SL_OPACITY(Name) / SL_OPACITY_MASK(Name): material-level outputs referencing a Value block. */ void ParseMaterialOutput(FScanner& S, FName& OutValueName, const TCHAR* What) { - if (!S.Expect(TEXT('='), What)) + FString Inner; + if (!S.ReadBalanced(TEXT('('), TEXT(')'), Inner)) { return; } - FString Name; - if (!S.ReadIdentifier(Name)) + const FString Name = Inner.TrimStartAndEnd(); + if (Name.IsEmpty()) { S.Error(FString::Printf(TEXT("%s expects a Value block name"), What)); return; } OutValueName = FName(*Name); - S.Expect(TEXT(';'), What); } } @@ -913,7 +974,7 @@ bool FShaderLabParser::Parse( OutModel = FShaderLabModel(); OutModel.SourceFilePath = SourceName; // Identity + display name come from the file name (e.g. "Basic.usl" -> "Basic"); the file IS the - // shader, so there is no top-level `shader "Name" { }` wrapper — sections are parsed flat to EOF. + // shader. Annotated-HLSL constructs are parsed flat to EOF in any order. OutModel.ShaderName = FPaths::GetBaseFilename(SourceName); FScanner S(Source, OutErrors); @@ -923,94 +984,68 @@ bool FShaderLabParser::Parse( S.SkipTrivia(); if (S.IsEnd()) { - break; // End of file: all sections parsed. + break; // End of file: all constructs parsed. } - FString Section; - if (!S.ReadIdentifier(Section)) + // Top-level preprocessor lines: `#include "..."` feeds Model.Includes; everything else + // (`#pragma`, stray `#define`, etc.) is non-DSL and skipped. + if (S.Peek() == TEXT('#')) { - S.Error(FString::Printf(TEXT("Expected a section keyword, found '%c'"), S.Peek())); - return false; - } - - if (Section == TEXT("Settings")) - { - ParseSettings(S, OutModel); - } - else if (Section == TEXT("Properties")) - { - ParseProperties(S, OutModel); - } - else if (Section == TEXT("Includes")) - { - ParseIncludes(S, OutModel); - } - else if (Section == TEXT("Slab")) - { - ParseSlab(S, OutModel); - } - else if (Section == TEXT("Value")) - { - ParseValue(S, OutModel); - } - else if (Section == TEXT("FrontMaterial")) - { - ParseFrontMaterial(S, OutModel); - } - else if (Section == TEXT("Opacity")) - { - ParseMaterialOutput(S, OutModel.OpacityValueName, TEXT("Opacity")); - } - else if (Section == TEXT("OpacityMask")) - { - ParseMaterialOutput(S, OutModel.OpacityMaskValueName, TEXT("OpacityMask")); - } - else if (Section == TEXT("Surface") || Section == TEXT("PostProcess") || Section == TEXT("UI") || Section == TEXT("Vertex")) - { - const bool bIsSurface = (Section != TEXT("Vertex")); - FString SigInner; - if (!S.ReadBalanced(TEXT('('), TEXT(')'), SigInner)) + S.Advance(); // '#' + FString Directive; + if (S.ReadIdentifier(Directive) && Directive == TEXT("include")) { - return false; - } - TArray Params; - if (!ParseEntryParams(SigInner, Params)) - { - S.Error(FString::Printf(TEXT("%s has a malformed parameter signature"), *Section)); - return false; - } - FString Body; - int32 BodyLine = 0; - if (!S.ReadBalanced(TEXT('{'), TEXT('}'), Body, &BodyLine)) - { - return false; - } - if (bIsSurface) - { - if (OutModel.bHasSurface) - { - S.Error(TEXT("A shader may declare only one pixel entry (Surface/PostProcess/UI)")); - return false; - } - OutModel.SurfaceParams = MoveTemp(Params); - OutModel.SurfaceBody = MoveTemp(Body); - OutModel.SurfaceBodyLine = BodyLine; - OutModel.bHasSurface = true; - OutModel.SurfaceEntry = (Section == TEXT("PostProcess")) ? EShaderLabEntry::PostProcess - : (Section == TEXT("UI")) ? EShaderLabEntry::UI - : EShaderLabEntry::Surface; + ParseInclude(S, OutModel); } else { - OutModel.VertexParams = MoveTemp(Params); - OutModel.VertexBody = MoveTemp(Body); - OutModel.VertexBodyLine = BodyLine; - OutModel.bHasVertex = true; + S.SkipToEndOfLine(); } + continue; + } + + FString Token; + if (!S.ReadIdentifier(Token)) + { + S.Error(FString::Printf(TEXT("Expected a ShaderLab macro or #include, found '%c'"), S.Peek())); + return false; + } + + if (Token == TEXT("SL_SETTINGS")) + { + ParseSettings(S, OutModel); + } + else if (Token == TEXT("SL_PROPERTY")) + { + ParseProperty(S, OutModel); + } + else if (Token == TEXT("SL_SURFACE") || Token == TEXT("SL_POSTPROCESS") || Token == TEXT("SL_UI") || Token == TEXT("SL_VERTEX")) + { + ParseEntry(S, OutModel, Token); + } + else if (Token == TEXT("SL_SLAB")) + { + ParseSlab(S, OutModel); + } + else if (Token == TEXT("SL_VALUE")) + { + ParseValue(S, OutModel); + } + else if (Token == TEXT("SL_FRONTMATERIAL")) + { + ParseFrontMaterial(S, OutModel); + } + else if (Token == TEXT("SL_OPACITY")) + { + ParseMaterialOutput(S, OutModel.OpacityValueName, TEXT("SL_OPACITY")); + } + else if (Token == TEXT("SL_OPACITY_MASK")) + { + ParseMaterialOutput(S, OutModel.OpacityMaskValueName, TEXT("SL_OPACITY_MASK")); } else { - S.Error(FString::Printf(TEXT("Unknown section '%s'"), *Section)); + S.Error(FString::Printf(TEXT("Unexpected token '%s' (expected a SL_ macro or #include)"), *Token)); return false; } } @@ -1024,24 +1059,24 @@ bool FShaderLabParser::Parse( const bool bHasMultiSlab = OutModel.Slabs.Num() > 0 || OutModel.TopologyRoot != INDEX_NONE; if (OutModel.bHasSurface && bHasMultiSlab) { - S.Error(TEXT("Use either a single Surface(...) or named Slab blocks with FrontMaterial, not both")); + S.Error(TEXT("Use either a single SL_SURFACE(...) or SL_SLAB blocks with SL_FRONTMATERIAL, not both")); return false; } if (!OutModel.bHasSurface && !bHasMultiSlab) { - S.Error(TEXT("Shader must declare a Surface(...) entry point, or Slab blocks with a FrontMaterial expression")); + S.Error(TEXT("Shader must declare a SL_SURFACE(...) entry point, or SL_SLAB blocks with a SL_FRONTMATERIAL expression")); return false; } if (bHasMultiSlab) { if (OutModel.Slabs.Num() == 0) { - S.Error(TEXT("FrontMaterial requires at least one Slab block")); + S.Error(TEXT("SL_FRONTMATERIAL requires at least one SL_SLAB block")); return false; } if (OutModel.TopologyRoot == INDEX_NONE) { - S.Error(TEXT("Slab blocks require a 'FrontMaterial = ...;' topology expression")); + S.Error(TEXT("SL_SLAB blocks require a SL_FRONTMATERIAL(...) topology expression")); return false; } } @@ -1051,18 +1086,18 @@ bool FShaderLabParser::Parse( const EShaderLabDomain Domain = OutModel.Settings.Domain; if (Domain == EShaderLabDomain::PostProcess && !(OutModel.bHasSurface && OutModel.SurfaceEntry == EShaderLabEntry::PostProcess)) { - S.Error(TEXT("Domain = PostProcess requires a PostProcess(inout FShaderLabPostProcess) entry")); + S.Error(TEXT("Domain = PostProcess requires a SL_POSTPROCESS(inout FShaderLabPostProcess) entry")); return false; } if (Domain == EShaderLabDomain::UI && !(OutModel.bHasSurface && OutModel.SurfaceEntry == EShaderLabEntry::UI)) { - S.Error(TEXT("Domain = UI requires a UI(inout FShaderLabUI) entry")); + S.Error(TEXT("Domain = UI requires a SL_UI(inout FShaderLabUI) entry")); return false; } if ((Domain == EShaderLabDomain::Surface || Domain == EShaderLabDomain::Decal) && OutModel.bHasSurface && OutModel.SurfaceEntry != EShaderLabEntry::Surface) { - S.Error(TEXT("Surface/Decal domains require a Surface entry (or Slab blocks), not PostProcess/UI")); + S.Error(TEXT("Surface/Decal domains require a SL_SURFACE entry (or SL_SLAB blocks), not PostProcess/UI")); return false; } diff --git a/Source/UShaderLabEditor/Private/ShaderLabIDEPrepare.cpp b/Source/UShaderLabEditor/Private/ShaderLabIDEPrepare.cpp new file mode 100644 index 0000000..353fad0 --- /dev/null +++ b/Source/UShaderLabEditor/Private/ShaderLabIDEPrepare.cpp @@ -0,0 +1,285 @@ +// Copyright FlecsProj. All Rights Reserved. +// +// `ShaderLab.IDE.Prepare` console command — generates the IDE authoring assets so a `.usl` file +// (which is plain HLSL plus SL_* annotation macros) gets full member/function/include completion +// from Rider's UE shader support or VSCode + HLSL Tools. It writes, all from a single source of truth: +// 1. /Shaders/ShaderLab.ush — umbrella header: DSL macros + structs + `namespace UE` +// intrinsic stubs generated from FShaderLabIntrinsicRegistry. +// 2. /shadertoolsconfig.json — HLSL Tools virtual-directory mappings, dumped from +// AllShaderSourceDirectoryMappings() (the same table the +// runtime registers). Machine-specific → gitignored. +// 3. /.vscode/settings.json — merges *.usl/*.usf/*.ush -> hlsl into files.associations +// (non-destructive: existing keys are preserved). + +#include "CoreMinimal.h" + +#include "Dom/JsonObject.h" +#include "HAL/IConsoleManager.h" +#include "Interfaces/IPluginManager.h" +#include "Misc/FileHelper.h" +#include "Misc/Paths.h" +#include "Serialization/JsonReader.h" +#include "Serialization/JsonSerializer.h" +#include "Serialization/JsonWriter.h" +#include "ShaderCore.h" +#include "ShaderLabIntrinsicRegistry.h" + +DEFINE_LOG_CATEGORY_STATIC(LogShaderLabIDE, Log, All); + +namespace ShaderLabIDEPrepare_Private +{ + // Strip line and block comments (string-aware) so JSONC settings.json parses as JSON. + static FString StripJsonComments(const FString& In) + { + FString Out; + Out.Reserve(In.Len()); + const int32 N = In.Len(); + for (int32 i = 0; i < N;) + { + const TCHAR C = In[i]; + if (C == TEXT('"')) + { + Out.AppendChar(C); + ++i; + while (i < N) + { + const TCHAR D = In[i]; + Out.AppendChar(D); + ++i; + if (D == TEXT('\\') && i < N) { Out.AppendChar(In[i]); ++i; } + else if (D == TEXT('"')) { break; } + } + continue; + } + if (C == TEXT('/') && i + 1 < N && In[i + 1] == TEXT('/')) + { + while (i < N && In[i] != TEXT('\n')) { ++i; } + continue; + } + if (C == TEXT('/') && i + 1 < N && In[i + 1] == TEXT('*')) + { + i += 2; + while (i + 1 < N && !(In[i] == TEXT('*') && In[i + 1] == TEXT('/'))) { ++i; } + i += 2; + continue; + } + Out.AppendChar(C); + ++i; + } + return Out; + } + + // Remove trailing commas before `}`/`]` (string-aware) so VSCode-style JSONC parses as strict JSON. + static FString StripTrailingCommas(const FString& In) + { + FString Out; + Out.Reserve(In.Len()); + const int32 N = In.Len(); + for (int32 i = 0; i < N; ++i) + { + const TCHAR C = In[i]; + if (C == TEXT('"')) + { + Out.AppendChar(C); + for (++i; i < N; ++i) + { + const TCHAR D = In[i]; + Out.AppendChar(D); + if (D == TEXT('\\') && i + 1 < N) { Out.AppendChar(In[++i]); } + else if (D == TEXT('"')) { break; } + } + continue; + } + if (C == TEXT(',')) + { + int32 j = i + 1; + while (j < N && FChar::IsWhitespace(In[j])) { ++j; } + if (j < N && (In[j] == TEXT('}') || In[j] == TEXT(']'))) + { + continue; // drop the trailing comma + } + } + Out.AppendChar(C); + } + return Out; + } + + /** Build the `namespace UE { ... }` block of stub declarations from the registry (sorted, deterministic). */ + static FString GenerateIntrinsicNamespace() + { + TArray Descs; + FShaderLabIntrinsicRegistry::Get().ForEach([&Descs](const FShaderLabIntrinsicDesc& D) { Descs.Add(D); }); + Descs.Sort([](const FShaderLabIntrinsicDesc& A, const FShaderLabIntrinsicDesc& B) + { + return A.Name.LexicalLess(B.Name); + }); + + FString Body; + Body += TEXT("namespace UE\n{\n"); + for (const FShaderLabIntrinsicDesc& D : Descs) + { + if (!D.Doc.IsEmpty()) + { + Body += FString::Printf(TEXT("\t// %s\n"), *D.Doc); + } + FString Params; + for (int32 i = 0; i < D.Params.Num(); ++i) + { + const FShaderLabIntrinsicParam& P = D.Params[i]; + if (i > 0) { Params += TEXT(", "); } + Params += FString::Printf(TEXT("%s %s"), *P.Type, *P.Name); + if (!P.DefaultLiteral.IsEmpty()) { Params += FString::Printf(TEXT(" = %s"), *P.DefaultLiteral); } + } + // Stub body returns a zero of the return type; this is authoring-only and never compiled + // (the graph builder rewrites UE:: calls into material-expression inputs before compile). + Body += FString::Printf(TEXT("\t%s %s(%s) { return (%s)0; }\n"), + *D.ReturnType, *D.Name.ToString(), *Params, *D.ReturnType); + } + Body += TEXT("}\n"); + return Body; + } + + static bool WriteIfChanged(const FString& Path, const FString& Content) + { + FString Existing; + if (FFileHelper::LoadFileToString(Existing, *Path) && Existing.Equals(Content, ESearchCase::CaseSensitive)) + { + UE_LOG(LogShaderLabIDE, Log, TEXT(" unchanged: %s"), *Path); + return true; + } + // UTF-8 (no BOM) so HLSL Tools / Rider and JSON parsers read the generated files reliably + // (FFileHelper defaults to UTF-16, which not every shader/JSON tool handles). + if (!FFileHelper::SaveStringToFile(Content, *Path, FFileHelper::EEncodingOptions::ForceUTF8WithoutBOM)) + { + UE_LOG(LogShaderLabIDE, Error, TEXT(" FAILED to write: %s"), *Path); + return false; + } + UE_LOG(LogShaderLabIDE, Log, TEXT(" wrote: %s"), *Path); + return true; + } + + static void GenerateShaderLabHeader(const FString& PluginShaderDir) + { + FString H; + H += TEXT("// GENERATED by `ShaderLab.IDE.Prepare` — do not edit by hand.\n"); + H += TEXT("// Authoring umbrella header: include this from every .usl for IDE completion.\n"); + H += TEXT("// At real shader-compile time this header is NOT included (the parser strips it);\n"); + H += TEXT("// the UE:: stubs below are for the editor only.\n"); + H += TEXT("#pragma once\n\n"); + H += TEXT("#include \"/Plugin/ShaderLab/ShaderLabDSL.ush\"\n"); + H += TEXT("#include \"/Plugin/ShaderLab/Private/ShaderLabCommon.ush\"\n\n"); + H += GenerateIntrinsicNamespace(); + WriteIfChanged(FPaths::Combine(PluginShaderDir, TEXT("ShaderLab.ush")), H); + } + + static void GenerateShaderToolsConfig(const FString& ProjectDir) + { + const TMap& Mappings = AllShaderSourceDirectoryMappings(); + + const TSharedRef Root = MakeShared(); + Root->SetBoolField(TEXT("root"), true); + const TSharedRef Virt = MakeShared(); + for (const TPair& Pair : Mappings) + { + // Engine mappings come back relative to the engine binaries dir; HLSL Tools needs + // absolute (or config-relative) paths, so normalize to full paths. + Virt->SetStringField(Pair.Key, FPaths::ConvertRelativePathToFull(Pair.Value)); + } + Root->SetObjectField(TEXT("hlsl.virtualDirectoryMappings"), Virt); + + FString Out; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Root, Writer); + WriteIfChanged(FPaths::Combine(ProjectDir, TEXT("shadertoolsconfig.json")), Out); + } + + static void MergeVSCodeSettings(const FString& ProjectDir) + { + const FString SettingsPath = FPaths::Combine(ProjectDir, TEXT(".vscode"), TEXT("settings.json")); + + TSharedPtr Settings; + FString Existing; + if (FFileHelper::LoadFileToString(Existing, *SettingsPath)) + { + const TSharedRef> Reader = TJsonReaderFactory<>::Create(StripTrailingCommas(StripJsonComments(Existing))); + if (!FJsonSerializer::Deserialize(Reader, Settings) || !Settings.IsValid()) + { + UE_LOG(LogShaderLabIDE, Warning, + TEXT(" could not parse %s — add files.associations for *.usl/*.usf/*.ush -> hlsl manually."), + *SettingsPath); + return; // Never clobber an unparseable user file. + } + } + else + { + Settings = MakeShared(); + } + + // Union into files.associations, preserving any existing entries. Only rewrite the file when + // we actually add a mapping, so a hand-maintained settings.json (comments, formatting) is left + // untouched once the associations are present. + const TSharedPtr* AssocPtr = nullptr; + TSharedPtr Assoc = Settings->TryGetObjectField(TEXT("files.associations"), AssocPtr) + ? *AssocPtr : MakeShared(); + bool bChanged = false; + for (const TCHAR* Ext : { TEXT("*.usl"), TEXT("*.usf"), TEXT("*.ush") }) + { + FString Existing2; + if (!Assoc->TryGetStringField(Ext, Existing2) || Existing2 != TEXT("hlsl")) + { + Assoc->SetStringField(Ext, TEXT("hlsl")); + bChanged = true; + } + } + if (!bChanged) + { + UE_LOG(LogShaderLabIDE, Log, TEXT(" unchanged: %s"), *SettingsPath); + return; + } + Settings->SetObjectField(TEXT("files.associations"), Assoc); + + FString Out; + const TSharedRef> Writer = TJsonWriterFactory<>::Create(&Out); + FJsonSerializer::Serialize(Settings.ToSharedRef(), Writer); + WriteIfChanged(SettingsPath, Out); + } + + static void EnsureGitIgnore(const FString& ProjectDir) + { + const FString Path = FPaths::Combine(ProjectDir, TEXT(".gitignore")); + FString GI; + FFileHelper::LoadFileToString(GI, *Path); // missing file -> empty, fine + if (GI.Contains(TEXT("shadertoolsconfig.json"))) + { + return; + } + if (!GI.IsEmpty() && !GI.EndsWith(TEXT("\n"))) + { + GI += TEXT("\n"); + } + GI += TEXT("# Machine-specific HLSL Tools config (generated by ShaderLab.IDE.Prepare).\n"); + GI += TEXT("shadertoolsconfig.json\n"); + WriteIfChanged(Path, GI); + } + + static void Run(const TArray& /*Args*/) + { + const TSharedPtr Plugin = IPluginManager::Get().FindPlugin(TEXT("UShaderLab")); + check(Plugin.IsValid()); // The command lives in this plugin's editor module; it must be findable. + + const FString PluginShaderDir = FPaths::Combine(Plugin->GetBaseDir(), TEXT("Shaders")); + const FString ProjectDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir()); + + UE_LOG(LogShaderLabIDE, Log, TEXT("ShaderLab.IDE.Prepare:")); + GenerateShaderLabHeader(PluginShaderDir); + GenerateShaderToolsConfig(ProjectDir); + MergeVSCodeSettings(ProjectDir); + EnsureGitIgnore(ProjectDir); + UE_LOG(LogShaderLabIDE, Log, TEXT("ShaderLab.IDE.Prepare: done.")); + } +} + +static FAutoConsoleCommand GShaderLabIDEPrepareCommand( + TEXT("ShaderLab.IDE.Prepare"), + TEXT("Generate IDE completion assets for .usl: /Shaders/ShaderLab.ush, shadertoolsconfig.json, .vscode/settings.json."), + FConsoleCommandWithArgsDelegate::CreateStatic(&ShaderLabIDEPrepare_Private::Run)); diff --git a/Source/UShaderLabEditor/Private/ShaderLabIntrinsicRegistry.cpp b/Source/UShaderLabEditor/Private/ShaderLabIntrinsicRegistry.cpp index a58fa02..d1530e4 100644 --- a/Source/UShaderLabEditor/Private/ShaderLabIntrinsicRegistry.cpp +++ b/Source/UShaderLabEditor/Private/ShaderLabIntrinsicRegistry.cpp @@ -63,6 +63,8 @@ void FShaderLabIntrinsicRegistry::Register(FShaderLabIntrinsicDesc Desc) { check(!Desc.Name.IsNone()); check(Desc.MakeNode); + // Contract: every intrinsic is IDE-describable so ShaderLab.IDE.Prepare can always emit a stub. + check(!Desc.ReturnType.IsEmpty()); Descs.Add(Desc.Name, MoveTemp(Desc)); } @@ -71,35 +73,49 @@ const FShaderLabIntrinsicDesc* FShaderLabIntrinsicRegistry::Find(FName Name) con return Descs.Find(Name); } +void FShaderLabIntrinsicRegistry::ForEach(TFunctionRef Fn) const +{ + for (const TPair& Pair : Descs) + { + Fn(Pair.Value); + } +} + void FShaderLabIntrinsicRegistry::RegisterBuiltins() { using namespace ShaderLabIntrinsic_Private; using EFreq = EShaderLabIntrinsicFrequency; - // Helper for the common no-arg case. - auto AddSimple = [this](const TCHAR* Name, EFreq Freq, TFunction Make) + // Helper for the common no-arg case (ReturnType drives the generated authoring stub). + auto AddSimple = [this](const TCHAR* Name, EFreq Freq, const TCHAR* ReturnType, TFunction Make) { FShaderLabIntrinsicDesc Desc; Desc.Name = FName(Name); Desc.Frequency = Freq; + Desc.ReturnType = ReturnType; Desc.MakeNode = [Make = MoveTemp(Make)](UMaterial& M, const TArray&, FString&) { return Make(M); }; Register(MoveTemp(Desc)); }; // --- A. Mesh / surface attributes --- - AddSimple(TEXT("VertexNormalWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("VertexTangentWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("PixelNormalWS"), EFreq::PixelOnly, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("VertexColor"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("TwoSidedSign"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("LightmapUVs"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("PreSkinnedNormal"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("PreSkinnedPosition"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("VertexNormalWS"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("VertexTangentWS"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PixelNormalWS"), EFreq::PixelOnly, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("VertexColor"), EFreq::Any, TEXT("float4"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("TwoSidedSign"), EFreq::Any, TEXT("float"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("LightmapUVs"), EFreq::Any, TEXT("float2"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PreSkinnedNormal"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PreSkinnedPosition"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); // TextureCoordinate(Index[, UTiling, VTiling]) — const args configure the node (mechanism: scalar/int args). { FShaderLabIntrinsicDesc Desc; Desc.Name = FName(TEXT("TextureCoordinate")); + Desc.ReturnType = TEXT("float2"); + Desc.Params = { { TEXT("int"), TEXT("Index"), TEXT("") }, + { TEXT("float"), TEXT("UTiling"), TEXT("1") }, + { TEXT("float"), TEXT("VTiling"), TEXT("1") } }; + Desc.Doc = TEXT("UV channel `Index`, optionally tiled."); Desc.MakeNode = [](UMaterial& M, const TArray& Args, FString&) -> UMaterialExpression* { UMaterialExpressionTextureCoordinate* E = NewObject(&M); @@ -113,31 +129,34 @@ void FShaderLabIntrinsicRegistry::RegisterBuiltins() } // --- B. Positions / spaces --- - AddSimple(TEXT("WorldPosition"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("LocalPosition"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("ActorPositionWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("ObjectPositionWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("CameraPositionWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("CameraVectorWS"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("LightVector"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("PixelDepth"), EFreq::PixelOnly, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("ScreenPosition"), EFreq::PixelOnly, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("WorldPosition"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("LocalPosition"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ActorPositionWS"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ObjectPositionWS"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("CameraPositionWS"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("CameraVectorWS"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("LightVector"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PixelDepth"), EFreq::PixelOnly, TEXT("float"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ScreenPosition"), EFreq::PixelOnly, TEXT("float2"), [](UMaterial& M) { return MakeSimple(M); }); // --- C. Object / bounds (ObjectLocalBounds & PreSkinnedLocalBounds are multi-output; output 0) --- - AddSimple(TEXT("ObjectRadius"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("ObjectBounds"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("ObjectLocalBounds"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("ObjectOrientation"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("PreSkinnedLocalBounds"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ObjectRadius"), EFreq::Any, TEXT("float"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ObjectBounds"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ObjectLocalBounds"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("ObjectOrientation"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PreSkinnedLocalBounds"), EFreq::Any, TEXT("float3"), [](UMaterial& M) { return MakeSimple(M); }); // --- D. Instancing --- - AddSimple(TEXT("PerInstanceRandom"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("PerInstanceFadeAmount"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PerInstanceRandom"), EFreq::Any, TEXT("float"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("PerInstanceFadeAmount"), EFreq::Any, TEXT("float"), [](UMaterial& M) { return MakeSimple(M); }); // PerInstanceCustomData(DataIndex[, ConstDefaultValue]) — mechanism: const args + optional input pin left at default. { FShaderLabIntrinsicDesc Desc; Desc.Name = FName(TEXT("PerInstanceCustomData")); + Desc.ReturnType = TEXT("float"); + Desc.Params = { { TEXT("int"), TEXT("DataIndex"), TEXT("") }, + { TEXT("float"), TEXT("ConstDefaultValue"), TEXT("0") } }; Desc.MakeNode = [](UMaterial& M, const TArray& Args, FString&) -> UMaterialExpression* { UMaterialExpressionPerInstanceCustomData* E = NewObject(&M); @@ -150,13 +169,16 @@ void FShaderLabIntrinsicRegistry::RegisterBuiltins() } // --- Time --- - AddSimple(TEXT("Time"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); - AddSimple(TEXT("DeltaTime"), EFreq::Any, [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("Time"), EFreq::Any, TEXT("float"), [](UMaterial& M) { return MakeSimple(M); }); + AddSimple(TEXT("DeltaTime"), EFreq::Any, TEXT("float"), [](UMaterial& M) { return MakeSimple(M); }); // ViewProperty(Property) — representative of the enum-by-name const-arg mechanism. { FShaderLabIntrinsicDesc Desc; Desc.Name = FName(TEXT("ViewProperty")); + Desc.ReturnType = TEXT("float4"); + Desc.Params = { { TEXT("int"), TEXT("Property"), TEXT("") } }; + Desc.Doc = TEXT("Property is an EMaterialExposedViewProperty token (e.g. MEVP_FieldOfView)."); Desc.MakeNode = [](UMaterial& M, const TArray& Args, FString& OutError) -> UMaterialExpression* { UMaterialExpressionViewProperty* E = NewObject(&M); @@ -183,6 +205,7 @@ void FShaderLabIntrinsicRegistry::RegisterBuiltins() { FShaderLabIntrinsicDesc Desc; Desc.Name = FName(TEXT("ParticleColor")); + Desc.ReturnType = TEXT("float4"); Desc.MakeNode = [](UMaterial& M, const TArray&, FString&) { return MakeSimple(M); }; Register(MoveTemp(Desc)); } diff --git a/Source/UShaderLabEditor/Public/ShaderLabIntrinsicRegistry.h b/Source/UShaderLabEditor/Public/ShaderLabIntrinsicRegistry.h index 8a487b4..0f4222b 100644 --- a/Source/UShaderLabEditor/Public/ShaderLabIntrinsicRegistry.h +++ b/Source/UShaderLabEditor/Public/ShaderLabIntrinsicRegistry.h @@ -15,10 +15,25 @@ enum class EShaderLabIntrinsicFrequency : uint8 VertexOnly, }; +/** One formal parameter of an intrinsic, used only to generate the IDE authoring stub signature. */ +struct FShaderLabIntrinsicParam +{ + /** HLSL type of the parameter, e.g. "int", "float". */ + FString Type; + /** Parameter name shown in completion, e.g. "Index". */ + FString Name; + /** Default-value literal for the stub (e.g. "1"); empty means the parameter is required. */ + FString DefaultLiteral; +}; + /** * Describes one `UE::NodeName(...)` intrinsic: how to build the backing material expression node, * which of its outputs to read, and what it requires of the material. The Custom-node input type is * derived by the engine from the connected node's real output type, so no output type is declared here. + * + * The ReturnType/Params/Doc fields carry no runtime meaning — they exist so `ShaderLab.IDE.Prepare` + * can emit a `namespace UE { ... }` HLSL stub header from this single source of truth, giving the IDE + * member/signature completion for `UE::` calls without any drift from the real registration. */ struct FShaderLabIntrinsicDesc { @@ -30,6 +45,15 @@ struct FShaderLabIntrinsicDesc /** Stage restriction; misuse is a build error. */ EShaderLabIntrinsicFrequency Frequency = EShaderLabIntrinsicFrequency::Any; + /** HLSL return type for the authoring stub, e.g. "float2"/"float3"/"float". Required (see Register). */ + FString ReturnType; + + /** Formal parameters for the authoring stub (empty for the common no-arg intrinsics). */ + TArray Params; + + /** Optional one-line doc surfaced as a comment above the stub. */ + FString Doc; + /** * Create the backing expression node and add it to `Material`'s expression collection. `ConstArgs` * are the literal arguments parsed from the call (e.g. {"0","2.0"} for `UE::TextureCoordinate(0,2.0)`). @@ -55,6 +79,9 @@ public: /** Lookup by name; nullptr if unknown. */ const FShaderLabIntrinsicDesc* Find(FName Name) const; + /** Visit every registered intrinsic (used by ShaderLab.IDE.Prepare to generate the UE:: stub header). */ + void ForEach(TFunctionRef Fn) const; + private: FShaderLabIntrinsicRegistry(); void RegisterBuiltins(); diff --git a/Source/UShaderLabEditor/UShaderLabEditor.Build.cs b/Source/UShaderLabEditor/UShaderLabEditor.Build.cs index f7c4457..e0cba8b 100644 --- a/Source/UShaderLabEditor/UShaderLabEditor.Build.cs +++ b/Source/UShaderLabEditor/UShaderLabEditor.Build.cs @@ -22,6 +22,7 @@ public class UShaderLabEditor : ModuleRules "UShaderLab", "RenderCore", "RHI", + "Json", "UnrealEd", "AssetTools", "MaterialEditor",