Enhance .usl compile error display

This commit is contained in:
Eragon-Brisingr
2026-07-01 18:23:46 +08:00
parent c21efdc64e
commit 0d7db515ae
6 changed files with 141 additions and 19 deletions

View File

@@ -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);

View File

@@ -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<FString> LoadErrors;
FShaderLabSettings Settings;
/**

View File

@@ -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:

View File

@@ -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<FString> 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<FString> 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."));
}

View File

@@ -1194,3 +1194,51 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode
Material.UpdateCachedExpressionData();
return true;
}
void FShaderLabGraphBuilder::BuildPoisonInto(UMaterial& Material, const TArray<FString>& 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<UMaterialExpressionCustom>(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<UMaterialExpressionSubstrateSlabBSDF>(Material, IoY, 0);
Slab->DiffuseAlbedo.Connect(0, Custom);
EditorOnly->FrontMaterial.Connect(0, Slab);
Material.UpdateCachedExpressionData();
}

View File

@@ -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<FString>& 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<FString>& Diagnostics, const FString& SrcPath);
};