Fix -Game no matieral error

This commit is contained in:
Eragon-Brisingr
2026-07-05 11:26:32 +08:00
parent 799848f87f
commit a6fbdf10f8
30 changed files with 331 additions and 206 deletions

View File

@@ -36,11 +36,6 @@ namespace ShaderLabResolver_Private
return P.ToLower();
}
static bool IsTexture(const FShaderLabProperty& P)
{
return P.Type == EShaderLabPropertyType::Texture2D || P.Type == EShaderLabPropertyType::TextureCube;
}
struct FResolveState
{
const FShaderLabImportResolver::FSourceLoader& Loader;
@@ -267,7 +262,12 @@ bool FShaderLabImportResolver::Resolve(
continue;
}
Lib.PropRewrite.Add(Prop.Name, Promoted);
if (IsTexture(Prop))
// A texture property of ANY kind (2D/Cube/2DArray/3D/CubeArray) carries a companion
// `<Name>Sampler`; its promoted name must be rewritten in lockstep with the texture, or a
// namespaced import leaves the body's `XSampler` unrewritten while the emitted parameter is
// `NS_XSampler` -> undefined symbol. Use the canonical predicate (ShaderLabModel.h) so this
// stays in step with the full set of texture types.
if (ShaderLabIsTextureType(Prop.Type))
{
Lib.PropRewrite.Add(
FName(*(Prop.Name.ToString() + TEXT("Sampler"))),

View File

@@ -16,9 +16,9 @@ DEFINE_LOG_CATEGORY_STATIC(LogShaderLab, Log, All);
void FShaderLabModule::StartupModule()
{
#if !WITH_EDITOR
// In cooked builds there is no editor graph builder; apply material settings when the registry
// builds a base shell. The shell carries no shader map — instances carry their own.
FShaderLabMaterialRegistry::Get().OnBuildMaterial().AddLambda(
// In cooked builds there is no graph builder; apply material settings when the registry builds a base
// shell. The shell carries no shader map — instances carry their own.
BuildHandle = FShaderLabMaterialRegistry::Get().OnBuildMaterial().AddLambda(
[](UMaterial& Material, const FShaderLabModel& Model)
{
FShaderLabRuntimeBuilder::ApplySettings(Material, Model);
@@ -29,6 +29,13 @@ void FShaderLabModule::StartupModule()
void FShaderLabModule::ShutdownModule()
{
#if !WITH_EDITOR
if (BuildHandle.IsValid())
{
FShaderLabMaterialRegistry::Get().OnBuildMaterial().Remove(BuildHandle);
BuildHandle.Reset();
}
#endif
}
IMPLEMENT_MODULE(FShaderLabModule, UShaderLab)

View File

@@ -42,10 +42,13 @@ void UShaderLabSubsystem::DiscoverAndRegisterAll()
bScanned = true;
// Both editor and cooked runtime rebuild from the SAME .usl sources — there is no serialized
// side data. The .usl files are staged into packaged builds (a wildcard RuntimeDependency in
// ShaderLab.Build.cs) and the parser/discovery are runtime-safe, so a cooked build re-parses
// them exactly as the editor does. In the editor the bound builder compiles a graph; in a cooked
// build it only applies settings to a never-rendered base shell (the instances carry the shaders).
// side data. The .usl files are staged into packaged builds by UShaderLab.Build.cs, which ENUMERATES
// each file into RuntimeDependencies and registers the source dirs as ExternalDependencies so new/removed
// files invalidate the makefile (NOT a wildcard RuntimeDependency — that was the classic trap that
// silently dropped newly added .usl; see Build.cs + AIDoc §5). The parser/discovery are runtime-safe, so
// a cooked build re-parses them exactly as the editor does. In the editor (and `-game`) the bound builder
// compiles a graph; in a cooked build it only applies settings to a never-rendered base shell (the
// instances carry the shaders).
const TArray<FString> Files = FShaderLabDiscovery::FindShaderLabFiles();
int32 NumOk = 0;
for (const FString& File : Files)

View File

@@ -15,6 +15,14 @@ class UMaterial;
*/
DECLARE_MULTICAST_DELEGATE_TwoParams(FOnShaderLabBuildMaterial, UMaterial& /*Material*/, const FShaderLabModel& /*Model*/);
/**
* Broadcast AFTER a base material's graph has been fully (re)built and compiled by the uncooked builder.
* Split from OnBuildMaterial so the editor-only reaction (refreshing open Material Instance editors) runs
* strictly after the build, without dragging the MaterialEditor dependency into the UncookedOnly builder
* module (which must load in `-game`, where editor-UI modules are absent). No listeners in `-game`.
*/
DECLARE_MULTICAST_DELEGATE_OneParam(FOnShaderLabMaterialGraphBuilt, UMaterial& /*Material*/);
/**
* Owns the in-memory base materials. They live in a compiled-in package named "/Script/UShaderLab"
* so that saved assets (MICs) can hard-reference them by object path and have that import resolve
@@ -55,11 +63,15 @@ public:
FOnShaderLabBuildMaterial& OnBuildMaterial() { return BuildMaterialDelegate; }
/** Fired by the uncooked builder once a base material's graph is built + compiled (see delegate doc). */
FOnShaderLabMaterialGraphBuilt& OnMaterialGraphBuilt() { return MaterialGraphBuiltDelegate; }
/** Models registered so far, keyed by object name (used by the instance factory picker and the example/validation commandlets). */
const TMap<FName, FShaderLabModel>& GetRegisteredModels() const { return RegisteredModels; }
private:
UPackage* Package = nullptr;
FOnShaderLabBuildMaterial BuildMaterialDelegate;
FOnShaderLabMaterialGraphBuilt MaterialGraphBuiltDelegate;
TMap<FName, FShaderLabModel> RegisteredModels;
};

View File

@@ -10,4 +10,11 @@ class FShaderLabModule : public IModuleInterface
public:
virtual void StartupModule() override;
virtual void ShutdownModule() override;
#if !WITH_EDITOR
private:
// Handle for the cooked-runtime OnBuildMaterial->ApplySettings binding, unbound in ShutdownModule
// (symmetric with the builder/editor modules). Only exists in cooked builds where that binding is made.
FDelegateHandle BuildHandle;
#endif
};

View File

@@ -0,0 +1,167 @@
// Copyright UShaderLab. All Rights Reserved.
#include "Engine/Engine.h"
#include "HAL/FileManager.h"
#include "Interfaces/IPluginManager.h"
#include "MaterialShared.h"
#include "Materials/Material.h"
#include "Misc/Paths.h"
#include "Modules/ModuleManager.h"
#include "ShaderCore.h"
#include "ShaderLabGraphBuilder.h"
#include "ShaderLabMaterialInstanceConstant.h"
#include "ShaderLabMaterialRegistry.h"
#include "ShaderLabModel.h"
#include "UObject/UObjectIterator.h"
DEFINE_LOG_CATEGORY_STATIC(LogShaderLabBuilder, Log, All);
namespace
{
/**
* Fill a base material shell: rebuild its expression graph from the model, then recompile it and every
* dependent instance. Runs whenever the registry asks (startup discovery + editor hot reload) in ANY
* uncooked run — the editor AND a `-game` launch on the editor binary. In `-game` the project is uncooked,
* so each instance must translate its own static-permutation shader map from THIS graph at load: an empty
* shell here is exactly why materials used to fall back to the default material in `-game`.
*/
void BuildAndCompile(UMaterial& Material, const FShaderLabModel& Model)
{
// 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)
{
// 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(LogShaderLabBuilder, 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(LogShaderLabBuilder, 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);
}
}
// Recompile the base AND propagate to everything that depends on it. A bare PostEditChange
// recompiles only the base shell; the instances carry their own static-permutation shader maps
// (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();
}
// Re-derive the cook-inclusion data (CachedExpressionData copy + profile overrides) on every ROOT
// UShaderLabMaterialInstanceConstant of this base. The FMaterialUpdateContext above recompiles the
// dependent shader maps but does NOT refresh that derived data (it's built once at PostLoad and pinned by
// bLoadedCachedExpressionData=true), so a hot-reload that added/removed an MPC or profile would otherwise
// leave a resident MIC stale — and an in-editor iterative cook would serialize the stale references.
//
// Inheritance chain: only a ROOT (immediate parent == this base) carries the derived data; CHILD instances
// (parent is another instance, however deep) hold none and resolve up the chain to the root at query time,
// and their shared shader maps were just recompiled by FMaterialUpdateContext — so refreshing the roots is
// sufficient and children need no separate pass. RefreshShaderLabDerivedData self-guards via
// GetShaderLabRootBase(), so it is a safe no-op on anything that isn't a root of this base. Skip CDOs and
// objects being GC'd.
for (TObjectIterator<UShaderLabMaterialInstanceConstant> It(
/*AdditionalExclusionFlags*/ RF_ClassDefaultObject,
/*bIncludeDerivedClasses*/ true,
/*InternalExclusionFlags*/ EInternalObjectFlags::Garbage); It; ++It)
{
if (It->Parent == &Material)
{
It->RefreshShaderLabDerivedData();
}
}
// The graph is now built + compiled and the derived data refreshed. Notify the editor layer so it can
// refresh any open Material Instance editors — kept out of this UncookedOnly module because that reaction
// needs the MaterialEditor module, which is absent in `-game`. No listeners there; a no-op broadcast.
FShaderLabMaterialRegistry::Get().OnMaterialGraphBuilt().Broadcast(Material);
}
}
class FShaderLabBuilderModule : public IModuleInterface
{
public:
virtual void StartupModule() override
{
// Map the plugin's Shaders/ directory so generated Custom nodes can #include
// "/Plugin/ShaderLab/Private/ShaderLabCommon.ush" at shader-compile time.
if (const TSharedPtr<IPlugin> Plugin = IPluginManager::Get().FindPlugin(TEXT("UShaderLab")))
{
const FString ShaderDir = FPaths::Combine(Plugin->GetBaseDir(), TEXT("Shaders"));
if (!AllShaderSourceDirectoryMappings().Contains(TEXT("/Plugin/ShaderLab")))
{
AddShaderSourceDirectoryMapping(TEXT("/Plugin/ShaderLab"), ShaderDir);
}
}
// Map the project's Shaders/ directory to the virtual root "/Project" so user .ush library
// files placed there (alongside .usl) can be #included from a shader's Includes { } block.
{
const FString ProjectShaderDir = FPaths::Combine(FPaths::ProjectDir(), TEXT("Shaders"));
if (FPaths::DirectoryExists(ProjectShaderDir) && !AllShaderSourceDirectoryMappings().Contains(TEXT("/Project")))
{
AddShaderSourceDirectoryMapping(TEXT("/Project"), ProjectShaderDir);
}
}
// Map the plugin's Intermediate/ShaderLabGen directory (a build-time artifact dir) so the graph
// builder can #include per-shader generated local-code headers (`/UShaderLabGen/<Name>.gen.ush`).
// AddShaderSourceDirectoryMapping requires the real dir to already exist, so create it first.
{
const FString GenDir = FShaderLabGraphBuilder::GetGeneratedShaderDir();
const TCHAR* GenRoot = FShaderLabGraphBuilder::GetGeneratedVirtualRoot();
if (!GenDir.IsEmpty())
{
IFileManager::Get().MakeDirectory(*GenDir, /*Tree*/ true);
if (FPaths::DirectoryExists(GenDir) && !AllShaderSourceDirectoryMappings().Contains(GenRoot))
{
AddShaderSourceDirectoryMapping(GenRoot, GenDir);
}
}
}
// Fill in / recompile base materials whenever the registry asks (startup + hot reload). This is the
// binding that was previously in the Editor module and never fired in `-game`; hosting it here (a
// module loaded for all uncooked runs) is the fix for invisible materials under `-game`.
BuildHandle = FShaderLabMaterialRegistry::Get().OnBuildMaterial().AddStatic(&BuildAndCompile);
UE_LOG(LogShaderLabBuilder, Log, TEXT("ShaderLabBuilder module started."));
}
virtual void ShutdownModule() override
{
if (BuildHandle.IsValid())
{
FShaderLabMaterialRegistry::Get().OnBuildMaterial().Remove(BuildHandle);
BuildHandle.Reset();
}
}
private:
FDelegateHandle BuildHandle;
};
IMPLEMENT_MODULE(FShaderLabBuilderModule, UShaderLabBuilder)

View File

@@ -41,7 +41,6 @@
#include "ShaderLabIntrinsicRegistry.h"
#include "ShaderLabAnchorCapabilityRegistry.h"
#include "ShaderLabImportResolver.h"
#include "ShaderLabRuntimeBuilder.h"
#include "UObject/Class.h"
#include "ShaderLabSettingsApplier.h"
#include "UObject/UObjectGlobals.h"
@@ -130,31 +129,9 @@ namespace ShaderLabGraph
return Full;
}
/**
* Resolve a virtual shader path ("/Project/Lib/X.uslfunc", "/Plugin/ShaderLab/...", "/Engine/...") to an
* absolute disk path via the editor's registered shader-source directory mappings (longest-prefix match).
* Returns false if no mapping root contains the path. Used to load `.uslfunc` imports off disk.
*/
static bool ResolveVirtualShaderFile(const FString& VirtualPath, FString& OutDiskPath)
{
FString Best, BestDir;
for (const TPair<FString, FString>& Pair : AllShaderSourceDirectoryMappings())
{
if ((VirtualPath.StartsWith(Pair.Key + TEXT("/")) || VirtualPath == Pair.Key) && Pair.Key.Len() > Best.Len())
{
Best = Pair.Key;
BestDir = Pair.Value;
}
}
if (Best.IsEmpty())
{
return false;
}
FString Rest = VirtualPath.Mid(Best.Len());
Rest.RemoveFromStart(TEXT("/"));
OutDiskPath = FPaths::Combine(BestDir, Rest);
return true;
}
// Virtual-shader-path -> disk resolution is now the shared FShaderLabGraphBuilder::ResolveVirtualShaderFile
// (defined below, exposed in the header) so the editor module (hot-reload loader) and the VSCode button
// reuse the same longest-prefix-match logic instead of each hand-rolling it.
/**
* Wrap a user HLSL body so shader-compiler errors map back to the .usl source: a `#line`
@@ -2356,6 +2333,27 @@ namespace ShaderLabGraph
}
}
bool FShaderLabGraphBuilder::ResolveVirtualShaderFile(const FString& VirtualPath, FString& OutDiskPath)
{
FString Best, BestDir;
for (const TPair<FString, FString>& Pair : AllShaderSourceDirectoryMappings())
{
if ((VirtualPath.StartsWith(Pair.Key + TEXT("/")) || VirtualPath == Pair.Key) && Pair.Key.Len() > Best.Len())
{
Best = Pair.Key;
BestDir = Pair.Value;
}
}
if (Best.IsEmpty())
{
return false;
}
FString Rest = VirtualPath.Mid(Best.Len());
Rest.RemoveFromStart(TEXT("/"));
OutDiskPath = FPaths::Combine(BestDir, Rest);
return true;
}
bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabModel& Model, TArray<FString>& OutErrors)
{
using namespace ShaderLabGraph;

View File

@@ -6,7 +6,9 @@
// 1. <Plugin>/Shaders/Private/ShaderLabUENode.ush — the UE_ node-intrinsic stubs (+ their enums)
// generated from FShaderLabIntrinsicRegistry. (The static
// ShaderLab.ush umbrella includes it; it is IDE-only.)
// 2. <Project>/.vscode/settings.json — merges (non-destructively) files.associations
// 2. <Plugin>/Shaders/Private/UEFunctions/Transform.ush + TransformPosition.ush — the generated
// UE_Transform{From}VectorTo{To} / ...PositionTo{To} stubs (all space pairs).
// 3. <Project>/.vscode/settings.json — merges (non-destructively) files.associations
// (*.usl/*.uslfunc/*.usf/*.ush -> hlsl) and shader-validator.pathRemapping
// (virtual shader roots -> disk, from AllShaderSourceDirectoryMappings).
// pathRemapping is machine-specific; the command regenerates it.
@@ -414,7 +416,7 @@ namespace ShaderLabIDEPrepare_Private
static void Run(const TArray<FString>& /*Args*/)
{
const TSharedPtr<IPlugin> Plugin = IPluginManager::Get().FindPlugin(TEXT("UShaderLab"));
check(Plugin.IsValid()); // The command lives in this plugin's editor module; it must be findable.
check(Plugin.IsValid()); // The command lives in this plugin's builder module; it must be findable.
const FString PluginShaderDir = FPaths::Combine(Plugin->GetBaseDir(), TEXT("Shaders"));
const FString ProjectDir = FPaths::ConvertRelativePathToFull(FPaths::ProjectDir());
@@ -430,5 +432,5 @@ namespace ShaderLabIDEPrepare_Private
static FAutoConsoleCommand GShaderLabIDEPrepareCommand(
TEXT("ShaderLab.IDE.Prepare"),
TEXT("Generate IDE completion assets for .usl: <Plugin>/Shaders/ShaderLab.ush, shadertoolsconfig.json, .vscode/settings.json."),
TEXT("Generate IDE completion assets for .usl: Private/ShaderLabUENode.ush, Private/UEFunctions/Transform.ush + TransformPosition.ush, and .vscode/settings.json."),
FConsoleCommandWithArgsDelegate::CreateStatic(&ShaderLabIDEPrepare_Private::Run));

View File

@@ -79,7 +79,7 @@ struct FShaderLabAnchorCapability
* Distance Field) on first use; other editor modules may register their own from their StartupModule via
* Get().Register(...). The graph builder queries it once per build.
*/
class USHADERLABEDITOR_API FShaderLabAnchorCapabilityRegistry
class USHADERLABBUILDER_API FShaderLabAnchorCapabilityRegistry
{
public:
static FShaderLabAnchorCapabilityRegistry& Get();

View File

@@ -8,15 +8,16 @@ class UMaterial;
struct FShaderLabModel;
/**
* Editor-only builder that turns a parsed FShaderLabModel into a UMaterial expression graph:
* property parameter nodes + a Custom HLSL node for the Surface body wired into a Substrate
* Slab BSDF (-> FrontMaterial), plus an optional Vertex Custom node for WPO/displacement/UVs.
* Turns a parsed FShaderLabModel into a UMaterial expression graph: property parameter nodes + a Custom
* HLSL node for the Surface body wired into a Substrate Slab BSDF (-> FrontMaterial), plus an optional
* Vertex Custom node for WPO/displacement/UVs. Lives in the UncookedOnly UShaderLabBuilder module, so it
* runs in the editor AND under `-game` on uncooked data (not in cooked builds see AIDoc §2.9).
*
* The build is fully deterministic for a given model (node creation order, input/output order,
* generated HLSL) so the cook-time and editor-time graphs and therefore the baked uniform
* expression set match.
*/
class USHADERLABEDITOR_API FShaderLabGraphBuilder
class USHADERLABBUILDER_API FShaderLabGraphBuilder
{
public:
/**
@@ -36,6 +37,15 @@ public:
*/
static void BuildPoisonInto(UMaterial& Material, const TArray<FString>& Diagnostics, const FString& SrcPath);
/**
* Resolve a virtual shader path ("/Project/Lib/X.uslfunc", "/Plugin/ShaderLab/...", "/Engine/...") to an
* absolute disk path via the registered shader-source directory mappings (longest-prefix match, exact-root
* allowed). Returns false if no mapping root contains the path. The single canonical reversal of the
* shader-source mappings used to load `.uslfunc` imports, by the editor hot-reload loader, and by the
* VSCode button. (Forward direction: FShaderLabMaterialRegistry::MakeVirtualShaderPath.)
*/
static bool ResolveVirtualShaderFile(const FString& VirtualPath, FString& OutDiskPath);
/**
* Virtual shader root under which per-shader generated local-code headers live
* (e.g. "/UShaderLabGen"). The editor module maps it to GetGeneratedShaderDir() at startup.

View File

@@ -86,7 +86,7 @@ struct FShaderLabIntrinsicDesc
* other editor modules can register intrinsics for their own custom UMaterialExpression nodes from
* their StartupModule via Get().Register(...).
*/
class USHADERLABEDITOR_API FShaderLabIntrinsicRegistry
class USHADERLABBUILDER_API FShaderLabIntrinsicRegistry
{
public:
static FShaderLabIntrinsicRegistry& Get();

View File

@@ -0,0 +1,40 @@
// Copyright UShaderLab. All Rights Reserved.
using UnrealBuildTool;
// The material-graph-building core: turns a parsed .usl model into a UMaterial expression graph and
// triggers its compile. This is needed whenever the engine runs UNCOOKED data — the editor, AND a
// `-game` / Standalone launch on the editor binary against an uncooked project (GIsEditor == false).
// It must NOT be an `Editor`-type module (those are gated on GIsEditor and never load in `-game`, which
// left base materials as empty shells and made instances fall back to the default material). `UncookedOnly`
// loads iff `!RequiresCookedData()`, i.e. exactly the uncooked runs, and is excluded from cooked builds
// (where instances carry their own shader maps and the runtime module's ApplySettings path suffices).
//
// Deliberately depends only on Engine/RenderCore/RHI/Projects/Json — NO UnrealEd/MaterialEditor/Slate —
// so it is safe to load in `-game` where those editor-UI modules are not initialized. The editor-only UX
// (factory, VSCode button, hot-reload watcher, open-editor refresh) stays in UShaderLabEditor.
public class UShaderLabBuilder : ModuleRules
{
public UShaderLabBuilder(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = ModuleRules.PCHUsageMode.UseExplicitOrSharedPCHs;
bUseUnity = false;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
});
PrivateDependencyModuleNames.AddRange(new string[]
{
"UShaderLab",
"RenderCore",
"RHI",
"Json",
"Projects",
});
}
}

View File

@@ -3,108 +3,42 @@
#include "DirectoryWatcherModule.h"
#include "Editor.h"
#include "Engine/Engine.h"
#include "HAL/FileManager.h"
#include "IDirectoryWatcher.h"
#include "Interfaces/IPluginManager.h"
#include "MaterialEditingLibrary.h"
#include "MaterialShared.h"
#include "Materials/Material.h"
#include "Misc/FileHelper.h"
#include "Misc/Paths.h"
#include "Modules/ModuleManager.h"
#include "ShaderCore.h"
#include "Misc/FileHelper.h"
#include "ShaderLabDiscovery.h"
#include "ShaderLabGraphBuilder.h"
#include "ShaderLabImportResolver.h"
#include "ShaderLabMaterialInstanceConstant.h"
#include "ShaderLabMaterialRegistry.h"
#include "ShaderLabModel.h"
#include "ShaderLabParser.h"
#include "ShaderLabSubsystem.h"
#include "ShaderLabVSCodeButton.h"
#include "UObject/Package.h"
#include "UObject/UObjectIterator.h"
DEFINE_LOG_CATEGORY_STATIC(LogShaderLabEditor, Log, All);
// This module hosts only the interactive-editor UX for ShaderLab (GIsEditor-gated): the .usl directory
// watcher for hot reload, the "open in VSCode" toolbar button, the Material Instance factory, and
// refreshing open Material Instance editors after a rebuild. The material-graph-building core moved to
// UShaderLabBuilder (an UncookedOnly module) so it also runs under `-game`, where this Editor module and
// its editor-UI dependencies (UnrealEd/MaterialEditor) are absent.
namespace
{
/** Editor-side reaction to a base material needing build: rebuild graph + trigger compile. */
void BuildAndCompile(UMaterial& Material, const FShaderLabModel& Model)
/**
* Editor-only reaction to a base material finishing its (re)build: refresh any open Material Instance
* editors 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. Bound to OnMaterialGraphBuilt (not
* OnBuildMaterial) so it runs strictly AFTER the builder finished the graph + compile. Guarded on GEditor:
* the builder also runs during cook/headless registration and under `-game`, where there is no interactive
* editor (and RebuildMaterialInstanceEditors dereferences GEditor).
*/
void RefreshInstanceEditors(UMaterial& Material)
{
// 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)
{
// 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 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);
}
}
// Recompile the base AND propagate to everything that depends on it. A bare PostEditChange
// recompiles only the base shell; the instances carry their own static-permutation shader maps
// (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();
}
// Re-derive the cook-inclusion data (CachedExpressionData copy + profile overrides) on every ROOT
// UShaderLabMaterialInstanceConstant of this base. The FMaterialUpdateContext above recompiles the
// dependent shader maps but does NOT refresh that derived data (it's built once at PostLoad and pinned by
// bLoadedCachedExpressionData=true), so a hot-reload that added/removed an MPC or profile would otherwise
// leave a resident MIC stale — and an in-editor iterative cook would serialize the stale references.
//
// Inheritance chain: only a ROOT (immediate parent == this base) carries the derived data; CHILD instances
// (parent is another instance, however deep) hold none and resolve up the chain to the root at query time,
// and their shared shader maps were just recompiled by FMaterialUpdateContext — so refreshing the roots is
// sufficient and children need no separate pass. RefreshShaderLabDerivedData self-guards via
// GetShaderLabRootBase(), so it is a safe no-op on anything that isn't a root of this base. Skip CDOs and
// objects being GC'd.
for (TObjectIterator<UShaderLabMaterialInstanceConstant> It(
/*AdditionalExclusionFlags*/ RF_ClassDefaultObject,
/*bIncludeDerivedClasses*/ true,
/*InternalExclusionFlags*/ EInternalObjectFlags::Garbage); It; ++It)
{
if (It->Parent == &Material)
{
It->RefreshShaderLabDerivedData();
}
}
// 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);
@@ -117,45 +51,8 @@ class FShaderLabEditorModule : public IModuleInterface
public:
virtual void StartupModule() override
{
// Map the plugin's Shaders/ directory so generated Custom nodes can #include
// "/Plugin/ShaderLab/Private/ShaderLabCommon.ush" at shader-compile time.
if (const TSharedPtr<IPlugin> Plugin = IPluginManager::Get().FindPlugin(TEXT("UShaderLab")))
{
const FString ShaderDir = FPaths::Combine(Plugin->GetBaseDir(), TEXT("Shaders"));
if (!AllShaderSourceDirectoryMappings().Contains(TEXT("/Plugin/ShaderLab")))
{
AddShaderSourceDirectoryMapping(TEXT("/Plugin/ShaderLab"), ShaderDir);
}
}
// Map the project's Shaders/ directory to the virtual root "/Project" so user .ush library
// files placed there (alongside .usl) can be #included from a shader's Includes { } block.
{
const FString ProjectShaderDir = FPaths::Combine(FPaths::ProjectDir(), TEXT("Shaders"));
if (FPaths::DirectoryExists(ProjectShaderDir) && !AllShaderSourceDirectoryMappings().Contains(TEXT("/Project")))
{
AddShaderSourceDirectoryMapping(TEXT("/Project"), ProjectShaderDir);
}
}
// Map the plugin's Intermediate/ShaderLabGen directory (a build-time artifact dir) so the graph
// builder can #include per-shader generated local-code headers (`/UShaderLabGen/<Name>.gen.ush`).
// AddShaderSourceDirectoryMapping requires the real dir to already exist, so create it first.
{
const FString GenDir = FShaderLabGraphBuilder::GetGeneratedShaderDir();
const TCHAR* GenRoot = FShaderLabGraphBuilder::GetGeneratedVirtualRoot();
if (!GenDir.IsEmpty())
{
IFileManager::Get().MakeDirectory(*GenDir, /*Tree*/ true);
if (FPaths::DirectoryExists(GenDir) && !AllShaderSourceDirectoryMappings().Contains(GenRoot))
{
AddShaderSourceDirectoryMapping(GenRoot, GenDir);
}
}
}
// Fill in / recompile base materials whenever the registry asks (startup + hot reload).
BuildHandle = FShaderLabMaterialRegistry::Get().OnBuildMaterial().AddStatic(&BuildAndCompile);
// After the builder rebuilds a base material's graph, refresh any open MIC editors on it.
GraphBuiltHandle = FShaderLabMaterialRegistry::Get().OnMaterialGraphBuilt().AddStatic(&RefreshInstanceEditors);
StartWatchingSources();
@@ -169,10 +66,10 @@ public:
{
StopWatchingSources();
ShaderLabVSCodeButton::Unregister();
if (BuildHandle.IsValid())
if (GraphBuiltHandle.IsValid())
{
FShaderLabMaterialRegistry::Get().OnBuildMaterial().Remove(BuildHandle);
BuildHandle.Reset();
FShaderLabMaterialRegistry::Get().OnMaterialGraphBuilt().Remove(GraphBuiltHandle);
GraphBuiltHandle.Reset();
}
}
@@ -219,25 +116,6 @@ private:
WatchedRoots.Reset();
}
/** Resolve a virtual shader path to disk via the registered shader-source directory mappings. */
static bool ResolveVirtualToDisk(const FString& VirtualPath, FString& OutDiskPath)
{
FString Best, BestDir;
for (const TPair<FString, FString>& Pair : AllShaderSourceDirectoryMappings())
{
if ((VirtualPath.StartsWith(Pair.Key + TEXT("/")) || VirtualPath == Pair.Key) && Pair.Key.Len() > Best.Len())
{
Best = Pair.Key;
BestDir = Pair.Value;
}
}
if (Best.IsEmpty()) { return false; }
FString Rest = VirtualPath.Mid(Best.Len());
Rest.RemoveFromStart(TEXT("/"));
OutDiskPath = FPaths::Combine(BestDir, Rest);
return true;
}
static FString NormalizePath(const FString& Path)
{
FString N = FPaths::ConvertRelativePathToFull(Path);
@@ -269,7 +147,7 @@ private:
FShaderLabImportResolver::FSourceLoader Loader =
[](const FString& VPath, FString& OutSrc, FString& OutDisk, FString& OutErr) -> bool
{
if (!ResolveVirtualToDisk(VPath, OutDisk)) { OutErr = TEXT("unmapped"); return false; }
if (!FShaderLabGraphBuilder::ResolveVirtualShaderFile(VPath, OutDisk)) { OutErr = TEXT("unmapped"); return false; }
if (!FFileHelper::LoadFileToString(OutSrc, *OutDisk)) { OutErr = TEXT("read failed"); return false; }
return true;
};
@@ -331,7 +209,7 @@ private:
}
}
FDelegateHandle BuildHandle;
FDelegateHandle GraphBuiltHandle;
TMap<FString, FDelegateHandle> WatchedRoots;
};

