Fix cook/runtime error

This commit is contained in:
Eragon-Brisingr
2026-07-03 17:44:09 +08:00
parent e76eb856f5
commit 530708cd29
5 changed files with 369 additions and 19 deletions

View File

@@ -2,30 +2,44 @@
#include "ShaderLabMaterialInstanceConstant.h"
#include "Engine/SpecularProfile.h"
#include "Engine/SubsurfaceProfile.h"
#include "Engine/ToonProfile.h"
#include "MaterialCachedData.h"
#include "Materials/Material.h"
#include "Materials/MaterialInstance.h"
#include "ShaderLabMaterialAssetUserData.h"
#include "ShaderLabMaterialRegistry.h"
#include "ShaderLabModel.h"
#include "UObject/AssetRegistryTagsContext.h"
#include "UObject/Package.h"
const FName UShaderLabMaterialInstanceConstant::ShaderLabPathTagName(TEXT("ShaderLabPath"));
bool UShaderLabMaterialInstanceConstant::HasOverridenBaseProperties() const
const UMaterial* UShaderLabMaterialInstanceConstant::GetShaderLabRootBase() const
{
// Force a self-contained permutation only at the ROOT — where our immediate parent is a ShaderLab
// in-memory /Script base material (which has no serialized shader map). For child instances (parent
// is another instance) fall through to stock behavior so they DEFER to and SHARE the root's shader
// map — this is what bounds the shader-map count (no combinatorial explosion across variants).
//
// The condition compares the parent's package against the ShaderLab /Script package POINTER (not a
// string name): a non-instance parent in that exact package is one of our bases.
// A ROOT instance's immediate parent is a non-instance UMaterial living in the ShaderLab /Script package.
// Compare against the package POINTER (not a string name). Child instances (parent is another instance)
// return nullptr — they defer to and share the root's shader map + derived data.
if (const UMaterial* BaseMaterial = Cast<UMaterial>(Parent))
{
if (BaseMaterial->GetOutermost() == FShaderLabMaterialRegistry::Get().GetPackage())
{
return true;
return BaseMaterial;
}
}
return nullptr;
}
bool UShaderLabMaterialInstanceConstant::HasOverridenBaseProperties() const
{
// Force a self-contained permutation only at the ROOT — where our immediate parent is a ShaderLab
// in-memory /Script base material (which has no serialized shader map). For child instances fall through
// to stock behavior so they DEFER to and SHARE the root's shader map (bounds the shader-map count).
if (GetShaderLabRootBase() != nullptr)
{
return true;
}
return Super::HasOverridenBaseProperties();
}
@@ -47,7 +61,168 @@ FString UShaderLabMaterialInstanceConstant::GetShaderLabPath(const UMaterialInte
return FString();
}
void UShaderLabMaterialInstanceConstant::PostLoad()
{
Super::PostLoad();
#if WITH_EDITOR
// The stock UMaterialInstanceConstant::UpdateCachedData() (called from Super::PostLoad) builds this
// instance's OWN CachedExpressionData only when it has material layers; otherwise it leaves it null and
// defers to the parent's. Our parent is the MEMORY-ONLY /Script base material, whose CachedExpressionData
// is never serialized/cooked — so any /Game asset it references (Material Parameter Collections via
// SL_COLLECTION, texture assets, functions) would be invisible to the cook's reference gathering AND never
// loaded at runtime (e.g. an MPC's uniform buffer → fatal "Failed to find parameter collection buffer").
//
// Force the ROOT instance to build its OWN CachedExpressionData by analyzing the base graph. The engine's
// standard UMaterialInterface::Serialize then harvests + serializes its hard references (see
// MaterialInterface.cpp), so the cook includes them and the runtime loads them. The data is DERIVED from the
// current base graph on every load/cook (never written to the editor .uasset), so editing the .usl needs no
// MIC re-save — the memory base stays resource-transparent.
BuildSelfContainedCachedExpressionData();
ApplyShaderLabProfileOverrides();
#endif
}
#if WITH_EDITOR
void UShaderLabMaterialInstanceConstant::RefreshShaderLabDerivedData()
{
if (GetShaderLabRootBase() == nullptr)
{
return; // Only root instances carry the derived cook-inclusion data.
}
// Invalidate the derived state so it rebuilds from the (just hot-reloaded) base graph. Without the reset,
// BuildSelfContainedCachedExpressionData early-outs on the existing copy, and bLoadedCachedExpressionData=true
// makes stock UpdateCachedData skip refreshing it — the resident MIC would keep stale references.
CachedExpressionData.Reset();
bLoadedCachedExpressionData = false;
// Clear stale profile overrides too, so a profile removed from the .usl actually drops off (rather than
// lingering because ApplyShaderLabProfileOverrides only ever sets, never clears).
bOverrideSubsurfaceProfile = false;
SubsurfaceProfile = nullptr;
bOverrideSpecularProfile = false;
SpecularProfileOverride = nullptr;
bOverrideToonProfile = false;
ToonProfileOverride = nullptr;
BuildSelfContainedCachedExpressionData();
ApplyShaderLabProfileOverrides();
}
void UShaderLabMaterialInstanceConstant::BuildSelfContainedCachedExpressionData()
{
// Only the ROOT instance (immediate parent is a ShaderLab /Script base material) owns the self-contained
// shader map; child instances share the root's map and its cached data (see HasOverridenBaseProperties).
if (GetShaderLabRootBase() == nullptr)
{
return;
}
// Respect an existing build (e.g. a layered instance that already populated its own via the stock path).
if (CachedExpressionData)
{
return;
}
UMaterial* BaseMaterial = GetMaterial();
check(BaseMaterial); // A ShaderLab root instance always resolves to its /Script base material.
// Ensure the base material's cached data reflects the current graph, then take our OWN copy of it. We copy
// rather than call FMaterialCachedExpressionData::AnalyzeMaterial directly because that method is not
// ENGINE_API-exported, whereas UpdateCachedExpressionData()/GetCachedExpressionData() are. The copy carries
// the base graph's referenced parameter collections (with StateIds), texture assets and function infos.
BaseMaterial->UpdateCachedExpressionData();
CachedExpressionData = MakeUnique<FMaterialCachedExpressionData>(BaseMaterial->GetCachedExpressionData());
// Mirror the editor-only side, exactly as the stock UMaterialInstanceConstant path does after a build.
if (UMaterialInstanceEditorOnlyData* EditorData = GetEditorOnlyData())
{
EditorData->CachedExpressionData = CachedExpressionData->EditorOnlyData;
}
// CRITICAL: the stock UMaterialInstanceConstant::UpdateCachedData() rebuilds (for layered) or RESETS TO NULL
// (for non-layered — our case) CachedExpressionData inside an `if (!bLoadedCachedExpressionData)` block. The
// cook calls that again after PostLoad (via BeginCacheForCookedPlatformData → UpdateStaticPermutation), which
// would wipe the copy we just made before it gets serialized. Marking it as "loaded" makes those subsequent
// calls skip the reset, so our copy survives to cook-save (and is re-derived fresh on the next load/cook).
bLoadedCachedExpressionData = true;
}
void UShaderLabMaterialInstanceConstant::ApplyShaderLabProfileOverrides()
{
// Substrate profiles (Subsurface/Specular/Toon) are NOT part of FMaterialCachedExpressionData — they live in
// UMaterial::SubsurfaceProfiles/SpecularProfiles/ToonProfiles on the memory-only /Script base and resolve via
// the parent chain, so the CachedExpressionData copy above does not cover them and they would be missing from
// the cook (→ profile-shaded demos render dark). Set them as this instance's OWN overrides (hard TObjectPtr
// UPROPERTYs) so the cook harvests + serializes them and the runtime loads + applies them. Resolved from the
// source `.usl` model (not the base's compiled Substrate info, which may not be populated yet at PostLoad).
const UMaterial* ImmediateBase = GetShaderLabRootBase();
if (!ImmediateBase)
{
return;
}
const TMap<FName, FShaderLabModel>& Models = FShaderLabMaterialRegistry::Get().GetRegisteredModels();
const FShaderLabModel* Model = Models.Find(ImmediateBase->GetFName());
if (!Model)
{
return;
}
// Collect the profile path of each type across the surface entry + all slabs. A MIC carries a SINGLE override
// per profile type, and at runtime an instance override replaces the profile for ALL slots — so a shader using
// two DIFFERENT profiles of the same type across slabs cannot be represented and would silently render both
// with one profile. Fail loud on that (contract-style) rather than ship a wrong material; repeats of the SAME
// path are fine.
FString SubsurfacePath, SpecularPath, ToonPath;
auto Collect = [Model](FString& Dst, const FString& Src, const TCHAR* Type)
{
if (Src.IsEmpty())
{
return;
}
checkf(Dst.IsEmpty() || Dst == Src,
TEXT("ShaderLab '%s': two distinct %s profiles across slabs ('%s' vs '%s'); a material instance carries ")
TEXT("one override per profile type, so multi-profile-per-type is unsupported. Use a single %s profile."),
*Model->ShaderName, Type, *Dst, *Src, Type);
Dst = Src;
};
Collect(SubsurfacePath, Model->SurfaceModifiers.SubsurfaceProfilePath, TEXT("Subsurface"));
Collect(SpecularPath, Model->SurfaceModifiers.SpecularProfilePath, TEXT("Specular"));
Collect(ToonPath, Model->SurfaceModifiers.ToonProfilePath, TEXT("Toon"));
for (const FShaderLabSlab& Slab : Model->Slabs)
{
Collect(SubsurfacePath, Slab.Modifiers.SubsurfaceProfilePath, TEXT("Subsurface"));
Collect(SpecularPath, Slab.Modifiers.SpecularProfilePath, TEXT("Specular"));
Collect(ToonPath, Slab.Modifiers.ToonProfilePath, TEXT("Toon"));
}
// Contract-style: an author-declared profile path that fails to load is a hard error (it would silently ship a
// dark/incorrect material) — the same class of bug this override exists to prevent.
if (!SubsurfacePath.IsEmpty())
{
USubsurfaceProfile* Profile = LoadObject<USubsurfaceProfile>(nullptr, *SubsurfacePath);
checkf(Profile, TEXT("ShaderLab '%s': SubsurfaceProfile '%s' failed to load."), *Model->ShaderName, *SubsurfacePath);
bOverrideSubsurfaceProfile = true;
SubsurfaceProfile = Profile;
}
if (!SpecularPath.IsEmpty())
{
USpecularProfile* Profile = LoadObject<USpecularProfile>(nullptr, *SpecularPath);
checkf(Profile, TEXT("ShaderLab '%s': SpecularProfile '%s' failed to load."), *Model->ShaderName, *SpecularPath);
bOverrideSpecularProfile = true;
SpecularProfileOverride = Profile;
}
if (!ToonPath.IsEmpty())
{
UToonProfile* Profile = LoadObject<UToonProfile>(nullptr, *ToonPath);
checkf(Profile, TEXT("ShaderLab '%s': ToonProfile '%s' failed to load."), *Model->ShaderName, *ToonPath);
bOverrideToonProfile = true;
ToonProfileOverride = Profile;
}
}
void UShaderLabMaterialInstanceConstant::GetAssetRegistryTags(FAssetRegistryTagsContext Context) const
{
Super::GetAssetRegistryTags(Context);

View File

@@ -32,6 +32,10 @@ public:
virtual bool HasOverridenBaseProperties() const override;
//~ End UMaterialInstance interface
//~ UObject interface
virtual void PostLoad() override;
//~ End UObject interface
#if WITH_EDITOR
//~ UObject interface
virtual void GetAssetRegistryTags(FAssetRegistryTagsContext Context) const override;
@@ -47,4 +51,63 @@ public:
* material. Works for any instance depth (child instances inherit the root's path).
*/
static FString GetShaderLabPath(const UMaterialInterface* Material);
#if WITH_EDITOR
/**
* Re-derive this ROOT instance's cook-inclusion data (CachedExpressionData copy + profile overrides) from the
* CURRENT base graph. Called by the editor's `.usl` hot-reload path after the base material is rebuilt: the
* base graph's referenced assets may have changed (MPC/profile added/removed), but BuildSelfContainedCachedExpressionData
* early-outs on the existing copy and the anti-reset flag keeps stock UpdateCachedData from refreshing it — so
* the resident MIC would otherwise keep stale data (and an in-editor iterative cook would serialize it). Resets
* the derived state first, then rebuilds. No-op on non-root instances. (The packaged BuildCookRun path loads the
* MIC fresh → PostLoad re-derives, so the shipped pak is never stale; this only hardens the in-editor session.)
*/
void RefreshShaderLabDerivedData();
/**
* Test/diagnostic: does this instance carry its OWN FMaterialCachedExpressionData (vs. falling back to the
* parent's via GetCachedExpressionData)? Only the own copy is serialized into the cook, so a regression that
* dropped it — or the anti-reset flag that keeps it alive — is only observable through this, not through
* GetCachedExpressionData() (which hides the distinction behind the parent fallback).
*/
bool HasOwnShaderLabCachedExpressionData() const { return CachedExpressionData != nullptr; }
#endif
private:
/**
* Return this instance's immediate parent iff it is a ShaderLab /Script base material (i.e. this is a ROOT
* instance — see the class comment / HasOverridenBaseProperties); nullptr otherwise. The single source of truth
* for "am I a root", used by HasOverridenBaseProperties, the cook-data builders and the hot-reload refresh.
*/
const UMaterial* GetShaderLabRootBase() const;
#if WITH_EDITOR
/**
* Give this ROOT instance its OWN FMaterialCachedExpressionData (a copy of the /Script base material's).
* Stock UMaterialInstanceConstant only builds its own for layered instances; otherwise it defers to the
* parent's. Our parent is the MEMORY-ONLY /Script base material, whose cached data is never serialized —
* so the /Game assets the base graph references (Material Parameter Collections, texture assets, functions)
* would be invisible to the cook's reference gathering AND never loaded at runtime (e.g. an MPC's uniform
* buffer → fatal "Failed to find parameter collection buffer"). Copying the base's cached data onto this
* instance makes the engine's standard UMaterialInterface::Serialize harvest + serialize those hard refs.
* FMaterialCachedExpressionData is serialized ONLY under a cooking archive (MaterialInterface.cpp), so a
* plain in-editor save does NOT persist it — it is re-derived each load/cook, keeping the memory base
* resource-transparent (editing the .usl needs no MIC re-save).
*/
void BuildSelfContainedCachedExpressionData();
/**
* Set this ROOT instance's Subsurface/Specular/Toon profile overrides from the source `.usl` model's profile
* paths. Unlike Material Parameter Collections, Substrate profiles are not part of FMaterialCachedExpressionData;
* they live on the memory-only /Script base and resolve via the parent chain, so they are never cooked. Setting
* them as this instance's own hard-ref overrides makes the cook include them and the runtime apply them.
*
* NOTE: unlike CachedExpressionData, the override fields (bOverride*Profile / *ProfileOverride) are ordinary
* UPROPERTYs — a plain in-editor save DOES persist them. That is harmless: PostLoad re-derives them every load
* (self-healing if the .usl's profile path later changes), and the generating commandlet does not save after
* PostLoad, so the checked-in demo .uassets carry no override. It is NOT cook-only-serialized the way the
* cached-expression copy is.
*/
void ApplyShaderLabProfileOverrides();
#endif
};

View File

@@ -1,5 +1,7 @@
// Copyright UShaderLab. All Rights Reserved.
using System;
using System.IO;
using UnrealBuildTool;
public class UShaderLab : ModuleRules
@@ -24,10 +26,67 @@ public class UShaderLab : ModuleRules
"Projects",
});
// Stage the .usl sources into packaged builds so the cooked runtime can rebuild base-material
// shells by re-parsing them (no serialized side data, no cook-time hook). A wildcard works here
// because .usl are source files that exist at build time (when RuntimeDependencies are globbed),
// unlike a cook-generated file. "..." matches recursively, including the top level.
RuntimeDependencies.Add("$(ProjectDir)/Shaders/.../*.usl", StagedFileType.UFS);
StageShaderLabSources(Target);
}
/// <summary>
/// Stage every `.usl` / `.uslfunc` under the project's `Shaders/` and any project plugin's `Shaders/` into the
/// pak (UFS) so the cooked runtime can re-parse them to rebuild the memory base-material shells. Config-free
/// (no Config/DefaultGame.ini edit) and plugin-generic (scans all project plugins), so it is invisible to the
/// user of the plugin.
///
/// Files are enumerated here (at build-graph generation) and added EXPLICITLY, and each shader source
/// directory is registered in ExternalDependencies so that adding/removing/editing a `.usl` changes the
/// directory's timestamp, invalidates UBT's makefile, and re-runs this enumeration. That is what avoids the
/// classic RuntimeDependencies-wildcard trap: UBT expands a wildcard once and caches the result, so files
/// added afterwards are silently dropped from the pak (which made new base materials fall back to the default
/// material at runtime).
/// </summary>
private void StageShaderLabSources(ReadOnlyTargetRules Target)
{
if (Target.ProjectFile == null)
{
return; // engine build with no project — nothing project-side to stage
}
string ProjectDir = Target.ProjectFile.Directory.FullName;
// Collect the shader roots: <Project>/Shaders and every "Shaders" dir under <Project>/Plugins.
var Roots = new System.Collections.Generic.List<string>();
string ProjectShaders = Path.Combine(ProjectDir, "Shaders");
if (Directory.Exists(ProjectShaders))
{
Roots.Add(ProjectShaders);
}
string PluginsDir = Path.Combine(ProjectDir, "Plugins");
if (Directory.Exists(PluginsDir))
{
foreach (string Dir in Directory.GetDirectories(PluginsDir, "*", SearchOption.AllDirectories))
{
if (string.Equals(Path.GetFileName(Dir), "Shaders", StringComparison.OrdinalIgnoreCase))
{
Roots.Add(Dir);
}
}
}
foreach (string Root in Roots)
{
// Watch the whole tree so an add/remove of a .usl (which bumps a directory's mtime) invalidates the
// makefile and re-triggers this enumeration on the next build.
ExternalDependencies.Add(Root);
foreach (string SubDir in Directory.GetDirectories(Root, "*", SearchOption.AllDirectories))
{
ExternalDependencies.Add(SubDir);
}
foreach (string File in Directory.GetFiles(Root, "*.usl", SearchOption.AllDirectories))
{
RuntimeDependencies.Add(File, StagedFileType.UFS);
}
foreach (string File in Directory.GetFiles(Root, "*.uslfunc", SearchOption.AllDirectories))
{
RuntimeDependencies.Add(File, StagedFileType.UFS);
}
}
}
}

