diff --git a/Shaders/Private/ShaderLabUENode.ush b/Shaders/Private/ShaderLabUENode.ush index c5b2480..75726f2 100644 --- a/Shaders/Private/ShaderLabUENode.ush +++ b/Shaders/Private/ShaderLabUENode.ush @@ -45,6 +45,8 @@ float2 UE_DecalDerivativeDDY() { return (float2)0; } float UE_DecalLifetimeOpacity() { return (float)0; } float UE_DeltaTime() { return (float)0; } float UE_DistanceCullFade() { return (float)0; } +float3 UE_DistanceFieldGradient() { return (float3)0; } +float UE_DistanceToNearestSurface() { return (float)0; } float UE_EyeAdaptation() { return (float)0; } float UE_IsFirstPerson() { return (float)0; } float UE_IsOrthographic() { return (float)0; } diff --git a/Source/UShaderLab/Private/ShaderLabParser.cpp b/Source/UShaderLab/Private/ShaderLabParser.cpp index 181a6e3..bd08bd3 100644 --- a/Source/UShaderLab/Private/ShaderLabParser.cpp +++ b/Source/UShaderLab/Private/ShaderLabParser.cpp @@ -679,8 +679,9 @@ namespace } else { - // Long-tail setting: stored verbatim. Allowlist + type validation happens in - // FShaderLabSettingsApplier at build time (the parser is Engine-reflection-free). + // Long-tail setting: stored verbatim (dotted struct keys like `Struct.Field` included). + // Reflection resolution + type validation happens in FShaderLabSettingsApplier at build + // time (the parser is Engine-reflection-free). Model.RawSettings.Emplace(Key, Value); } } diff --git a/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp b/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp index 5842c8b..2b887e5 100644 --- a/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp +++ b/Source/UShaderLab/Private/ShaderLabSettingsApplier.cpp @@ -4,107 +4,123 @@ #include "Materials/Material.h" #include "Materials/MaterialInterface.h" +#include "Misc/StringOutputDevice.h" #include "UObject/Class.h" -#include "UObject/EnumProperty.h" #include "UObject/UnrealType.h" namespace ShaderLabSettings_Private { - // UMaterial properties a `.usl` Settings block may set by reflection. Names only — adding a - // setting is a one-line edit here. Every entry must be a non-editor-only property so the cooked - // runtime shell can apply the identical value. - const TSet& AllowedSettingNames() + // Resolve a (possibly dotted) key such as "DisplacementScaling.Magnitude" to the leaf property + the + // address of its value. OutTop is the top-level UMaterial property (used for the deny/editable checks). + // bOutNotFound = a segment does not exist (a typo at cook, or an editor-only member stripped from the + // cooked class at runtime) — distinguished from a structural error (dotting into a non-struct). + bool ResolveKeyPath(UMaterial& Material, const FString& Key, + FProperty*& OutTop, FProperty*& OutLeaf, void*& OutValuePtr, bool& bOutNotFound, FString& OutReason) { - static const TSet Names = { - FName(TEXT("OpacityMaskClipValue")), - FName(TEXT("TranslucencyLightingMode")), - FName(TEXT("TranslucencyPass")), - FName(TEXT("bCastDynamicShadowAsMasked")), - FName(TEXT("bEnableResponsiveAA")), - FName(TEXT("bScreenSpaceReflections")), - FName(TEXT("bContactShadows")), - FName(TEXT("bIsThinSurface")), - FName(TEXT("DitheredLODTransition")), - FName(TEXT("RefractionMethod")), - FName(TEXT("NumCustomizedUVs")), // caps how many CustomizedUV vertex outputs the material compiles - }; - return Names; - } + bOutNotFound = false; + OutTop = OutLeaf = nullptr; + OutValuePtr = nullptr; - bool ParseBool(const FString& In, bool& Out) - { - const FString V = In.TrimStartAndEnd(); - if (V == TEXT("true") || V == TEXT("1")) { Out = true; return true; } - if (V == TEXT("false") || V == TEXT("0")) { Out = false; return true; } - return false; - } + TArray Segments; + Key.ParseIntoArray(Segments, TEXT(".")); + if (Segments.Num() == 0) + { + OutReason = TEXT("empty setting key"); + return false; + } - // Coerce `Value` to `Prop`'s type and write it into `Material`. Returns false with a reason on - // type mismatch / unresolved enum token. - bool CoerceAndSet(UMaterial& Material, FProperty* Prop, const FString& Value, FString& OutReason) - { + FProperty* Prop = UMaterial::StaticClass()->FindPropertyByName(FName(*Segments[0])); + if (!Prop) + { + bOutNotFound = true; + return false; + } + OutTop = Prop; void* ValuePtr = Prop->ContainerPtrToValuePtr(&Material); - if (FBoolProperty* BoolProp = CastField(Prop)) + for (int32 i = 1; i < Segments.Num(); ++i) { - bool bVal = false; - if (!ParseBool(Value, bVal)) + FStructProperty* StructProp = CastField(Prop); + if (!StructProp) { - OutReason = TEXT("expected true/false"); + OutReason = FString::Printf(TEXT("'%s' is not a struct; cannot access sub-field '%s'"), *Segments[i - 1], *Segments[i]); return false; } - BoolProp->SetPropertyValue_InContainer(&Material, bVal); - return true; - } - if (FFloatProperty* FloatProp = CastField(Prop)) - { - FloatProp->SetPropertyValue(ValuePtr, FCString::Atof(*Value)); - return true; - } - if (FDoubleProperty* DoubleProp = CastField(Prop)) - { - DoubleProp->SetPropertyValue(ValuePtr, FCString::Atod(*Value)); - return true; - } - if (FIntProperty* IntProp = CastField(Prop)) - { - IntProp->SetPropertyValue(ValuePtr, FCString::Atoi(*Value)); - return true; - } - // TEnumAsByte reflects as FByteProperty with ->Enum set; resolve the bare token by name. - if (FByteProperty* ByteProp = CastField(Prop)) - { - if (UEnum* Enum = ByteProp->Enum) + FProperty* Sub = StructProp->Struct->FindPropertyByName(FName(*Segments[i])); + if (!Sub) { - const int64 EnumVal = Enum->GetValueByNameString(Value); - if (EnumVal == INDEX_NONE) - { - OutReason = FString::Printf(TEXT("unknown enum value '%s' for %s"), *Value, *Enum->GetName()); - return false; - } - ByteProp->SetPropertyValue(ValuePtr, static_cast(EnumVal)); - return true; - } - ByteProp->SetPropertyValue(ValuePtr, static_cast(FCString::Atoi(*Value))); - return true; - } - // enum class reflects as FEnumProperty. - if (FEnumProperty* EnumProp = CastField(Prop)) - { - UEnum* Enum = EnumProp->GetEnum(); - const int64 EnumVal = Enum ? Enum->GetValueByNameString(Value) : INDEX_NONE; - if (EnumVal == INDEX_NONE) - { - OutReason = FString::Printf(TEXT("unknown enum value '%s'"), *Value); + bOutNotFound = true; // unknown sub-field (typo) or editor-only member stripped at runtime return false; } - EnumProp->GetUnderlyingProperty()->SetIntPropertyValue(ValuePtr, EnumVal); - return true; + ValuePtr = Sub->ContainerPtrToValuePtr(ValuePtr); + Prop = Sub; } - OutReason = FString::Printf(TEXT("unsupported property type '%s'"), *Prop->GetClass()->GetName()); - return false; + OutLeaf = Prop; + OutValuePtr = ValuePtr; + return true; } + + // Accept only user-facing material configuration: the property must be editable in the material editor + // (CPF_Edit) and not read-only/deprecated. This is what "all the settings a UMaterial exposes" means, + // while filtering out transient/derived/internal members. + bool IsAcceptableTopProperty(const FProperty* Top, FString& OutReason) + { + if (!Top->HasAnyPropertyFlags(CPF_Edit)) + { + OutReason = TEXT("not an editable material property"); + return false; + } + if (Top->HasAnyPropertyFlags(CPF_EditConst | CPF_Deprecated)) + { + OutReason = TEXT("read-only or deprecated property"); + return false; + } + return true; + } + + // Coerce `Value` into the leaf property via the engine's own text importer, which handles every leaf + // type uniformly (bool / numeric / enum-by-name / FName / FString / object reference by asset path / + // struct literal), matching the format UE uses in .ini and copy/paste. Arrays are rejected explicitly + // (SL_SETTINGS splits on commas, so a list value cannot be expressed). + bool CoerceLeaf(UMaterial& Material, FProperty* Leaf, void* ValuePtr, const FString& Value, FString& OutReason) + { + if (CastField(Leaf)) + { + OutReason = TEXT("array properties are not settable via SL_SETTINGS"); + return false; + } + + FStringOutputDevice ImportErrors; + const TCHAR* Consumed = Leaf->ImportText_Direct(*Value, ValuePtr, &Material, PPF_None, &ImportErrors); + if (Consumed == nullptr) + { + const FString Detail = ImportErrors.TrimStartAndEnd(); + OutReason = Detail.IsEmpty() + ? FString::Printf(TEXT("cannot parse '%s' as %s"), *Value, *Leaf->GetClass()->GetName()) + : Detail; + return false; + } + return true; + } +} + +const TCHAR* FShaderLabSettingsApplier::GetControlledSettingReason(const FString& TopName) +{ + // Properties ShaderLab controls itself, or that are not "material configuration" — NOT settable via + // SL_SETTINGS. (Domain/BlendMode/TwoSided are consumed as first-class keys and never reach here.) + // Everything else that is an editable UMaterial property is accepted by reflection, so the DSL tracks + // the engine's material settings automatically without a hand-maintained allowlist. + if (TopName == TEXT("MaterialDomain")) { return TEXT("use the first-class `Domain` key"); } + if (TopName == TEXT("BlendMode")) { return TEXT("use the first-class `BlendMode` key"); } + if (TopName == TEXT("TwoSided")) { return TEXT("use the first-class `TwoSided` key"); } + if (TopName == TEXT("ShadingModel") || + TopName == TEXT("ShadingModels")) { return TEXT("the shading model is derived from the material's entry (SL_SURFACE / SL_UNLIT / SL_SLAB BSDF)"); } + if (TopName == TEXT("bUseMaterialAttributes")) { return TEXT("ShaderLab wires individual material pins, not a MaterialAttributes struct"); } + if (TopName == TEXT("bEnableNewHLSLGenerator") || + TopName == TEXT("bEnableExecWire")) { return TEXT("experimental translator toggle not supported by ShaderLab"); } + if (TopName.StartsWith(TEXT("bUsedWith"))) { return TEXT("material usage is per-instance; set it via CheckMaterialUsage on the instance, not on the base"); } + return nullptr; // not controlled — settable by reflection } bool FShaderLabSettingsApplier::ApplyReflectedSettings( @@ -117,27 +133,61 @@ bool FShaderLabSettingsApplier::ApplyReflectedSettings( bool bOk = true; for (const TPair& Pair : RawSettings) { - const FName Key(*Pair.Key); - if (!AllowedSettingNames().Contains(Key)) + const FString& Key = Pair.Key; + const FString& Value = Pair.Value; + + // Top-level name (before any '.') for the deny check. + FString TopName = Key; + int32 DotIdx = INDEX_NONE; + if (Key.FindChar(TEXT('.'), DotIdx)) { - OutErrors.Add(FString::Printf(TEXT("Setting '%s' is not in the ShaderLab allowlist"), *Pair.Key)); - bOk = false; - continue; - } - FProperty* Prop = UMaterial::StaticClass()->FindPropertyByName(Key); - if (!Prop) - { - OutErrors.Add(FString::Printf(TEXT("UMaterial has no property '%s'"), *Pair.Key)); + TopName = Key.Left(DotIdx); + } + + if (const TCHAR* DenyReason = FShaderLabSettingsApplier::GetControlledSettingReason(TopName)) + { + OutErrors.Add(FString::Printf(TEXT("Setting '%s' is controlled by ShaderLab and cannot be set here: %s"), *Key, DenyReason)); bOk = false; continue; } + + FProperty* Top = nullptr; + FProperty* Leaf = nullptr; + void* ValuePtr = nullptr; + bool bNotFound = false; FString Reason; - if (!CoerceAndSet(Material, Prop, Pair.Value, Reason)) + if (!ResolveKeyPath(Material, Key, Top, Leaf, ValuePtr, bNotFound, Reason)) { - OutErrors.Add(FString::Printf(TEXT("Setting '%s': %s"), *Pair.Key, *Reason)); + if (bNotFound) + { +#if WITH_EDITOR + // At cook / in the editor the full property set is present, so an unknown key is an authoring + // error (typo, or a setting that does not exist on this engine version). + OutErrors.Add(FString::Printf(TEXT("UMaterial has no setting '%s'"), *Key)); + bOk = false; +#endif + // Cooked runtime: an editor-only property is stripped from the class, so "not found" is expected — + // its value was already baked into the instance's shader map at cook. Skip silently. (Genuine + // typos were already rejected at cook, above.) + continue; + } + OutErrors.Add(FString::Printf(TEXT("Setting '%s': %s"), *Key, *Reason)); + bOk = false; + continue; + } + + if (!IsAcceptableTopProperty(Top, Reason)) + { + OutErrors.Add(FString::Printf(TEXT("Setting '%s': %s"), *Key, *Reason)); + bOk = false; + continue; + } + + if (!CoerceLeaf(Material, Leaf, ValuePtr, Value, Reason)) + { + OutErrors.Add(FString::Printf(TEXT("Setting '%s': %s"), *Key, *Reason)); bOk = false; } } return bOk; } - diff --git a/Source/UShaderLab/Public/ShaderLabModel.h b/Source/UShaderLab/Public/ShaderLabModel.h index 0a52722..7a477a5 100644 --- a/Source/UShaderLab/Public/ShaderLabModel.h +++ b/Source/UShaderLab/Public/ShaderLabModel.h @@ -361,8 +361,9 @@ struct USHADERLAB_API FShaderLabModel /** * Long-tail material settings applied via reflection onto UMaterial by name (the typed * Domain/BlendMode/TwoSided above stay first-class because the graph builder branches on them). - * Stored as raw key/value strings to keep this model Core-only; the allowlist check and value - * coercion happen in FShaderLabSettingsApplier at build/runtime, where errors are surfaced. + * Stored as raw key/value strings to keep this model Core-only; reflection resolution (any editable + * UMaterial property, incl. dotted `Struct.Field` keys) and value coercion happen in + * FShaderLabSettingsApplier at build/runtime, where errors are surfaced. */ TArray> RawSettings; diff --git a/Source/UShaderLab/Public/ShaderLabSettingsApplier.h b/Source/UShaderLab/Public/ShaderLabSettingsApplier.h index c6d252d..c2e5604 100644 --- a/Source/UShaderLab/Public/ShaderLabSettingsApplier.h +++ b/Source/UShaderLab/Public/ShaderLabSettingsApplier.h @@ -11,16 +11,22 @@ class UMaterial; * builder and the cooked runtime shell builder so the same flags drive shader compilation * everywhere. * - * Reflected settings: arbitrary `Key = Value` pairs resolved against an allowlist of UMaterial - * properties and coerced by reflection (float/int/bool-bitfield/enum-by-name). + * Reflected settings: arbitrary `Key = Value` pairs resolved by reflection against ANY editable + * (CPF_Edit) UMaterial property and coerced via the engine's own text importer (FProperty::ImportText — + * bool / numeric / enum-by-name / FName / FString / object-ref-by-asset-path / struct literal). This + * means the DSL tracks the full set of material settings automatically, with no hand-maintained + * allowlist. Struct settings use dotted sub-field keys, e.g. `DisplacementScaling.Magnitude = 2.0`. + * + * A small deny set covers properties ShaderLab controls itself (MaterialDomain — use the first-class + * `Domain` key; ShadingModel(s) — from the entry; bUseMaterialAttributes; bUsedWith* — per-instance + * usage) and non-config internals. Arrays are not settable (SL_SETTINGS splits on commas). * * (Material usage is intentionally NOT a ShaderLab concern: base materials are templates with no - * usage flags. Usage is established per-instance on the UShaderLabMaterialInstanceConstant — the - * editor auto-sets it on mesh assignment, or the author ticks it in the instance's Usage Flag - * Overrides — so only the vertex-factory permutations actually used get compiled.) + * usage flags. Usage is established per-instance on the UShaderLabMaterialInstanceConstant.) * - * Contract: an unknown/disallowed key or an unresolvable value is collected into OutErrors (a hard - * build failure / cook abort) — never silently skipped. + * Contract: at cook / in the editor an unknown key, a denied key, or an unresolvable value is collected + * into OutErrors (a hard build failure / cook abort). At cooked runtime a "not found" key is an + * editor-only property already baked into the shader map at cook, so it is skipped silently. */ struct USHADERLAB_API FShaderLabSettingsApplier { @@ -29,4 +35,11 @@ struct USHADERLAB_API FShaderLabSettingsApplier UMaterial& Material, const TArray>& RawSettings, TArray& OutErrors); + + /** + * If the given top-level UMaterial property name is one ShaderLab controls itself (and so cannot be set + * via SL_SETTINGS), returns a human-readable reason; otherwise nullptr. Shared single source of truth for + * both the apply path and IDE completion generation (so "what completes" == "what is accepted"). + */ + static const TCHAR* GetControlledSettingReason(const FString& TopLevelName); }; diff --git a/Source/UShaderLabBuilder/Private/ShaderLabBuilderModule.cpp b/Source/UShaderLabBuilder/Private/ShaderLabBuilderModule.cpp index 9275d15..9a7f70e 100644 --- a/Source/UShaderLabBuilder/Private/ShaderLabBuilderModule.cpp +++ b/Source/UShaderLabBuilder/Private/ShaderLabBuilderModule.cpp @@ -5,10 +5,12 @@ #include "Interfaces/IPluginManager.h" #include "MaterialShared.h" #include "Materials/Material.h" +#include "Misc/CoreDelegates.h" #include "Misc/Paths.h" #include "Modules/ModuleManager.h" #include "ShaderCore.h" #include "ShaderLabGraphBuilder.h" +#include "ShaderLabIDEPrepare.h" #include "ShaderLabMaterialInstanceConstant.h" #include "ShaderLabMaterialRegistry.h" #include "ShaderLabModel.h" @@ -148,6 +150,12 @@ public: // module loaded for all uncooked runs) is the fix for invisible materials under `-game`. BuildHandle = FShaderLabMaterialRegistry::Get().OnBuildMaterial().AddStatic(&BuildAndCompile); + // First-launch convenience: auto-generate the .usl IDE authoring assets if the project is missing them + // (a fresh clone lacks .vscode — it is git-ignored). Deferred to post-engine-init so shader-source + // mappings (added above) and UMaterial reflection are ready; the callback self-gates to an interactive + // editor session (no cook / -game / commandlet / unattended). + PostEngineInitHandle = FCoreDelegates::GetOnPostEngineInit().AddStatic(&ShaderLabIDE_EnsureGeneratedIfMissing); + UE_LOG(LogShaderLabBuilder, Log, TEXT("ShaderLabBuilder module started.")); } @@ -158,10 +166,16 @@ public: FShaderLabMaterialRegistry::Get().OnBuildMaterial().Remove(BuildHandle); BuildHandle.Reset(); } + if (PostEngineInitHandle.IsValid()) + { + FCoreDelegates::GetOnPostEngineInit().Remove(PostEngineInitHandle); + PostEngineInitHandle.Reset(); + } } private: FDelegateHandle BuildHandle; + FDelegateHandle PostEngineInitHandle; }; IMPLEMENT_MODULE(FShaderLabBuilderModule, UShaderLabBuilder) diff --git a/Source/UShaderLabBuilder/Private/ShaderLabIDEPrepare.cpp b/Source/UShaderLabBuilder/Private/ShaderLabIDEPrepare.cpp index 96ddcf0..a07b4c9 100644 --- a/Source/UShaderLabBuilder/Private/ShaderLabIDEPrepare.cpp +++ b/Source/UShaderLabBuilder/Private/ShaderLabIDEPrepare.cpp @@ -12,21 +12,33 @@ // (*.usl/*.uslfunc/*.usf/*.ush -> hlsl) and shader-validator.pathRemapping // (virtual shader roots -> disk, from AllShaderSourceDirectoryMappings). // pathRemapping is machine-specific; the command regenerates it. +// 4. /.vscode/shaderlab.code-snippets — completion for the whole `.usl` DSL: macro scaffolds +// (full signature + body), specifier fills with enum value choices, +// SL_FRONTMATERIAL topology operators, and every settable SL_SETTINGS +// key (reflected from editable UMaterial properties minus the +// ShaderLab-controlled set). Substrate-only constructs are omitted at +// r.Substrate=0. Regenerated per engine version + project mode. #include "CoreMinimal.h" #include "Dom/JsonObject.h" #include "HAL/IConsoleManager.h" +#include "Materials/Material.h" #include "UObject/Class.h" +#include "UObject/UnrealType.h" #include "Interfaces/IPluginManager.h" +#include "HAL/FileManager.h" +#include "Misc/App.h" #include "Misc/FileHelper.h" #include "Misc/Paths.h" +#include "ShaderLabIDEPrepare.h" #include "Serialization/JsonReader.h" #include "Serialization/JsonSerializer.h" #include "Serialization/JsonWriter.h" #include "RenderUtils.h" // Substrate::IsSubstrateEnabled() #include "ShaderCore.h" #include "ShaderLabIntrinsicRegistry.h" +#include "ShaderLabSettingsApplier.h" DEFINE_LOG_CATEGORY_STATIC(LogShaderLabIDE, Log, All); @@ -428,6 +440,211 @@ namespace ShaderLabIDEPrepare_Private WriteIfChanged(SettingsPath, SerializeObject(Settings.ToSharedRef())); } + // The VSCode snippet value placeholder (tabstop) for a leaf property: enums/bools become a choice list, + // everything else a typed placeholder. + static FString SnippetValueForLeaf(const FProperty* Leaf, int32 Tab) + { + auto EnumChoice = [Tab](const UEnum* Enum) -> FString + { + TArray Tokens; + for (int32 i = 0; i < Enum->NumEnums(); ++i) + { + const FString T = Enum->GetNameStringByIndex(i); + if (!T.IsEmpty() && !T.EndsWith(TEXT("_MAX"))) { Tokens.Add(T); } + } + return Tokens.Num() > 0 + ? FString::Printf(TEXT("${%d|%s|}"), Tab, *FString::Join(Tokens, TEXT(","))) + : FString::Printf(TEXT("${%d:}"), Tab); + }; + + if (CastField(Leaf)) { return FString::Printf(TEXT("${%d|true,false|}"), Tab); } + if (const FByteProperty* B = CastField(Leaf)) { return B->Enum ? EnumChoice(B->Enum) : FString::Printf(TEXT("${%d:0}"), Tab); } + if (const FEnumProperty* E = CastField(Leaf)) { return E->GetEnum() ? EnumChoice(E->GetEnum()) : FString::Printf(TEXT("${%d:0}"), Tab); } + if (CastField(Leaf)) { return FString::Printf(TEXT("${%d:0}"), Tab); } + if (CastField(Leaf)) { return FString::Printf(TEXT("${%d:/Game/Path/Asset.Asset}"), Tab); } + return FString::Printf(TEXT("${%d:}"), Tab); // FName / FString / other leaf + } + + // Add a completion snippet for a property. Struct properties expand one level into dotted + // `Prefix.SubField` keys (the material config structs are shallow); arrays are skipped (not settable). + static void EmitSettingSnippets(const TSharedRef& Out, const FString& Prefix, FProperty* Prop, int32 Depth) + { + if (CastField(Prop)) { return; } + + if (const FStructProperty* StructProp = CastField(Prop)) + { + if (Depth < 2 && StructProp->Struct) + { + bool bAny = false; + for (TFieldIterator It(StructProp->Struct); It; ++It) + { + EmitSettingSnippets(Out, Prefix + TEXT(".") + It->GetName(), *It, Depth + 1); + bAny = true; + } + if (bAny) { return; } // expanded into sub-fields + } + // no reflected fields / too deep: fall through to a single literal placeholder + } + + TArray> Body; + Body.Add(MakeShared(FString::Printf(TEXT("%s = %s"), *Prefix, *SnippetValueForLeaf(Prop, 1)))); + + const TSharedRef Entry = MakeShared(); + Entry->SetStringField(TEXT("prefix"), Prefix); + Entry->SetArrayField(TEXT("body"), Body); + Entry->SetStringField(TEXT("description"), FString::Printf(TEXT("ShaderLab material setting (%s)"), *Prop->GetCPPType())); + Entry->SetStringField(TEXT("scope"), TEXT("hlsl")); + Out->SetObjectField(FString::Printf(TEXT("SL_SETTINGS %s"), *Prefix), Entry); + } + + // One hand-authored DSL snippet. `Body` is '\n'-separated (each line -> a VSCode snippet body line) + // with $0/${n:default}/${n|a,b,c|} tabstops. bSubstrateOnly entries are emitted only when the project + // runs with r.Substrate=1 (legacy rejects those constructs at build). + struct FDslSnippet { const TCHAR* Prefix; const TCHAR* Body; const TCHAR* Desc; bool bSubstrateOnly; }; + + static void AddSnippet(const TSharedRef& Out, const FString& Key, const FString& Prefix, const FString& Body, const FString& Desc) + { + TArray Lines; + Body.ParseIntoArray(Lines, TEXT("\n"), /*CullEmpty*/ false); + TArray> BodyArr; + for (const FString& L : Lines) { BodyArr.Add(MakeShared(L)); } + + const TSharedRef Entry = MakeShared(); + Entry->SetStringField(TEXT("prefix"), Prefix); + Entry->SetArrayField(TEXT("body"), BodyArr); + Entry->SetStringField(TEXT("description"), Desc); + Entry->SetStringField(TEXT("scope"), TEXT("hlsl")); + Out->SetObjectField(Key, Entry); + } + + // Macro scaffolds: entry/BSDF/block macros expand the FULL declaration they annotate (function signature + + // body braces), so a single trigger drops in a compilable construct. Value tokens mirror the parser's + // accepted sets one-for-one (see ShaderLabParser.cpp). Struct FIELDS and HLSL types are intentionally NOT + // snippets — those come from the HLSL language server against the generated mode-specific Surface_*.ush. + static const FDslSnippet* MacroTemplates(int32& OutNum) + { + static const FDslSnippet T[] = { + // --- both modes --- + { TEXT("SL_SETTINGS"), TEXT("SL_SETTINGS(Domain = ${1|Surface,PostProcess,UI,Decal,Volume,LightFunction|}, BlendMode = ${2|Opaque,Masked,Translucent,Additive,Modulate,AlphaComposite,AlphaHoldout|})"), TEXT("ShaderLab: material settings"), false }, + { TEXT("SL_PROPERTY"), TEXT("SL_PROPERTY(Category = \"${1:Surface}\")\n${2:float3} ${3:Name} = ${4:0};"), TEXT("ShaderLab: material parameter (float->Scalar, float3->Color, float4->Vector, Texture2D->texture)"), false }, + { TEXT("SL_SURFACE"), TEXT("SL_SURFACE()\nvoid Surface(inout FShaderLabSurface S)\n{\n\t$0\n}"), TEXT("ShaderLab: single-entry surface (DefaultLit in legacy)"), false }, + { TEXT("SL_POSTPROCESS"), TEXT("SL_POSTPROCESS()\nvoid PostProcess(inout FShaderLabPostProcess O)\n{\n\t$0\n}"), TEXT("ShaderLab: post-process entry (Domain = PostProcess)"), false }, + { TEXT("SL_UI"), TEXT("SL_UI()\nvoid UI(inout FShaderLabUI O)\n{\n\t$0\n}"), TEXT("ShaderLab: UI entry (Domain = UI)"), false }, + { TEXT("SL_VERTEX"), TEXT("SL_VERTEX()\nvoid Vertex(inout FShaderLabVertex V)\n{\n\t$0\n}"), TEXT("ShaderLab: vertex stage (WorldPositionOffset / CustomizedUV)"), false }, + { TEXT("SL_UNLIT"), TEXT("SL_UNLIT()\nvoid Unlit(inout FShaderLabUnlit U)\n{\n\t$0\n}"), TEXT("ShaderLab: unlit BSDF (emissive/transmittance only)"), false }, + { TEXT("SL_VALUE"), TEXT("SL_VALUE()\nfloat ${1:Name}()\n{\n\treturn ${2:0};\n}"), TEXT("ShaderLab: named float/float2 value block (mix factor / UV)"), false }, + { TEXT("SL_INTERPOLATOR"), TEXT("SL_INTERPOLATOR()\nfloat3 ${1:Name}()\n{\n\treturn ${2:0};\n}"), TEXT("ShaderLab: per-vertex interpolator (read with UE_Interpolator)"), false }, + { TEXT("SL_COLLECTION"), TEXT("SL_COLLECTION(Path = \"${1:/Game/MPC/MPC_Name}\")\n${2:float3} ${3:Name};"), TEXT("ShaderLab: Material Parameter Collection read"), false }, + { TEXT("SL_VTSAMPLE"), TEXT("SL_VTSAMPLE(DefaultTexture = \"${1:/Game/VT/T_Name}\", SamplerType = ${2|VirtualColor,VirtualNormal,VirtualGrayscale,VirtualAlpha,VirtualMasks,VirtualLinearColor,VirtualLinearGrayscale|}, UV = ${3:TexCoord0})\nfloat4 ${4:Name};"), TEXT("ShaderLab: streaming Virtual Texture read"), false }, + { TEXT("SL_RVTSAMPLE"), TEXT("SL_RVTSAMPLE(VirtualTexture = \"${1:/Game/RVT/RVT_Name}\", MaterialType = ${2|BaseColor_Normal_Roughness,BaseColor_Normal_Specular,BaseColor_Normal_Specular_YCoCg,BaseColor_Normal_Specular_Mask_YCoCg,BaseColor,Mask4,WorldHeight,Displacement|}, UV = ${3|World,TexCoord0|})\nFShaderLabRVT ${4:Terrain};"), TEXT("ShaderLab: Runtime Virtual Texture read"), false }, + { TEXT("SL_RVTOUTPUT"), TEXT("SL_RVTOUTPUT()\nvoid RVTOutput(inout FShaderLabRVTOutput O)\n{\n\t$0\n}"), TEXT("ShaderLab: Runtime Virtual Texture write block"), false }, + { TEXT("SL_IMPORT"), TEXT("SL_IMPORT(Namespace = \"${1:Detail}\")\n#include \"${2:/Project/Lib/Lib.uslfunc}\""), TEXT("ShaderLab: function-library import with namespace"), false }, + { TEXT("SL_FUNCTION"), TEXT("SL_FUNCTION()\n${1:float3} ${2:Name}(${3:float2 uv})\n{\n\treturn ${4:0};\n}"), TEXT("ShaderLab: library function (.uslfunc files only)"), false }, + // --- Substrate only --- + { TEXT("SL_SLAB"), TEXT("SL_SLAB()\nvoid ${1:Name}(inout FShaderLabSlab S)\n{\n\t$0\n}"), TEXT("ShaderLab: Substrate slab BSDF block"), true }, + { TEXT("SL_MATERIAL"), TEXT("SL_MATERIAL()\nvoid Material(inout FShaderLabMaterialOutput O)\n{\n\t$0\n}"), TEXT("ShaderLab: whole-material outputs for multi-slab (Substrate)"), true }, + { TEXT("SL_HAIR"), TEXT("SL_HAIR()\nvoid ${1:Name}(inout FShaderLabHair H)\n{\n\t$0\n}"), TEXT("ShaderLab: Substrate hair BSDF"), true }, + { TEXT("SL_EYE"), TEXT("SL_EYE()\nvoid ${1:Name}(inout FShaderLabEye E)\n{\n\t$0\n}"), TEXT("ShaderLab: Substrate eye BSDF"), true }, + { TEXT("SL_WATER"), TEXT("SL_WATER()\nvoid ${1:Name}(inout FShaderLabWater W)\n{\n\t$0\n}"), TEXT("ShaderLab: Substrate water BSDF"), true }, + { TEXT("SL_CLEARCOAT"), TEXT("SL_CLEARCOAT()\nvoid ${1:Name}(inout FShaderLabClearCoat C)\n{\n\t$0\n}"), TEXT("ShaderLab: Substrate clear-coat BSDF"), true }, + { TEXT("SL_TOON"), TEXT("SL_TOON()\nvoid ${1:Name}(inout FShaderLabToon T)\n{\n\t$0\n}"), TEXT("ShaderLab: Substrate toon BSDF"), true }, + { TEXT("SL_VOLUME"), TEXT("SL_VOLUME()\nvoid ${1:Name}(inout FShaderLabVolume V)\n{\n\t$0\n}"), TEXT("ShaderLab: Substrate volume BSDF (Domain = Volume)"), true }, + { TEXT("SL_LIGHTFUNCTION"), TEXT("SL_LIGHTFUNCTION()\nvoid ${1:Name}(inout FShaderLabLightFunction L)\n{\n\t$0\n}"), TEXT("ShaderLab: light-function BSDF (Domain = LightFunction)"), true }, + { TEXT("SL_FRONTMATERIAL"), TEXT("SL_FRONTMATERIAL(${1:VerticalLayer(Top, Base, 0.5)})"), TEXT("ShaderLab: compose slabs into the front material (Substrate)"), true }, + }; + OutNum = UE_ARRAY_COUNT(T); + return T; + } + + // Standalone specifier fills (Key = value), with enum value choices where the parser enumerates them. + static const FDslSnippet* SpecifierSnippets(int32& OutNum) + { + static const FDslSnippet S[] = { + // SL_SETTINGS first-class + { TEXT("Domain"), TEXT("Domain = ${1|Surface,PostProcess,UI,Decal,Volume,LightFunction|}"), TEXT("SL_SETTINGS: material domain"), false }, + { TEXT("BlendMode"), TEXT("BlendMode = ${1|Opaque,Masked,Translucent,Additive,Modulate,AlphaComposite,AlphaHoldout|}"), TEXT("SL_SETTINGS: blend mode"), false }, + { TEXT("TwoSided"), TEXT("TwoSided = ${1|true,false|}"), TEXT("SL_SETTINGS: two-sided"), false }, + // SL_PROPERTY + { TEXT("Category"), TEXT("Category = \"${1:Surface}\""), TEXT("SL_PROPERTY: editor group"), false }, + { TEXT("SortPriority"), TEXT("SortPriority = ${1:0}"), TEXT("SL_PROPERTY: sort order in group"), false }, + { TEXT("DefaultTexture"), TEXT("DefaultTexture = \"${1:white}\""), TEXT("SL_PROPERTY/SL_VTSAMPLE: default texture (white/black/grey/normal token or asset path)"), false }, + { TEXT("ClampMin"), TEXT("ClampMin = ${1:0}"), TEXT("SL_PROPERTY: scalar slider min"), false }, + { TEXT("ClampMax"), TEXT("ClampMax = ${1:1}"), TEXT("SL_PROPERTY: scalar slider max"), false }, + { TEXT("CustomPrimitiveData"), TEXT("CustomPrimitiveData = ${1:0}"), TEXT("SL_PROPERTY: per-instance Custom Primitive Data index (Scalar/Vector)"), false }, + { TEXT("SamplerType"), TEXT("SamplerType = ${1|Color,LinearColor,Grayscale,LinearGrayscale,Alpha,Normal,Masks,DistanceFieldFont,Data|}"), TEXT("SL_PROPERTY: texture sampler type"), false }, + // SL_COLLECTION / SL_IMPORT / SL_RVTSAMPLE + { TEXT("Path"), TEXT("Path = \"${1:/Game/MPC/MPC_Name}\""), TEXT("SL_COLLECTION: parameter collection asset"), false }, + { TEXT("Parameter"), TEXT("Parameter = \"${1:ParamName}\""), TEXT("SL_COLLECTION: in-collection parameter name"), false }, + { TEXT("Namespace"), TEXT("Namespace = \"${1:Detail}\""), TEXT("SL_IMPORT: promoted-parameter namespace prefix"), false }, + { TEXT("VirtualTexture"), TEXT("VirtualTexture = \"${1:/Game/RVT/RVT_Name}\""), TEXT("SL_RVTSAMPLE: runtime virtual texture asset"), false }, + { TEXT("MaterialType"), TEXT("MaterialType = ${1|BaseColor_Normal_Roughness,BaseColor_Normal_Specular,BaseColor_Normal_Specular_YCoCg,BaseColor_Normal_Specular_Mask_YCoCg,BaseColor,Mask4,WorldHeight,Displacement|}"), TEXT("SL_RVTSAMPLE: RVT layout"), false }, + { TEXT("UV"), TEXT("UV = ${1|TexCoord0,World|}"), TEXT("SL_VTSAMPLE/SL_RVTSAMPLE: UV source (TexCoord / World / SL_VALUE block name)"), false }, + // BSDF modifiers (Substrate only) + { TEXT("SubsurfaceProfile"), TEXT("SubsurfaceProfile = \"${1:/Game/Profiles/PP_SSS}\""), TEXT("Slab/Eye modifier (Substrate)"), true }, + { TEXT("SpecularProfile"), TEXT("SpecularProfile = \"${1:/Game/Profiles/PP_Spec}\""), TEXT("Slab modifier (Substrate)"), true }, + { TEXT("ToonProfile"), TEXT("ToonProfile = \"${1:/Game/Profiles/PP_Toon}\""), TEXT("Toon modifier (Substrate)"), true }, + { TEXT("SubSurfaceType"), TEXT("SubSurfaceType = ${1:Wrap}"), TEXT("Slab modifier (Substrate)"), true }, + }; + OutNum = UE_ARRAY_COUNT(S); + return S; + } + + // SL_FRONTMATERIAL topology operators (Substrate only). + static const FDslSnippet* TopologySnippets(int32& OutNum) + { + static const FDslSnippet T[] = { + { TEXT("VerticalLayer"), TEXT("VerticalLayer(${1:Top}, ${2:Base}, ${3:0.5})"), TEXT("SL_FRONTMATERIAL: layer Top over Base by factor"), true }, + { TEXT("HorizontalMix"), TEXT("HorizontalMix(${1:A}, ${2:B}, ${3:0.5})"), TEXT("SL_FRONTMATERIAL: blend A and B by factor"), true }, + { TEXT("Add"), TEXT("Add(${1:A}, ${2:B})"), TEXT("SL_FRONTMATERIAL: add A and B"), true }, + { TEXT("Weight"), TEXT("Weight(${1:A}, ${2:0.5})"), TEXT("SL_FRONTMATERIAL: scale coverage of A"), true }, + { TEXT("Select"), TEXT("Select(${1:A}, ${2:B}, ${3:0.5})"), TEXT("SL_FRONTMATERIAL: pick A or B by threshold"), true }, + }; + OutNum = UE_ARRAY_COUNT(T); + return T; + } + + // Generates /.vscode/shaderlab.code-snippets — completion for the whole `.usl` DSL: + // * macro scaffolds (full function signature + body), specifier fills (with enum value choices), and + // SL_FRONTMATERIAL topology operators — hand-authored, mirroring the parser's grammar/value tokens; + // * every settable SL_SETTINGS key — reflected from editable UMaterial properties minus the + // ShaderLab-controlled set (FShaderLabSettingsApplier::GetControlledSettingReason), enums/bools as + // value choices, struct settings as dotted `Struct.Field` keys. + // Substrate-only constructs are omitted when the project runs with r.Substrate=0. Struct fields, HLSL + // types and variables are deliberately left to the HLSL language server (generated Surface_*.ush switched + // by the SHADERLAB_SUBSTRATE define). Regenerated per engine version + project mode. + static void GenerateSnippets(const FString& ProjectDir) + { + const bool bSubstrate = Substrate::IsSubstrateEnabled(); + const TSharedRef Root = MakeShared(); + + int32 Num = 0; + const FDslSnippet* Macros = MacroTemplates(Num); + for (int32 i = 0; i < Num; ++i) { if (!Macros[i].bSubstrateOnly || bSubstrate) { AddSnippet(Root, FString::Printf(TEXT("SL macro %s"), Macros[i].Prefix), Macros[i].Prefix, Macros[i].Body, Macros[i].Desc); } } + + const FDslSnippet* Specs = SpecifierSnippets(Num); + for (int32 i = 0; i < Num; ++i) { if (!Specs[i].bSubstrateOnly || bSubstrate) { AddSnippet(Root, FString::Printf(TEXT("SL spec %s"), Specs[i].Prefix), Specs[i].Prefix, Specs[i].Body, Specs[i].Desc); } } + + if (bSubstrate) + { + const FDslSnippet* Topo = TopologySnippets(Num); + for (int32 i = 0; i < Num; ++i) { AddSnippet(Root, FString::Printf(TEXT("SL topo %s"), Topo[i].Prefix), Topo[i].Prefix, Topo[i].Body, Topo[i].Desc); } + } + + // Reflected SL_SETTINGS material keys (every editable, non-controlled UMaterial property). + for (TFieldIterator It(UMaterial::StaticClass()); It; ++It) + { + FProperty* Prop = *It; + if (!Prop->HasAnyPropertyFlags(CPF_Edit) || Prop->HasAnyPropertyFlags(CPF_EditConst | CPF_Deprecated)) { continue; } + if (CastField(Prop)) { continue; } + if (FShaderLabSettingsApplier::GetControlledSettingReason(Prop->GetName()) != nullptr) { continue; } + EmitSettingSnippets(Root, Prop->GetName(), Prop, 0); + } + + // Retire the earlier settings-only file (superseded by the combined DSL snippet file). + IFileManager::Get().Delete(*FPaths::Combine(ProjectDir, TEXT(".vscode"), TEXT("shaderlab-settings.code-snippets")), /*RequireExists*/ false, /*EvenReadOnly*/ false, /*Quiet*/ true); + WriteIfChanged(FPaths::Combine(ProjectDir, TEXT(".vscode"), TEXT("shaderlab.code-snippets")), SerializeObject(Root)); + } + static void Run(const TArray& /*Args*/) { const TSharedPtr Plugin = IPluginManager::Get().FindPlugin(TEXT("UShaderLab")); @@ -441,11 +658,39 @@ namespace ShaderLabIDEPrepare_Private GenerateVectorTransforms(PluginShaderDir); GeneratePositionTransforms(PluginShaderDir); MergeVSCodeSettings(ProjectDir); + GenerateSnippets(ProjectDir); UE_LOG(LogShaderLabIDE, Log, TEXT("ShaderLab.IDE.Prepare: done.")); } + + static TAutoConsoleVariable CVarAutoPrepare( + TEXT("ShaderLab.IDE.AutoPrepare"), true, + TEXT("On editor startup, auto-generate the .usl IDE assets (.vscode snippets/settings + stubs) if the ") + TEXT("project's .vscode/shaderlab.code-snippets is missing. Set 0 to disable."), + ECVF_Default); + + static void EnsureGeneratedIfMissing() + { + // Interactive editor only — never during cook / -game (GIsEditor false) / commandlet / headless + // automation. Generating VSCode authoring assets makes no sense in those, and they must not write files. + if (!GIsEditor || IsRunningCommandlet() || FApp::IsUnattended()) { return; } + if (!CVarAutoPrepare.GetValueOnGameThread()) { return; } + + // Our exclusive marker file. .vscode is git-ignored, so a fresh clone lacks it -> first-launch setup. + const FString ProjectDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir()); + const FString Marker = FPaths::Combine(ProjectDir, TEXT(".vscode"), TEXT("shaderlab.code-snippets")); + if (FPaths::FileExists(Marker)) { return; } // already set up — don't churn on every launch + + UE_LOG(LogShaderLabIDE, Display, + TEXT("ShaderLab: IDE assets missing (%s) — auto-generating .vscode + stubs. Re-run `ShaderLab.IDE.Prepare` to refresh after an engine upgrade."), + *Marker); + Run(TArray()); + } } +void ShaderLabIDE_RunPrepare() { ShaderLabIDEPrepare_Private::Run(TArray()); } +void ShaderLabIDE_EnsureGeneratedIfMissing() { ShaderLabIDEPrepare_Private::EnsureGeneratedIfMissing(); } + static FAutoConsoleCommand GShaderLabIDEPrepareCommand( TEXT("ShaderLab.IDE.Prepare"), - TEXT("Generate IDE completion assets for .usl: Private/ShaderLabUENode.ush, Private/UEFunctions/Transform.ush + TransformPosition.ush, and .vscode/settings.json."), + TEXT("Generate IDE completion assets for .usl: Private/ShaderLabUENode.ush, Private/UEFunctions/Transform.ush + TransformPosition.ush, .vscode/settings.json, and .vscode/shaderlab.code-snippets (full DSL)."), FConsoleCommandWithArgsDelegate::CreateStatic(&ShaderLabIDEPrepare_Private::Run)); diff --git a/Source/UShaderLabBuilder/Private/ShaderLabIDEPrepare.h b/Source/UShaderLabBuilder/Private/ShaderLabIDEPrepare.h new file mode 100644 index 0000000..673781f --- /dev/null +++ b/Source/UShaderLabBuilder/Private/ShaderLabIDEPrepare.h @@ -0,0 +1,14 @@ +// Copyright UShaderLab. All Rights Reserved. + +#pragma once + +// Regenerate all `.usl` IDE authoring assets (same as the `ShaderLab.IDE.Prepare` console command): the +// generated UE_-intrinsic / transform stubs, the merged .vscode/settings.json, and the full-DSL +// .vscode/shaderlab.code-snippets. +void ShaderLabIDE_RunPrepare(); + +// In an interactive editor session, generate the IDE assets if they are missing (a fresh clone lacks them +// because .vscode is git-ignored). No-op in cook / -game / commandlet / unattended runs, when the assets +// already exist, or when `ShaderLab.IDE.AutoPrepare 0` disables it. Registered on OnPostEngineInit by the +// module so shader-source mappings + reflection are ready. +void ShaderLabIDE_EnsureGeneratedIfMissing();