diff --git a/Source/UShaderLab/Private/ShaderLabSubsystem.cpp b/Source/UShaderLab/Private/ShaderLabSubsystem.cpp index dc8c61f..c73b19e 100644 --- a/Source/UShaderLab/Private/ShaderLabSubsystem.cpp +++ b/Source/UShaderLab/Private/ShaderLabSubsystem.cpp @@ -4,6 +4,7 @@ #include "Misc/CoreDelegates.h" #include "Misc/FileHelper.h" +#include "Misc/Paths.h" #include "ShaderLabDiscovery.h" #include "ShaderLabMaterialRegistry.h" #include "ShaderLabModel.h" @@ -74,7 +75,28 @@ UMaterial* UShaderLabSubsystem::RebuildFromFile(const FString& FilePath) { UE_LOG(LogShaderLabSubsystem, Error, TEXT("ShaderLab parse error in %s %s"), *FilePath, *E.ToString()); } + +#if WITH_EDITOR + // Contract: a broken .usl must not silently keep the last-good material. Register an "error model" + // carrying the parse diagnostics — the editor graph builder turns it into a poison material whose + // shader fails to compile with these errors mapped back to the source, so they surface through the + // standard shader-compile path (MIC editor red text, cook) exactly like an HLSL error. The cooked + // runtime has no graph builder, so there it stays a hard skip (a broken .usl is a cook-time bug). + FString SrcPath = FPaths::ConvertRelativePathToFull(FilePath); + SrcPath.ReplaceInline(TEXT("\\"), TEXT("/")); + + FShaderLabModel ErrorModel; + ErrorModel.ShaderName = FPaths::GetBaseFilename(FilePath); + ErrorModel.SourceFilePath = FilePath; + for (const FShaderLabParseError& E : Errors) + { + ErrorModel.LoadErrors.Add(FString::Printf( + TEXT("%s(%d,%d): error: %s"), *SrcPath, E.Line, E.Column, *E.Message)); + } + return FShaderLabMaterialRegistry::Get().RegisterFromModel(ErrorModel); +#else return nullptr; +#endif } return FShaderLabMaterialRegistry::Get().RegisterFromModel(Model); diff --git a/Source/UShaderLab/Public/ShaderLabModel.h b/Source/UShaderLab/Public/ShaderLabModel.h index b6d6264..12382d2 100644 --- a/Source/UShaderLab/Public/ShaderLabModel.h +++ b/Source/UShaderLab/Public/ShaderLabModel.h @@ -148,6 +148,15 @@ struct USHADERLAB_API FShaderLabModel /** Source file this model was parsed from (for diagnostics / hashing context). */ FString SourceFilePath; + /** + * Diagnostics captured when a valid model could NOT be built (e.g. parse errors). When non-empty this + * is an "error model": only ShaderName/SourceFilePath are meaningful, and the editor graph builder + * emits a poison material whose shader deliberately fails to compile with these messages (mapped back + * to the .usl). That routes parse errors through the standard shader-compile path — MIC editor red + * text, GetCompileErrors, cook — exactly like an HLSL error, instead of being silently swallowed. + */ + TArray LoadErrors; + FShaderLabSettings Settings; /** diff --git a/Source/UShaderLab/Public/ShaderLabSubsystem.h b/Source/UShaderLab/Public/ShaderLabSubsystem.h index ed70ffd..ad7fbb8 100644 --- a/Source/UShaderLab/Public/ShaderLabSubsystem.h +++ b/Source/UShaderLab/Public/ShaderLabSubsystem.h @@ -25,7 +25,12 @@ public: /** Scan every search root and (re)register all discovered shaders. */ void DiscoverAndRegisterAll(); - /** Parse a single `.usl` file and (re)register its material. Returns null on parse error. */ + /** + * Parse a single `.usl` file and (re)register its material. In the editor a parse error still + * registers a poison base (carrying the diagnostics) so the error surfaces through the shader + * compile path; only a file-read failure (or the cooked runtime, which has no graph builder) + * returns null. + */ UMaterial* RebuildFromFile(const FString& FilePath); private: diff --git a/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp b/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp index 03d8372..4ea5661 100644 --- a/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp +++ b/Source/UShaderLabEditor/Private/ShaderLabEditorModule.cpp @@ -1,9 +1,11 @@ // Copyright FlecsProj. All Rights Reserved. #include "DirectoryWatcherModule.h" +#include "Editor.h" #include "Engine/Engine.h" #include "IDirectoryWatcher.h" #include "Interfaces/IPluginManager.h" +#include "MaterialEditingLibrary.h" #include "MaterialShared.h" #include "Materials/Material.h" #include "Misc/Paths.h" @@ -23,14 +25,36 @@ namespace /** Editor-side reaction to a base material needing build: rebuild graph + trigger compile. */ void BuildAndCompile(UMaterial& Material, const FShaderLabModel& Model) { - TArray Errors; - if (!FShaderLabGraphBuilder::BuildInto(Material, Model, Errors)) + // Absolute, forward-slashed source path for the poison node's `#line` mapping (matches the graph + // builder's own convention so compiler errors click through to the .usl). + FString SrcPath = FPaths::ConvertRelativePathToFull(Model.SourceFilePath); + SrcPath.ReplaceInline(TEXT("\\"), TEXT("/")); + + if (Model.LoadErrors.Num() > 0) { - for (const FString& E : Errors) + // Parse-stage failure: the .usl never produced a valid model. Route the diagnostics through + // the shader compiler as a poison material so they surface in the MIC editor / cook, not just + // the log. + for (const FString& E : Model.LoadErrors) { - UE_LOG(LogShaderLabEditor, Error, TEXT("ShaderLab build '%s': %s"), *Model.ShaderName, *E); + UE_LOG(LogShaderLabEditor, Error, TEXT("ShaderLab parse '%s': %s"), *Model.ShaderName, *E); + } + FShaderLabGraphBuilder::BuildPoisonInto(Material, Model.LoadErrors, SrcPath); + } + else + { + TArray Errors; + if (!FShaderLabGraphBuilder::BuildInto(Material, Model, Errors)) + { + for (const FString& E : Errors) + { + UE_LOG(LogShaderLabEditor, Error, TEXT("ShaderLab build '%s': %s"), *Model.ShaderName, *E); + } + // BuildInto already cleared the graph before failing — don't leave the base silently empty. + // Replace it with a poison material so the build errors compile-fail with the same + // visibility as an HLSL error, instead of vanishing into the log. + FShaderLabGraphBuilder::BuildPoisonInto(Material, Errors, SrcPath); } - return; } // Recompile the base AND propagate to everything that depends on it. A bare PostEditChange @@ -38,10 +62,23 @@ namespace // (and the level components render those), so on a hot-reload they must be recompiled and // re-pushed too. FMaterialUpdateContext does exactly that on scope exit: recompile the listed // material, then recache every dependent instance and refresh the components/viewports using it. - FMaterialUpdateContext UpdateContext; - UpdateContext.AddMaterial(&Material); - Material.PreEditChange(nullptr); - Material.PostEditChange(); + { + FMaterialUpdateContext UpdateContext; + UpdateContext.AddMaterial(&Material); + Material.PreEditChange(nullptr); + Material.PostEditChange(); + } + + // Refresh any open Material Instance editors built on this base so newly added/removed parameters + // (scalars, textures, static-bool switches) appear without reopening the editor. RegenerateArrays + // pulls the parameter set from the freshly rebuilt parent; mirrors what the stock material editor + // does after RecompileMaterial. Must run after PostEditChange so the parent's parameters are current. + // Guarded on GEditor: this handler also runs during cook/headless registration, where there is no + // interactive editor (and RebuildMaterialInstanceEditors dereferences GEditor). + if (GEditor) + { + UMaterialEditingLibrary::RebuildMaterialInstanceEditors(&Material); + } } } @@ -76,15 +113,6 @@ public: StartWatchingSources(); - // Note: ShaderLab base materials are deliberately NOT surfaced to the Content Browser — they are - // hidden templates. Users create UShaderLabMaterialInstanceConstant instances via the factory - // (Content Browser ▸ Material ▸ ShaderLab Material Instance), which picks a base directly from - // the registry. The instances are the only assets users interact with. - // - // There is NO cook-time hook: the .usl sources are staged into packaged builds (a wildcard - // RuntimeDependency in ShaderLab.Build.cs) and the cooked runtime re-parses them directly, so - // nothing special needs to be written at cook. - UE_LOG(LogShaderLabEditor, Log, TEXT("ShaderLabEditor module started.")); } diff --git a/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp b/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp index 15f0f56..8cdd363 100644 --- a/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp +++ b/Source/UShaderLabEditor/Private/ShaderLabGraphBuilder.cpp @@ -1194,3 +1194,51 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode Material.UpdateCachedExpressionData(); return true; } + +void FShaderLabGraphBuilder::BuildPoisonInto(UMaterial& Material, const TArray& Diagnostics, const FString& SrcPath) +{ + using namespace ShaderLabGraph; + + // A minimal, structurally-valid Substrate material: one Custom node -> Slab.DiffuseAlbedo -> FrontMaterial, + // so the Custom code is reached by translation. The code `#error`s with the diagnostics; that aborts the + // shader preprocessor, so the material fails to compile with our messages — surfacing everywhere real + // shader errors do (MIC editor red text, FMaterialResource::GetCompileErrors, cook). DiffuseAlbedo (a core + // BSDF pin) is used deliberately: EmissiveColor gets dead-stripped from the compiled permutation, which + // would drop the Custom function (and its #error) before the preprocessor ever sees it. + Material.AssignExpressionCollection(FMaterialExpressionCollection()); + Material.MaterialDomain = MD_Surface; + Material.BlendMode = BLEND_Opaque; + Material.TwoSided = 0; + Material.bUseMaterialAttributes = false; + + UMaterialEditorOnlyData* EditorOnly = Material.GetEditorOnlyData(); + check(EditorOnly); // A UMaterial always has editor-only data in the editor (contract). + + // `#error` halts at the first hit, so merge every diagnostic into one directive (newlines stripped to + // keep it a single preprocessor line). The `#line` makes the compiler error click through to the .usl; + // each merged message still carries its own precise `(line,col)` as text. + FString Combined; + for (int32 Index = 0; Index < Diagnostics.Num(); ++Index) + { + if (Index > 0) { Combined += TEXT(" ; "); } + Combined += Diagnostics[Index]; + } + Combined.ReplaceInline(TEXT("\r"), TEXT("")); + Combined.ReplaceInline(TEXT("\n"), TEXT(" ")); + if (Combined.IsEmpty()) { Combined = TEXT("ShaderLab: shader failed to build"); } + + int32 IoY = 0; + UMaterialExpressionCustom* Custom = NewExpr(Material, IoY, -300); + Custom->Description = TEXT("ShaderLab Error"); + Custom->OutputType = CMOT_Float3; + Custom->Code = FString::Printf( + TEXT("#line 1 \"%s\"\n#error ShaderLab: %s\n#line 1 \"ShaderLabGenerated.ush\"\nreturn float3(1,0,1);\n"), + *SrcPath, *Combined); + Custom->RebuildOutputs(); + + UMaterialExpressionSubstrateSlabBSDF* Slab = NewExpr(Material, IoY, 0); + Slab->DiffuseAlbedo.Connect(0, Custom); + EditorOnly->FrontMaterial.Connect(0, Slab); + + Material.UpdateCachedExpressionData(); +} diff --git a/Source/UShaderLabEditor/Public/ShaderLabGraphBuilder.h b/Source/UShaderLabEditor/Public/ShaderLabGraphBuilder.h index 082fceb..79bacc7 100644 --- a/Source/UShaderLabEditor/Public/ShaderLabGraphBuilder.h +++ b/Source/UShaderLabEditor/Public/ShaderLabGraphBuilder.h @@ -25,4 +25,14 @@ public: * compilation — callers decide when to compile. Returns false with diagnostics on failure. */ static bool BuildInto(UMaterial& Material, const FShaderLabModel& Model, TArray& OutErrors); + + /** + * Replace `Material`'s graph with a minimal Substrate material whose single Custom node emits a + * `#line`-mapped `#error` for `Diagnostics`, so it deliberately fails to compile with those messages + * mapped back to `SrcPath` (an absolute, forward-slashed .usl path). This routes .usl parse/build + * errors through the standard shader-compile path — MIC editor red text, GetCompileErrors, cook — + * instead of dropping them. The graph itself is always valid; the failure is intentional and happens + * at shader compile. Does NOT trigger compilation (the caller decides when). + */ + static void BuildPoisonInto(UMaterial& Material, const TArray& Diagnostics, const FString& SrcPath); };