// Copyright FlecsProj. All Rights Reserved. #include "DirectoryWatcherModule.h" #include "Engine/Engine.h" #include "IDirectoryWatcher.h" #include "Interfaces/IPluginManager.h" #include "MaterialShared.h" #include "Materials/Material.h" #include "Misc/Paths.h" #include "Modules/ModuleManager.h" #include "ShaderCore.h" #include "ShaderLabDiscovery.h" #include "ShaderLabGraphBuilder.h" #include "ShaderLabMaterialRegistry.h" #include "ShaderLabModel.h" #include "ShaderLabSubsystem.h" #include "UObject/Package.h" DEFINE_LOG_CATEGORY_STATIC(LogShaderLabEditor, Log, All); 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)) { for (const FString& E : Errors) { UE_LOG(LogShaderLabEditor, Error, TEXT("ShaderLab build '%s': %s"), *Model.ShaderName, *E); } return; } // 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(); } } 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 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); } } // Fill in / recompile base materials whenever the registry asks (startup + hot reload). BuildHandle = FShaderLabMaterialRegistry::Get().OnBuildMaterial().AddStatic(&BuildAndCompile); 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.")); } virtual void ShutdownModule() override { StopWatchingSources(); if (BuildHandle.IsValid()) { FShaderLabMaterialRegistry::Get().OnBuildMaterial().Remove(BuildHandle); BuildHandle.Reset(); } } private: void StartWatchingSources() { FDirectoryWatcherModule& Module = FModuleManager::LoadModuleChecked(TEXT("DirectoryWatcher")); IDirectoryWatcher* Watcher = Module.Get(); if (!Watcher) { return; } for (const FString& Root : FShaderLabDiscovery::GetSearchRoots()) { if (!FPaths::DirectoryExists(Root)) { continue; } FDelegateHandle Handle; Watcher->RegisterDirectoryChangedCallback_Handle( Root, IDirectoryWatcher::FDirectoryChanged::CreateStatic(&FShaderLabEditorModule::OnDirectoryChanged), Handle, IDirectoryWatcher::WatchOptions::IncludeDirectoryChanges); WatchedRoots.Add(Root, Handle); } } void StopWatchingSources() { if (FModuleManager::Get().IsModuleLoaded(TEXT("DirectoryWatcher"))) { FDirectoryWatcherModule& Module = FModuleManager::GetModuleChecked(TEXT("DirectoryWatcher")); if (IDirectoryWatcher* Watcher = Module.Get()) { for (const TPair& Pair : WatchedRoots) { Watcher->UnregisterDirectoryChangedCallback_Handle(Pair.Key, Pair.Value); } } } WatchedRoots.Reset(); } static void OnDirectoryChanged(const TArray& Changes) { UShaderLabSubsystem* Subsystem = GEngine ? GEngine->GetEngineSubsystem() : nullptr; if (!Subsystem) { return; } TSet Rebuilt; for (const FFileChangeData& Change : Changes) { if (!Change.Filename.EndsWith(TEXT(".usl"))) { continue; } if (Change.Action == FFileChangeData::FCA_Removed) { continue; // Leave the existing in-memory material in place on delete. } const FString Normalized = FPaths::ConvertRelativePathToFull(Change.Filename); if (Rebuilt.Contains(Normalized)) { continue; } Rebuilt.Add(Normalized); Subsystem->RebuildFromFile(Normalized); UE_LOG(LogShaderLabEditor, Log, TEXT("ShaderLab hot-reloaded '%s'"), *Normalized); } } FDelegateHandle BuildHandle; TMap WatchedRoots; }; IMPLEMENT_MODULE(FShaderLabEditorModule, UShaderLabEditor)