View File

@@ -16,12 +16,14 @@
#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);
@@ -74,6 +76,29 @@ namespace
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

View File

@@ -226,6 +226,26 @@ namespace ShaderLabGraph
}
}
template <typename T>
static T* NewExpr(UMaterial& Material, int32& IoY, int32 Column);
// A Substrate material in the Decal domain must route its BSDF through a SubstrateConvertToDecal node — that
// node flags the material with the SSM_Decal shading model, without which the engine's Substrate sanitization
// (Material.cpp) silently resets MaterialDomain back to MD_Surface (→ DecalComponent then rejects it with
// "Decal Material must use Deferred Decal Material Domain"). This mirrors exactly what the material editor
// inserts when you pick the Deferred Decal domain. Returns the node to connect to FrontMaterial (the wrapper
// for Decal, otherwise the BSDF unchanged).
static UMaterialExpression* WrapBsdfForDecal(UMaterial& Material, const FShaderLabModel& Model, UMaterialExpression* Bsdf, int32& IoY)
{
if (Model.Settings.Domain != EShaderLabDomain::Decal)
{
return Bsdf;
}
UMaterialExpressionSubstrateConvertToDecal* Node = NewExpr<UMaterialExpressionSubstrateConvertToDecal>(Material, IoY, 300);
Node->DecalMaterial.Connect(0, Bsdf);
return Node;
}
static EBlendMode MapBlend(EShaderLabBlendMode B)
{
switch (B)
@@ -2383,14 +2403,22 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode
case EShaderLabPropertyType::Texture3D:
case EShaderLabPropertyType::TextureCubeArray:
{
// Array / volume texture object parameter. The engine assigns a matching-dimension default when
// Texture is left null; the artist binds a real asset on the Material Instance. (Built-in white/
// black/grey/normal default tokens only exist for Texture2D, so they are not applied here.)
// Array / volume texture object parameter. Built-in white/black/grey/normal default tokens are
// Texture2D-only and must NOT be applied here, but an explicit asset-path default (e.g.
// "/Engine/EngineResources/DefaultVolumeTexture") of the matching dimension is honored. Left null,
// the engine assigns a matching-dimension default and the artist binds a real asset on the instance.
UMaterialExpressionTextureObjectParameter* E = NewExpr<UMaterialExpressionTextureObjectParameter>(Material, ParamY, -1000);
E->ParameterName = Prop.Name;
E->Group = FName(*Prop.Group);
E->SortPriority = Prop.SortPriority;
E->SamplerType = SAMPLERTYPE_Color;
if (Prop.TextureDefault.StartsWith(TEXT("/")))
{
UTexture* DefaultTex = LoadObject<UTexture>(nullptr, *Prop.TextureDefault);
checkf(DefaultTex, TEXT("ShaderLab property '%s': array/volume DefaultTexture '%s' failed to load."),
*Prop.Name.ToString(), *Prop.TextureDefault);
E->Texture = DefaultTex;
}
Node.Expr = E;
Node.bIsTexture = true;
break;
@@ -2485,7 +2513,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode
{
return false;
}
EditorOnly->FrontMaterial.Connect(0, Slab);
EditorOnly->FrontMaterial.Connect(0, WrapBsdfForDecal(Material, Model, Slab, ParamY));
}
else
{
@@ -2562,7 +2590,7 @@ bool FShaderLabGraphBuilder::BuildInto(UMaterial& Material, const FShaderLabMode
{
return false;
}
EditorOnly->FrontMaterial.Connect(0, Root);
EditorOnly->FrontMaterial.Connect(0, WrapBsdfForDecal(Material, Model, Root, ParamY));
// Material-level Opacity / OpacityMask from named Value blocks.
auto ConnectMaterialOutput = [&](FExpressionInput& Pin, FName ValueName, const TCHAR* What) -> bool