View File

@@ -8,7 +8,7 @@
#include "MaterialEditorModule.h"
#include "Misc/Paths.h"
#include "Modules/ModuleManager.h"
#include "ShaderCore.h"
#include "ShaderLabGraphBuilder.h"
#include "ShaderLabMaterialInstanceConstant.h"
#include "Styling/AppStyle.h"
#include "Textures/SlateIcon.h"
@@ -22,21 +22,16 @@ namespace ShaderLabVSCodeButton
{
static FDelegateHandle GExtenderDelegateHandle;
/** Reverse the ShaderLabPath virtual path (e.g. "/Project/Examples/Basic.usl") to a disk path via the
* registered shader mappings (generic — /Project, /Plugin/<any>, /Engine). Empty if unresolved. */
/** Reverse the ShaderLabPath virtual path (e.g. "/Project/Examples/Basic.usl") to an absolute disk path
* via the shared shader-mapping resolver (generic — /Project, /Plugin/<any>, /Engine). Empty if unresolved. */
static FString ResolveVirtualToDisk(const FString& VirtualPath)
{
int32 BestKeyLen = -1;
FString BestDisk;
for (const TPair<FString, FString>& Mapping : AllShaderSourceDirectoryMappings())
FString Disk;
if (!FShaderLabGraphBuilder::ResolveVirtualShaderFile(VirtualPath, Disk))
{
if (VirtualPath.StartsWith(Mapping.Key + TEXT("/"), ESearchCase::IgnoreCase) && Mapping.Key.Len() > BestKeyLen)
{
BestKeyLen = Mapping.Key.Len();
BestDisk = Mapping.Value / VirtualPath.RightChop(Mapping.Key.Len() + 1);
}
return FString();
}
return (BestKeyLen >= 0) ? FPaths::ConvertRelativePathToFull(BestDisk) : FString();
return FPaths::ConvertRelativePathToFull(Disk);
}
static void OpenInVSCode(TWeakObjectPtr<UShaderLabMaterialInstanceConstant> MICWeak)

View File

@@ -20,6 +20,7 @@ public class UShaderLabEditor : ModuleRules
PrivateDependencyModuleNames.AddRange(new string[]
{
"UShaderLab",
"UShaderLabBuilder",
"RenderCore",
"RHI",
"Json",

View File

@@ -19,6 +19,11 @@
"Type": "Runtime",
"LoadingPhase": "Default"
},
{
"Name": "UShaderLabBuilder",
"Type": "UncookedOnly",
"LoadingPhase": "Default"
},
{
"Name": "UShaderLabEditor",
"Type": "Editor",