(Feat) Add Cooking and Workbench

This commit is contained in:
2026-08-01 00:03:53 +02:00
parent 3f3ae1f5fa
commit d50ef564ba
360 changed files with 5316 additions and 67 deletions
+24
View File
@@ -0,0 +1,24 @@
{
"FileVersion": 3,
"Version": 1,
"VersionName": "1.0",
"FriendlyName": "Icon Studio",
"Description": "Studio de rendu d'icones : previsualise un mesh dans une scene isolee, avec eclairage de studio et fond transparent, puis exporte un PNG et une texture UI prete a l'emploi. Traite une selection entiere de DataAssets et remplit leur champ d'icone. Plugin made by Mathew Simon.",
"Category": "Editor Tools",
"CreatedBy": "Mathew Simon",
"CreatedByURL": "",
"DocsURL": "",
"MarketplaceURL": "",
"SupportURL": "",
"CanContainContent": true,
"IsBetaVersion": false,
"IsExperimentalVersion": false,
"Installed": false,
"Modules": [
{
"Name": "IconStudio",
"Type": "Editor",
"LoadingPhase": "PostEngineInit"
}
]
}
@@ -0,0 +1,41 @@
using UnrealBuildTool;
public class IconStudio : ModuleRules
{
public IconStudio(ReadOnlyTargetRules Target) : base(Target)
{
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[]
{
"Core",
"CoreUObject",
"Engine",
// UIconStudioSettings est un UDeveloperSettings : il apparait tout seul
// dans Project Settings, sans une ligne d'enregistrement a ecrire.
"DeveloperSettings",
});
PrivateDependencyModuleNames.AddRange(new string[]
{
"UnrealEd",
"EditorSubsystem",
"Slate",
"SlateCore",
// ReadPixels et la creation de render targets passent par le RHI.
"RenderCore",
"RHI",
// FImage / FImageUtils::SaveImageByExtension pour l'ecriture du PNG.
"ImageCore",
"AssetRegistry",
// Lecture de la selection du Content Browser pour le mode batch.
"ContentBrowser",
// La fenetre est ecrite en Slate : les deux panneaux de reglages sont
// des IDetailsView du moteur, et l'onglet se range dans Window > Tools.
"PropertyEditor",
"WorkspaceMenuStructure",
// Le bouton dans la barre d'outils et l'entree du menu Tools.
"ToolMenus",
});
}
}
@@ -0,0 +1,114 @@
// Icon Studio - Mathew Simon
#include "SIconStudioTab.h"
#include "Framework/Docking/TabManager.h"
#include "Modules/ModuleManager.h"
#include "Styling/AppStyle.h"
#include "ToolMenus.h"
#include "Widgets/Docking/SDockTab.h"
#include "WorkspaceMenuStructure.h"
#include "WorkspaceMenuStructureModule.h"
#define LOCTEXT_NAMESPACE "IconStudio"
namespace IconStudioModuleLocal
{
/** Identifiant de l'onglet. Sert aussi de cle a la disposition sauvegardee. */
static const FName StudioTabName(TEXT("IconStudio"));
TSharedRef<SDockTab> SpawnStudioTab(const FSpawnTabArgs& Args)
{
return SNew(SDockTab)
.TabRole(ETabRole::NomadTab)
[
SNew(SIconStudioTab)
];
}
void OpenStudio()
{
// TryInvokeTab remet au premier plan l'onglet deja ouvert au lieu d'en
// creer un second, et le rouvre a l'endroit ou l'utilisateur l'avait
// docke la derniere fois.
FGlobalTabmanager::Get()->TryInvokeTab(StudioTabName);
}
}
/**
* Le module pose le point d'entree de l'outil : l'onglet lui-meme, un bouton
* dans la barre d'outils et une entree dans le menu Tools.
*
* Le reste s'enregistre tout seul -- le sous-systeme est instancie par le
* moteur, les reglages apparaissent dans Project Settings grace a
* UDeveloperSettings.
*/
class FIconStudioModule : public IModuleInterface
{
public:
virtual void StartupModule() override
{
using namespace IconStudioModuleLocal;
FGlobalTabmanager::Get()
->RegisterNomadTabSpawner(StudioTabName, FOnSpawnTab::CreateStatic(&SpawnStudioTab))
.SetDisplayName(LOCTEXT("IconStudioLabel", "Icon Studio"))
.SetTooltipText(LOCTEXT("IconStudioTooltip", "Render item icons from a preset: preview, transparent background, batch over DataAssets."))
.SetIcon(FSlateIcon(FAppStyle::GetAppStyleSetName(), TEXT("ClassIcon.Texture2D")))
// Le range dans Window > Tools, avec les autres outils de l'editeur.
.SetGroup(WorkspaceMenu::GetMenuStructure().GetToolsCategory());
// Le systeme de menus n'est pas forcement pret quand un module de plugin
// demarre : ce rappel est la seule facon fiable d'etendre un menu, qu'on
// soit charge avant ou apres l'editeur de niveau.
UToolMenus::RegisterStartupCallback(
FSimpleMulticastDelegate::FDelegate::CreateRaw(this, &FIconStudioModule::RegisterMenus));
}
virtual void ShutdownModule() override
{
UToolMenus::UnRegisterStartupCallback(this);
UToolMenus::UnregisterOwner(this);
if (FSlateApplication::IsInitialized())
{
FGlobalTabmanager::Get()->UnregisterNomadTabSpawner(IconStudioModuleLocal::StudioTabName);
}
}
private:
void RegisterMenus()
{
// Tout ce qui est enregistre sous ce proprietaire part d'un bloc au
// dechargement du module : sans lui, un rechargement a chaud laisserait
// un bouton mort dans la barre d'outils.
FToolMenuOwnerScoped OwnerScoped(this);
const FSlateIcon Icon(FAppStyle::GetAppStyleSetName(), TEXT("ClassIcon.Texture2D"));
const FText Label = LOCTEXT("IconStudioLabel", "Icon Studio");
const FText Tooltip = LOCTEXT("IconStudioTooltip", "Render item icons from a preset: preview, transparent background, batch over DataAssets.");
const FUIAction Action(FExecuteAction::CreateStatic(&IconStudioModuleLocal::OpenStudio));
if (UToolMenu* ToolsMenu = UToolMenus::Get()->ExtendMenu(TEXT("LevelEditor.MainMenu.Tools")))
{
FToolMenuSection& Section = ToolsMenu->FindOrAddSection(
TEXT("IconStudio"), LOCTEXT("IconStudioSection", "Icon Studio"));
Section.AddMenuEntry(TEXT("OpenIconStudio"), Label, Tooltip, Icon, Action);
}
// Section "User" de la barre d'outils : l'emplacement prevu par le
// moteur pour les outils maison, a droite des boutons natifs.
if (UToolMenu* Toolbar = UToolMenus::Get()->ExtendMenu(TEXT("LevelEditor.LevelEditorToolBar.User")))
{
FToolMenuSection& Section = Toolbar->FindOrAddSection(TEXT("IconStudio"));
Section.AddEntry(FToolMenuEntry::InitToolBarButton(
TEXT("OpenIconStudio"), Action, Label, Tooltip, Icon));
}
}
};
#undef LOCTEXT_NAMESPACE
IMPLEMENT_MODULE(FIconStudioModule, IconStudio);
@@ -0,0 +1,23 @@
// Icon Studio - Mathew Simon
#include "IconStudioPreset.h"
UIconStudioPreset::UIconStudioPreset()
{
// Eclairage de studio classique en trois points, angles donnes par rapport
// a la camera : principale en haut a gauche, deboucheur doux a droite,
// contre-jour derriere. Ces valeurs marchent telles quelles sur presque tout
// objet ; elles sont la pour avoir quelque chose de correct au premier rendu,
// pas pour rester figees.
KeyLight = FIconStudioLight(-40.f, -35.f, 5.f, /*bShadows=*/true);
FillLight = FIconStudioLight(65.f, -5.f, 1.5f, /*bShadows=*/false);
RimLight = FIconStudioLight(155.f, 25.f, 4.f, /*bShadows=*/false);
RimLight.Color = FLinearColor(0.85f, 0.92f, 1.f);
// Cubemap du moteur : sans reflet ambiant, tout ce qui est metallique sort
// noir. Reference en soft pour ne pas charger la texture tant qu'aucun rendu
// n'a ete demande.
SkyCubemap = TSoftObjectPtr<UTextureCube>(FSoftObjectPath(TEXT("/Engine/EngineMaterials/DefaultCubemap.DefaultCubemap")));
OutputDirectory.Path = TEXT("/Game/UI/Icons");
}
@@ -0,0 +1,579 @@
// Icon Studio - Mathew Simon
#include "IconStudioRenderer.h"
#include "IconStudioPreset.h"
#include "Components/DirectionalLightComponent.h"
#include "Components/SceneCaptureComponent2D.h"
#include "Components/SkyLightComponent.h"
#include "Components/StaticMeshComponent.h"
#include "ContentStreaming.h"
#include "Engine/StaticMesh.h"
#include "Engine/Texture2D.h"
#include "Engine/TextureCube.h"
#include "Engine/TextureRenderTarget2D.h"
#include "Engine/World.h"
#include "Materials/MaterialInterface.h"
#include "PreviewScene.h"
#include "RenderingThread.h"
#include "TextureResource.h"
namespace IconStudioRendererLocal
{
/** Un pixel de la scene de rendu, avant reduction. */
struct FSample
{
FLinearColor Color = FLinearColor::Black;
bool bCovered = false;
};
/**
* Compose la rotation d'une lumiere dans le repere de la camera.
*
* L'ordre compte : c'est la rotation camera qui porte l'offset, et non
* l'inverse. Ecrit dans l'autre sens, incliner la camera vers le bas ferait
* pivoter les lumieres autour d'un axe qui n'a rien a voir.
*/
FRotator ComposeLightRotation(const FRotator& CameraRotation, const FIconStudioLight& Light)
{
const FQuat Composed = CameraRotation.Quaternion() * FRotator(Light.Pitch, Light.Yaw, 0.f).Quaternion();
return Composed.Rotator();
}
}
FIconStudioRenderer::FIconStudioRenderer()
{
}
FIconStudioRenderer::~FIconStudioRenderer()
{
// La scene detient ses composants : les detruire soi-meme laisserait des
// proxies de rendu enregistres dans un monde qui n'existe plus.
Scene.Reset();
}
void FIconStudioRenderer::AddReferencedObjects(FReferenceCollector& Collector)
{
Collector.AddReferencedObject(MeshComponent);
Collector.AddReferencedObject(CaptureComponent);
Collector.AddReferencedObject(KeyLightComponent);
Collector.AddReferencedObject(FillLightComponent);
Collector.AddReferencedObject(RimLightComponent);
Collector.AddReferencedObject(SkyLightComponent);
Collector.AddReferencedObject(ColorTarget);
Collector.AddReferencedObject(DepthTarget);
Collector.AddReferencedObject(PreviewTexture);
Collector.AddReferencedObject(AppliedSkyCubemap);
}
void FIconStudioRenderer::EnsureScene()
{
if (Scene.IsValid())
{
return;
}
// Pas d'eclairage par defaut : le studio pose lui-meme ses trois lumieres,
// et une directionnelle surnumeraire fausserait tous les presets. Pas de
// scene physique non plus, rien ne tombe ici.
FPreviewScene::ConstructionValues CVS;
CVS.SetCreateDefaultLighting(false)
.SetCreatePhysicsScene(false)
.ShouldSimulatePhysics(false)
.SetTransactional(false)
.AllowAudioPlayback(false)
.SetForceMipsResident(true);
Scene = MakeUnique<FPreviewScene>(CVS);
MeshComponent = NewObject<UStaticMeshComponent>(GetTransientPackage(), NAME_None, RF_Transient);
MeshComponent->SetMobility(EComponentMobility::Movable);
Scene->AddComponent(MeshComponent, FTransform::Identity);
auto MakeLight = [this](TObjectPtr<UDirectionalLightComponent>& Out)
{
Out = NewObject<UDirectionalLightComponent>(GetTransientPackage(), NAME_None, RF_Transient);
Out->SetMobility(EComponentMobility::Movable);
// Une directionnelle en mouvement declenche des cascades d'ombres
// dimensionnees pour un niveau entier ; ici l'objet tient dans quelques
// dizaines de centimetres, d'ou une seule cascade tres courte.
Out->DynamicShadowDistanceMovableLight = 2000.f;
Out->DynamicShadowCascades = 1;
Scene->AddComponent(Out, FTransform::Identity);
};
MakeLight(KeyLightComponent);
MakeLight(FillLightComponent);
MakeLight(RimLightComponent);
SkyLightComponent = NewObject<USkyLightComponent>(GetTransientPackage(), NAME_None, RF_Transient);
SkyLightComponent->SetMobility(EComponentMobility::Movable);
SkyLightComponent->SourceType = SLS_SpecifiedCubemap;
SkyLightComponent->bLowerHemisphereIsBlack = false;
SkyLightComponent->CastShadows = false;
SkyLightComponent->bRealTimeCapture = false;
Scene->AddComponent(SkyLightComponent, FTransform::Identity);
CaptureComponent = NewObject<USceneCaptureComponent2D>(GetTransientPackage(), NAME_None, RF_Transient);
CaptureComponent->SetMobility(EComponentMobility::Movable);
// La capture est declenchee a la main : sans monde tique, "chaque image" ne
// veut rien dire, et on ne veut surtout pas d'un rendu par frame d'editeur.
CaptureComponent->bCaptureEveryFrame = false;
CaptureComponent->bCaptureOnMovement = false;
CaptureComponent->bAlwaysPersistRenderingState = false;
CaptureComponent->PrimitiveRenderMode = ESceneCapturePrimitiveRenderMode::PRM_RenderScenePrimitives;
CaptureComponent->CompositeMode = SCCM_Overwrite;
Scene->AddComponent(CaptureComponent, FTransform::Identity);
}
void FIconStudioRenderer::ApplyLighting(const UIconStudioPreset* Preset, const FRotator& CameraRotation)
{
using namespace IconStudioRendererLocal;
auto Setup = [&CameraRotation](UDirectionalLightComponent* Component, const FIconStudioLight& Light)
{
Component->SetVisibility(Light.bEnabled);
if (!Light.bEnabled)
{
return;
}
Component->SetWorldRotation(ComposeLightRotation(CameraRotation, Light));
Component->SetIntensity(Light.Intensity);
Component->SetLightColor(Light.Color);
Component->SetCastShadows(Light.bCastShadows);
};
Setup(KeyLightComponent, Preset->KeyLight);
Setup(FillLightComponent, Preset->FillLight);
Setup(RimLightComponent, Preset->RimLight);
// Recapturer le ciel est l'operation la plus chere de toute la mise en place :
// on ne la refait que si la cubemap ou son intensite ont reellement change.
UTextureCube* const Cubemap = Preset->SkyCubemap.LoadSynchronous();
const bool bSkyDirty = (Cubemap != AppliedSkyCubemap) || !FMath::IsNearlyEqual(AppliedSkyIntensity, Preset->SkyIntensity);
SkyLightComponent->SetVisibility(Cubemap != nullptr && Preset->SkyIntensity > 0.f);
SkyLightComponent->SetLightColor(Preset->SkyColor);
if (bSkyDirty)
{
SkyLightComponent->Cubemap = Cubemap;
SkyLightComponent->SetIntensity(Preset->SkyIntensity);
SkyLightComponent->RecaptureSky();
Scene->UpdateCaptureContents();
AppliedSkyCubemap = Cubemap;
AppliedSkyIntensity = Preset->SkyIntensity;
}
}
void FIconStudioRenderer::ApplyCamera(const UIconStudioPreset* Preset, const FRotator& CameraRotation, const FBoxSphereBounds& Bounds)
{
const FRotationMatrix CameraBasis(CameraRotation);
const FVector Forward = CameraBasis.GetUnitAxis(EAxis::X);
const FVector Right = CameraBasis.GetUnitAxis(EAxis::Y);
const FVector Up = CameraBasis.GetUnitAxis(EAxis::Z);
// Etendue reelle de la boite englobante une fois projetee sur les axes de la
// camera. Se contenter du rayon de la sphere englobante, comme on le voit
// souvent, cadre bien trop large des qu'un objet est allonge : une planche
// occuperait le tiers de son icone.
const FVector Extent = Bounds.BoxExtent;
auto ProjectExtent = [&Extent](const FVector& Axis)
{
return FMath::Abs(Axis.X * Extent.X) + FMath::Abs(Axis.Y * Extent.Y) + FMath::Abs(Axis.Z * Extent.Z);
};
const float HalfWidth = ProjectExtent(Right);
const float HalfHeight = ProjectExtent(Up);
const float HalfDepth = ProjectExtent(Forward);
const float Padding = 1.f + FMath::Max(0.f, Preset->FramingPadding);
const float FrameHalfSize = Preset->bUseFixedScale
? FMath::Max(1.f, Preset->FixedFrameWidth) * 0.5f
: FMath::Max(HalfWidth, HalfHeight) * Padding;
// Recul : de quoi degager la profondeur de l'objet, plus une marge pour que
// le plan proche ne vienne jamais trancher dedans.
const float Distance = Preset->bOrthographic
? HalfDepth + FrameHalfSize * 2.f + 100.f
: FrameHalfSize / FMath::Tan(FMath::DegreesToRadians(Preset->FieldOfView * 0.5f)) + HalfDepth;
const FVector Offset = Right * (Preset->FramingOffset.X * FrameHalfSize * 2.f)
+ Up * (Preset->FramingOffset.Y * FrameHalfSize * 2.f);
CaptureComponent->SetWorldLocationAndRotation(Bounds.Origin - Forward * Distance - Offset, CameraRotation);
CaptureComponent->ProjectionType = Preset->bOrthographic ? ECameraProjectionMode::Orthographic : ECameraProjectionMode::Perspective;
CaptureComponent->OrthoWidth = FrameHalfSize * 2.f;
CaptureComponent->FOVAngle = Preset->FieldOfView;
CaptureComponent->bOverride_CustomNearClippingPlane = true;
CaptureComponent->CustomNearClippingPlane = 1.f;
// Seuil de vide : tout ce qui est au-dela ne peut plus etre l'objet. Genereux
// a dessein, la valeur du fond etant de plusieurs ordres de grandeur au-dessus.
DepthThreshold = Distance + HalfDepth * 2.f + FrameHalfSize * 4.f + 1000.f;
}
void FIconStudioRenderer::ApplyPostProcess(const UIconStudioPreset* Preset)
{
FPostProcessSettings& PP = CaptureComponent->PostProcessSettings;
PP = FPostProcessSettings();
CaptureComponent->PostProcessBlendWeight = 1.f;
// Exposition figee. L'exposition automatique est le piege numero un de ce
// genre d'outil : elle compense la luminosite moyenne de l'image, donc un
// objet sombre serait eclairci et un objet clair assombri, et deux icones
// cote a cote n'auraient plus la meme lumiere alors que le preset, lui,
// n'a pas bouge.
PP.bOverride_AutoExposureMethod = true;
PP.AutoExposureMethod = AEM_Manual;
PP.bOverride_AutoExposureBias = true;
PP.AutoExposureBias = Preset->ExposureBias;
PP.bOverride_AutoExposureMinBrightness = true;
PP.AutoExposureMinBrightness = 1.f;
PP.bOverride_AutoExposureMaxBrightness = true;
PP.AutoExposureMaxBrightness = 1.f;
PP.bOverride_BloomIntensity = true;
PP.BloomIntensity = Preset->BloomIntensity;
// Tout ce qui simule un objectif photo n'a rien a faire sur une icone : le
// vignettage assombrit les coins, l'aberration colore les bords, le flou de
// mouvement bave sur un objet qui ne bouge pas.
PP.bOverride_VignetteIntensity = true;
PP.VignetteIntensity = 0.f;
PP.bOverride_SceneFringeIntensity = true;
PP.SceneFringeIntensity = 0.f;
PP.bOverride_MotionBlurAmount = true;
PP.MotionBlurAmount = 0.f;
PP.bOverride_DepthOfFieldFocalDistance = true;
PP.DepthOfFieldFocalDistance = 0.f;
PP.bOverride_AmbientOcclusionIntensity = true;
PP.AmbientOcclusionIntensity = Preset->bAmbientOcclusion ? 0.5f : 0.f;
FEngineShowFlags& Flags = CaptureComponent->ShowFlags;
// L'antialiasing du moteur doit rester eteint : le masque de transparence
// vient de la profondeur, qui n'est jamais lissee, alors que la couleur le
// serait -- les deux ne coincideraient plus sur les bords. La suranalyse
// fait ce travail bien mieux, et de facon identique sur les deux passes.
Flags.SetAntiAliasing(false);
Flags.SetTemporalAA(false);
Flags.SetMotionBlur(false);
// Rien de tout cela n'existe dans une scene de studio, mais un show flag
// laisse actif suffit a colorer le fond ou a voiler l'objet.
Flags.SetAtmosphere(false);
Flags.SetFog(false);
Flags.SetVolumetricFog(false);
Flags.SetGrid(false);
Flags.SetSelectionOutline(false);
Flags.SetLensFlares(false);
Flags.SetScreenSpaceReflections(false);
Flags.SetToneCurve(true);
Flags.SetEyeAdaptation(true);
}
UTextureRenderTarget2D* FIconStudioRenderer::EnsureTarget(TObjectPtr<UTextureRenderTarget2D>& Target, int32 Size, bool bFloatingPoint)
{
if (Target && Target->SizeX == Size && Target->SizeY == Size)
{
return Target;
}
Target = NewObject<UTextureRenderTarget2D>(GetTransientPackage(), NAME_None, RF_Transient);
// RTF_RGBA8 et non sa variante sRGB : la capture LDR ecrit deja des valeurs
// encodees en gamma, un second encodage par le materiel les delaverait.
Target->RenderTargetFormat = bFloatingPoint ? RTF_RGBA16f : RTF_RGBA8;
Target->ClearColor = FLinearColor::Black;
Target->bAutoGenerateMips = false;
Target->InitAutoFormat(Size, Size);
Target->UpdateResourceImmediate(true);
return Target;
}
bool FIconStudioRenderer::CaptureTo(UTextureRenderTarget2D* Target, uint8 CaptureSource)
{
if (!Target)
{
return false;
}
CaptureComponent->TextureTarget = Target;
CaptureComponent->CaptureSource = static_cast<ESceneCaptureSource>(CaptureSource);
// Le monde de preview n'est pas tique : sans cet appel, les changements de
// transformation et de mesh faits juste au-dessus ne sont pas encore parvenus
// au thread de rendu, et la capture montrerait l'objet precedent.
if (UWorld* World = Scene->GetWorld())
{
World->SendAllEndOfFrameUpdates();
}
CaptureComponent->CaptureScene();
return true;
}
bool FIconStudioRenderer::Render(UStaticMesh* Mesh, UMaterialInterface* MaterialOverride, const UIconStudioPreset* Preset, EIconStudioPass Pass, FIconStudioImage& OutImage)
{
using namespace IconStudioRendererLocal;
if (!Mesh || !Preset)
{
return false;
}
EnsureScene();
MeshComponent->EmptyOverrideMaterials();
MeshComponent->SetStaticMesh(Mesh);
if (MaterialOverride)
{
MeshComponent->SetMaterial(0, MaterialOverride);
}
MeshComponent->SetWorldTransform(FTransform::Identity);
// Sans ca, une texture encore en cours de streaming rend l'icone floue --
// et comme le monde n'est pas tique, le streaming ne rattraperait jamais son
// retard tout seul. StreamAllResources bloque le temps qu'il faut ; c'est
// acceptable pour un outil d'editeur, et c'est la seule facon d'avoir un
// resultat reproductible entre le premier rendu et les suivants.
MeshComponent->SetTextureForceResidentFlag(true);
MeshComponent->PrestreamTextures(60.f, /*bPrioritizeCharacterTextures=*/false);
IStreamingManager::Get().StreamAllResources(0.f);
const FRotator CameraRotation(Preset->CameraPitch, Preset->CameraYaw, 0.f);
const FBoxSphereBounds Bounds = Mesh->GetBounds();
ApplyLighting(Preset, CameraRotation);
ApplyCamera(Preset, CameraRotation, Bounds);
ApplyPostProcess(Preset);
const int32 FinalSize = FMath::Max(16, Preset->Resolution);
const int32 Supersample = FMath::Clamp(Preset->SupersampleFactor, 1, 8);
const int32 RenderSize = FinalSize * Supersample;
UTextureRenderTarget2D* const Color = EnsureTarget(ColorTarget, RenderSize, /*bFloatingPoint=*/false);
UTextureRenderTarget2D* const Depth = EnsureTarget(DepthTarget, RenderSize, /*bFloatingPoint=*/true);
// Passe de masque d'abord : c'est elle qui decide de ce qui existe, et la
// passe de couleur ne fait que l'habiller.
CaptureTo(Depth, SCS_SceneDepth);
TArray<FFloat16Color> DepthPixels;
if (!Depth->GameThread_GetRenderTargetResource()->ReadFloat16Pixels(DepthPixels))
{
return false;
}
uint8 ColorSource = SCS_FinalColorLDR;
bool bColorIsSRGB = true;
switch (Pass)
{
case EIconStudioPass::BaseColor:
ColorSource = SCS_BaseColor;
// Le G-Buffer stocke l'albedo tel quel : le lire comme du sRGB
// l'eclaircirait deux fois.
bColorIsSRGB = false;
break;
case EIconStudioPass::Normal:
ColorSource = SCS_Normal;
bColorIsSRGB = false;
break;
case EIconStudioPass::Depth:
ColorSource = SCS_DeviceDepth;
bColorIsSRGB = false;
break;
default:
break;
}
CaptureTo(Color, ColorSource);
TArray<FColor> ColorPixels;
FReadSurfaceDataFlags ReadFlags(RCM_UNorm);
ReadFlags.SetLinearToGamma(false);
if (!Color->GameThread_GetRenderTargetResource()->ReadPixels(ColorPixels, ReadFlags))
{
return false;
}
const int32 Expected = RenderSize * RenderSize;
if (ColorPixels.Num() < Expected || DepthPixels.Num() < Expected)
{
return false;
}
// ------------------------------------------------------------------
// Reduction : un pixel final = SupersampleFactor^2 pixels de rendu.
// La couleur est moyennee sur les seuls sous-pixels couverts, ce qui donne
// directement une couleur non premultipliee -- pas de division par l'alpha,
// donc pas de bruit sur les bords tres transparents.
// ------------------------------------------------------------------
TArray<FLinearColor> Accum;
TArray<float> Coverage;
Accum.SetNumZeroed(FinalSize * FinalSize);
Coverage.SetNumZeroed(FinalSize * FinalSize);
const float SubPixelCount = static_cast<float>(Supersample * Supersample);
for (int32 Y = 0; Y < FinalSize; ++Y)
{
for (int32 X = 0; X < FinalSize; ++X)
{
FLinearColor Sum = FLinearColor(0.f, 0.f, 0.f, 0.f);
int32 Covered = 0;
for (int32 SubY = 0; SubY < Supersample; ++SubY)
{
const int32 SourceRow = (Y * Supersample + SubY) * RenderSize;
for (int32 SubX = 0; SubX < Supersample; ++SubX)
{
const int32 SourceIndex = SourceRow + X * Supersample + SubX;
const float SampleDepth = DepthPixels[SourceIndex].R.GetFloat();
if (!(SampleDepth > 0.f && SampleDepth < DepthThreshold))
{
continue;
}
const FColor& Raw = ColorPixels[SourceIndex];
// Moyenner des valeurs encodees en gamma assombrit les
// degrades ; on repasse en lineaire le temps de l'operation.
// L'alpha du render target n'a lui aucun sens ici -- il est
// force a 1 pour ne compter que les sous-pixels, jamais leur
// canal alpha parasite.
FLinearColor Sample = bColorIsSRGB
? FLinearColor(Raw)
: FLinearColor(Raw.R / 255.f, Raw.G / 255.f, Raw.B / 255.f, 1.f);
Sample.A = 1.f;
Sum += Sample;
++Covered;
}
}
const int32 Index = Y * FinalSize + X;
Coverage[Index] = Covered / SubPixelCount;
Accum[Index] = Covered > 0 ? Sum / static_cast<float>(Covered) : FLinearColor(0.f, 0.f, 0.f, 0.f);
}
}
// ------------------------------------------------------------------
// Etalement des bords : un pixel entierement transparent n'a aucune couleur
// definie. Laisse a noir, il se melange aux pixels voisins des que l'icone
// est filtree ou reduite, et l'objet se retrouve cerne d'un liseré sombre.
// On propage donc la couleur du bord vers l'exterieur, sans toucher a l'alpha.
// ------------------------------------------------------------------
for (int32 PassIndex = 0; PassIndex < FMath::Clamp(Preset->EdgeBleedPasses, 0, 8); ++PassIndex)
{
TArray<FLinearColor> Bled = Accum;
bool bAnyFilled = false;
for (int32 Y = 0; Y < FinalSize; ++Y)
{
for (int32 X = 0; X < FinalSize; ++X)
{
const int32 Index = Y * FinalSize + X;
if (Coverage[Index] > 0.f || Accum[Index].A > 0.f)
{
continue;
}
FLinearColor Sum(0.f, 0.f, 0.f, 0.f);
int32 Count = 0;
for (int32 OffsetY = -1; OffsetY <= 1; ++OffsetY)
{
for (int32 OffsetX = -1; OffsetX <= 1; ++OffsetX)
{
const int32 NeighbourX = X + OffsetX;
const int32 NeighbourY = Y + OffsetY;
if (NeighbourX < 0 || NeighbourY < 0 || NeighbourX >= FinalSize || NeighbourY >= FinalSize)
{
continue;
}
const int32 NeighbourIndex = NeighbourY * FinalSize + NeighbourX;
if (Coverage[NeighbourIndex] > 0.f || Accum[NeighbourIndex].A > 0.f)
{
Sum += Accum[NeighbourIndex];
++Count;
}
}
}
if (Count > 0)
{
Bled[Index] = Sum / static_cast<float>(Count);
// L'alpha sert ici de marqueur "ce pixel a recu une couleur",
// pour que la passe suivante puisse s'appuyer dessus. Il est
// remis a la couverture reelle au moment de l'encodage.
Bled[Index].A = 1.f;
bAnyFilled = true;
}
}
}
if (!bAnyFilled)
{
break;
}
Accum = MoveTemp(Bled);
}
// ------------------------------------------------------------------
// Encodage final
// ------------------------------------------------------------------
const bool bSolid = Preset->BackgroundMode == EIconStudioBackground::SolidColor;
const FLinearColor Background = Preset->BackgroundColor;
OutImage.Size = FinalSize;
OutImage.Pixels.SetNumUninitialized(FinalSize * FinalSize);
for (int32 Index = 0; Index < FinalSize * FinalSize; ++Index)
{
const float Alpha = Coverage[Index];
FLinearColor Linear = Accum[Index];
Linear.A = 1.f;
if (bSolid)
{
// Le fond opaque est compose ici, et non rendu : changer sa couleur
// ne coute alors pas un seul rendu, et les deux modes partagent
// exactement le meme masque.
Linear = Linear * Alpha + Background * (1.f - Alpha);
Linear.A = 1.f;
}
FColor Encoded = bColorIsSRGB ? Linear.ToFColor(/*bSRGB=*/true) : Linear.ToFColor(/*bSRGB=*/false);
Encoded.A = bSolid ? 255 : static_cast<uint8>(FMath::RoundToInt(FMath::Clamp(Alpha, 0.f, 1.f) * 255.f));
OutImage.Pixels[Index] = Encoded;
}
return true;
}
UTexture2D* FIconStudioRenderer::UpdatePreviewTexture(const FIconStudioImage& Image)
{
if (!Image.IsValid())
{
return PreviewTexture;
}
if (!PreviewTexture || PreviewTexture->GetSizeX() != Image.Size || PreviewTexture->GetSizeY() != Image.Size)
{
// Pas d'AddToRoot : le renderer est un FGCObject, il declare deja cette
// texture dans AddReferencedObjects. L'enraciner en plus la rendrait
// immortelle, y compris apres la fermeture de l'outil.
PreviewTexture = UTexture2D::CreateTransient(Image.Size, Image.Size, PF_B8G8R8A8);
PreviewTexture->SRGB = true;
PreviewTexture->Filter = TF_Bilinear;
}
FTexture2DMipMap& Mip = PreviewTexture->GetPlatformData()->Mips[0];
void* Data = Mip.BulkData.Lock(LOCK_READ_WRITE);
FMemory::Memcpy(Data, Image.Pixels.GetData(), Image.Pixels.Num() * sizeof(FColor));
Mip.BulkData.Unlock();
PreviewTexture->UpdateResource();
return PreviewTexture;
}
@@ -0,0 +1,24 @@
// Icon Studio - Mathew Simon
#include "IconStudioSession.h"
#if WITH_EDITOR
void UIconStudioSession::PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent)
{
Super::PostEditChangeProperty(PropertyChangedEvent);
// Modifier la liste du lot ne change rien a l'image affichee : inutile de
// relancer un rendu complet a chaque ligne ajoutee.
const FName PropertyName = PropertyChangedEvent.GetPropertyName();
static const TSet<FName> PreviewProperties = {
GET_MEMBER_NAME_CHECKED(UIconStudioSession, Preset),
GET_MEMBER_NAME_CHECKED(UIconStudioSession, Subject),
GET_MEMBER_NAME_CHECKED(UIconStudioSession, Pass),
};
if (PreviewProperties.Contains(PropertyName))
{
OnSessionChanged.Broadcast();
}
}
#endif
@@ -0,0 +1,13 @@
// Icon Studio - Mathew Simon
#include "IconStudioSettings.h"
UIconStudioSettings::UIconStudioSettings()
{
// Ordre volontaire : un DataAsset qui exposerait a la fois un mesh "en main"
// et un mesh "au sol" doit rendre celui qu'on voit dans le monde.
MeshPropertyNames = { TEXT("WorldMesh"), TEXT("Mesh"), TEXT("StaticMesh") };
MaterialOverridePropertyName = TEXT("WorldMeshMaterial");
IconPropertyName = TEXT("Icon");
DisplayNamePropertyName = TEXT("ItemId");
}
@@ -0,0 +1,619 @@
// Icon Studio - Mathew Simon
#include "IconStudioSubsystem.h"
#include "IconStudioPreset.h"
#include "IconStudioRenderer.h"
#include "IconStudioSession.h"
#include "IconStudioSettings.h"
#include "AssetRegistry/AssetRegistryModule.h"
#include "AssetRegistry/IAssetRegistry.h"
#include "ContentBrowserModule.h"
#include "Engine/StaticMesh.h"
#include "Engine/Texture2D.h"
#include "IContentBrowserSingleton.h"
#include "ImageCore.h"
#include "ImageUtils.h"
#include "Materials/MaterialInterface.h"
#include "Misc/PackageName.h"
#include "Misc/Paths.h"
#include "Misc/ScopedSlowTask.h"
#include "ObjectTools.h"
#include "UObject/SavePackage.h"
#include "UObject/UnrealType.h"
DEFINE_LOG_CATEGORY_STATIC(LogIconStudio, Log, All);
#define LOCTEXT_NAMESPACE "IconStudio"
namespace IconStudioLocal
{
/** Lit une propriete objet par son nom, en verifiant le type attendu. */
UObject* GetObjectProperty(const UObject* Owner, FName PropertyName, const UClass* ExpectedClass)
{
if (!Owner || PropertyName.IsNone())
{
return nullptr;
}
FObjectPropertyBase* Property = FindFProperty<FObjectPropertyBase>(Owner->GetClass(), PropertyName);
if (!Property)
{
return nullptr;
}
UObject* Value = Property->GetObjectPropertyValue_InContainer(Owner);
return (Value && Value->IsA(ExpectedClass)) ? Value : nullptr;
}
/** Lit un nom lisible, quel que soit le type textuel de la propriete. */
FString GetTextualProperty(const UObject* Owner, FName PropertyName)
{
if (!Owner || PropertyName.IsNone())
{
return FString();
}
const UClass* Class = Owner->GetClass();
if (const FNameProperty* NameProperty = FindFProperty<FNameProperty>(Class, PropertyName))
{
const FName Value = NameProperty->GetPropertyValue_InContainer(Owner);
return Value.IsNone() ? FString() : Value.ToString();
}
if (const FStrProperty* StringProperty = FindFProperty<FStrProperty>(Class, PropertyName))
{
return StringProperty->GetPropertyValue_InContainer(Owner);
}
if (const FTextProperty* TextProperty = FindFProperty<FTextProperty>(Class, PropertyName))
{
return TextProperty->GetPropertyValue_InContainer(Owner).ToString();
}
return FString();
}
}
void UIconStudioSubsystem::Initialize(FSubsystemCollectionBase& Collection)
{
Super::Initialize(Collection);
Renderer = MakeUnique<FIconStudioRenderer>();
// Regler une lumiere du preset doit rafraichir l'image tout de suite, or le
// preset est edite dans son propre Details View, que l'outil ne possede pas.
// Ecouter le signal global est le seul point d'accroche qui couvre les deux
// panneaux -- d'ou le filtrage strict juste apres, cette delegation etant
// emise pour toute propriete modifiee dans l'editeur.
PropertyChangedHandle = FCoreUObjectDelegates::OnObjectPropertyChanged.AddUObject(this, &UIconStudioSubsystem::HandleObjectPropertyChanged);
}
void UIconStudioSubsystem::Deinitialize()
{
FCoreUObjectDelegates::OnObjectPropertyChanged.Remove(PropertyChangedHandle);
PropertyChangedHandle.Reset();
Renderer.Reset();
Session = nullptr;
Super::Deinitialize();
}
UIconStudioSession* UIconStudioSubsystem::GetSession()
{
if (!Session)
{
Session = NewObject<UIconStudioSession>(this, NAME_None, RF_Transient);
Session->OnSessionChanged.AddUObject(this, &UIconStudioSubsystem::HandleSessionChanged);
}
return Session;
}
void UIconStudioSubsystem::HandleSessionChanged()
{
RefreshPreview();
}
void UIconStudioSubsystem::HandleObjectPropertyChanged(UObject* Object, FPropertyChangedEvent& Event)
{
if (!Session || !Object)
{
return;
}
// Seul le preset en cours nous interesse : la session, elle, previent deja
// par son propre delegue.
if (Object == Session->Preset)
{
RefreshPreview();
}
}
void UIconStudioSubsystem::Status(const FString& Message, bool bIsError)
{
if (bIsError)
{
UE_LOG(LogIconStudio, Warning, TEXT("%s"), *Message);
}
else
{
UE_LOG(LogIconStudio, Log, TEXT("%s"), *Message);
}
OnStatus.Broadcast(Message);
}
UStaticMesh* UIconStudioSubsystem::ResolveMesh(UObject* Subject, UMaterialInterface*& OutMaterialOverride) const
{
OutMaterialOverride = nullptr;
if (!Subject)
{
return nullptr;
}
// Cas direct : on previsualise un mesh pour lui-meme.
if (UStaticMesh* AsMesh = Cast<UStaticMesh>(Subject))
{
return AsMesh;
}
const UIconStudioSettings* Settings = GetDefault<UIconStudioSettings>();
for (const FName& PropertyName : Settings->MeshPropertyNames)
{
if (UObject* Found = IconStudioLocal::GetObjectProperty(Subject, PropertyName, UStaticMesh::StaticClass()))
{
OutMaterialOverride = Cast<UMaterialInterface>(
IconStudioLocal::GetObjectProperty(Subject, Settings->MaterialOverridePropertyName, UMaterialInterface::StaticClass()));
return Cast<UStaticMesh>(Found);
}
}
return nullptr;
}
FString UIconStudioSubsystem::BuildAssetName(UObject* Subject, const UIconStudioPreset* Preset) const
{
const UIconStudioSettings* Settings = GetDefault<UIconStudioSettings>();
FString BaseName = IconStudioLocal::GetTextualProperty(Subject, Settings->DisplayNamePropertyName);
if (BaseName.IsEmpty())
{
// Retomber sur le nom de l'asset, en retirant son prefixe de convention :
// DA_Branch donnerait sinon T_Icon_DA_Branch.
BaseName = Subject->GetName();
int32 UnderscoreIndex = INDEX_NONE;
if (BaseName.FindChar(TEXT('_'), UnderscoreIndex) && UnderscoreIndex <= 3)
{
BaseName.MidInline(UnderscoreIndex + 1);
}
}
return ObjectTools::SanitizeObjectName(Preset->AssetPrefix + BaseName);
}
UTexture2D* UIconStudioSubsystem::CreateOrUpdateTexture(const FIconStudioImage& Image, const FString& PackagePath, const FString& AssetName, TArray<UPackage*>& OutTouchedPackages)
{
const FString PackageName = PackagePath / AssetName;
if (!FPackageName::IsValidLongPackageName(PackageName))
{
Status(FString::Printf(TEXT("Invalid output path: %s"), *PackageName), /*bIsError=*/true);
return nullptr;
}
// Regenerer une icone doit MODIFIER la texture existante, jamais en creer une
// seconde : tout ce qui la reference -- les DataAssets, les widgets -- doit
// continuer de pointer au meme endroit.
const FString ObjectPath = PackageName + TEXT(".") + AssetName;
UObject* Existing = LoadObject<UObject>(nullptr, *ObjectPath, nullptr, LOAD_NoWarn | LOAD_Quiet);
if (Existing && !Existing->IsA<UTexture2D>())
{
Status(FString::Printf(TEXT("%s already exists and is not a texture."), *ObjectPath), /*bIsError=*/true);
return nullptr;
}
UPackage* Package = Existing ? Existing->GetOutermost() : CreatePackage(*PackageName);
if (!Package)
{
return nullptr;
}
UTexture2D* Texture = Cast<UTexture2D>(Existing);
const bool bIsNew = (Texture == nullptr);
if (bIsNew)
{
Texture = NewObject<UTexture2D>(Package, *AssetName, RF_Public | RF_Standalone);
}
Texture->Modify();
// FColor est stocke en BGRA en memoire : la source de texture prend donc les
// pixels tels quels, sans conversion.
Texture->Source.Init(Image.Size, Image.Size, 1, 1, TSF_BGRA8, reinterpret_cast<const uint8*>(Image.Pixels.GetData()));
// Reglages d'une texture d'interface : compression qui preserve l'alpha,
// aucun mip -- une icone est affichee a taille fixe, ses mips ne serviraient
// qu'a la rendre floue -- et pas de streaming, pour qu'elle soit la des la
// premiere image du HUD.
Texture->SRGB = true;
Texture->CompressionSettings = TC_EditorIcon;
Texture->MipGenSettings = TMGS_NoMipmaps;
Texture->LODGroup = TEXTUREGROUP_UI;
Texture->NeverStream = true;
Texture->Filter = TF_Bilinear;
Texture->PostEditChange();
if (bIsNew)
{
FAssetRegistryModule::AssetCreated(Texture);
}
Package->MarkPackageDirty();
OutTouchedPackages.AddUnique(Package);
return Texture;
}
bool UIconStudioSubsystem::AssignIcon(UObject* Subject, UTexture2D* Texture, TArray<UPackage*>& OutTouchedPackages) const
{
if (!Subject || !Texture)
{
return false;
}
const UIconStudioSettings* Settings = GetDefault<UIconStudioSettings>();
if (Settings->IconPropertyName.IsNone())
{
return false;
}
FObjectPropertyBase* Property = FindFProperty<FObjectPropertyBase>(Subject->GetClass(), Settings->IconPropertyName);
if (!Property || !Property->PropertyClass || !Texture->IsA(Property->PropertyClass))
{
return false;
}
Subject->Modify();
Property->SetObjectPropertyValue_InContainer(Subject, Texture);
// Sans PostEditChangeProperty, un DataAsset qui valide ses donnees ou met en
// cache quelque chose ne verrait jamais passer la modification.
FPropertyChangedEvent ChangedEvent(Property);
Subject->PostEditChangeProperty(ChangedEvent);
Subject->MarkPackageDirty();
OutTouchedPackages.AddUnique(Subject->GetOutermost());
return true;
}
bool UIconStudioSubsystem::WritePng(const FIconStudioImage& Image, const FString& AssetName) const
{
const FString Directory = FPaths::ProjectSavedDir() / TEXT("IconStudio");
const FString FullPath = Directory / (AssetName + TEXT(".png"));
FImage Output;
Output.Init(Image.Size, Image.Size, ERawImageFormat::BGRA8, EGammaSpace::sRGB);
FMemory::Memcpy(Output.RawData.GetData(), Image.Pixels.GetData(), Image.Pixels.Num() * sizeof(FColor));
return FImageUtils::SaveImageByExtension(*FullPath, Output);
}
UTexture2D* UIconStudioSubsystem::RefreshPreview()
{
UIconStudioSession* const Current = GetSession();
// Un seul point de sortie qui previent toujours, meme en cas d'echec : c'est
// ce signal qui sert aussi a la fenetre pour raccrocher son panneau de
// preset. S'en dispenser quand il n'y a rien a rendre laisserait l'interface
// afficher les reglages du preset precedent.
UTexture2D* Preview = nullptr;
if (Current->Preset && Current->Subject && Renderer.IsValid())
{
UMaterialInterface* MaterialOverride = nullptr;
if (UStaticMesh* const Mesh = ResolveMesh(Current->Subject, MaterialOverride))
{
FIconStudioImage Image;
if (Renderer->Render(Mesh, MaterialOverride, Current->Preset, Current->Pass, Image))
{
Preview = Renderer->UpdatePreviewTexture(Image);
}
else
{
Status(TEXT("Render failed."), /*bIsError=*/true);
}
}
else
{
Status(FString::Printf(TEXT("%s exposes no static mesh to render."), *Current->Subject->GetName()), /*bIsError=*/true);
}
}
OnPreviewUpdated.Broadcast(Preview);
return Preview;
}
bool UIconStudioSubsystem::GenerateInternal(UObject* Subject, TArray<UPackage*>& OutTouchedPackages)
{
UIconStudioSession* const Current = GetSession();
const UIconStudioPreset* const Preset = EnsurePreset();
if (!Preset)
{
Status(TEXT("No preset available."), /*bIsError=*/true);
return false;
}
if (!Subject)
{
return false;
}
const UIconStudioSettings* Settings = GetDefault<UIconStudioSettings>();
if (Current->bSkipIfIconAlreadySet)
{
if (IconStudioLocal::GetObjectProperty(Subject, Settings->IconPropertyName, UTexture2D::StaticClass()))
{
Status(FString::Printf(TEXT("%s already has an icon, skipped."), *Subject->GetName()));
return false;
}
}
UMaterialInterface* MaterialOverride = nullptr;
UStaticMesh* const Mesh = ResolveMesh(Subject, MaterialOverride);
if (!Mesh)
{
Status(FString::Printf(TEXT("%s exposes no static mesh, skipped."), *Subject->GetName()), /*bIsError=*/true);
return false;
}
// La passe choisie dans la session s'applique aussi au lot : exporter les
// base colors de toute une famille d'objets est un besoin reel. C'est bien
// ce qu'on voit dans la previsualisation qui sera ecrit sur le disque.
FIconStudioImage Image;
if (!Renderer->Render(Mesh, MaterialOverride, Preset, Current->Pass, Image))
{
Status(FString::Printf(TEXT("Render failed for %s."), *Subject->GetName()), /*bIsError=*/true);
return false;
}
const FString AssetName = BuildAssetName(Subject, Preset);
if (Preset->bAlsoWritePngToDisk && !WritePng(Image, AssetName))
{
Status(FString::Printf(TEXT("Could not write the PNG for %s."), *AssetName), /*bIsError=*/true);
}
UTexture2D* const Texture = CreateOrUpdateTexture(Image, Preset->OutputDirectory.Path, AssetName, OutTouchedPackages);
if (!Texture)
{
return false;
}
if (Current->bAssignIconToDataAsset && Subject != Mesh)
{
if (AssignIcon(Subject, Texture, OutTouchedPackages))
{
Status(FString::Printf(TEXT("%s -> %s (assigned)"), *Subject->GetName(), *AssetName));
return true;
}
Status(FString::Printf(TEXT("%s -> %s (created, but no '%s' property to assign it to)"),
*Subject->GetName(), *AssetName, *Settings->IconPropertyName.ToString()));
return true;
}
Status(FString::Printf(TEXT("%s -> %s"), *Subject->GetName(), *AssetName));
return true;
}
bool UIconStudioSubsystem::SavePackageToDisk(UPackage* Package)
{
if (!Package)
{
return false;
}
const FString Filename = FPackageName::LongPackageNameToFilename(Package->GetName(), FPackageName::GetAssetPackageExtension());
FSavePackageArgs SaveArgs;
SaveArgs.TopLevelFlags = RF_Public | RF_Standalone;
SaveArgs.SaveFlags = SAVE_NoError;
SaveArgs.Error = GError;
return UPackage::SavePackage(Package, nullptr, *Filename, SaveArgs);
}
void UIconStudioSubsystem::SavePackages(const TArray<UPackage*>& Packages) const
{
if (!GetDefault<UIconStudioSettings>()->bSaveModifiedPackages)
{
return;
}
for (UPackage* Package : Packages)
{
SavePackageToDisk(Package);
}
}
UIconStudioPreset* UIconStudioSubsystem::EnsurePreset()
{
UIconStudioSession* const Current = GetSession();
if (Current->Preset)
{
return Current->Preset;
}
const IAssetRegistry& AssetRegistry = FModuleManager::LoadModuleChecked<FAssetRegistryModule>(TEXT("AssetRegistry")).Get();
// Tant que le registre balaie encore le projet, une recherche infructueuse
// ne prouve rien : creer un preset maintenant en ferait un second a cote de
// celui qu'on n'a pas encore vu.
if (AssetRegistry.IsLoadingAssets())
{
Status(TEXT("Asset registry still scanning, try again in a second."), /*bIsError=*/true);
return nullptr;
}
FARFilter Filter;
Filter.ClassPaths.Add(UIconStudioPreset::StaticClass()->GetClassPathName());
Filter.bRecursiveClasses = true;
TArray<FAssetData> Existing;
AssetRegistry.GetAssets(Filter, Existing);
if (!Existing.IsEmpty())
{
Current->Preset = Cast<UIconStudioPreset>(Existing[0].GetAsset());
if (Current->Preset)
{
Status(FString::Printf(TEXT("Using preset %s."), *Current->Preset->GetName()));
return Current->Preset;
}
}
// Aucun preset dans le projet : on en pose un, avec les valeurs par defaut
// de la classe. C'est un asset normal, que l'auteur retouche, renomme ou
// duplique ensuite comme n'importe quel autre.
static const FString DefaultPackageName(TEXT("/Game/EditorTools/DA_IconPreset_Default"));
static const FString DefaultAssetName(TEXT("DA_IconPreset_Default"));
UPackage* const Package = CreatePackage(*DefaultPackageName);
if (!Package)
{
Status(TEXT("Could not create the default preset package."), /*bIsError=*/true);
return nullptr;
}
UIconStudioPreset* const Preset = NewObject<UIconStudioPreset>(Package, *DefaultAssetName, RF_Public | RF_Standalone);
FAssetRegistryModule::AssetCreated(Preset);
Package->MarkPackageDirty();
SavePackageToDisk(Package);
Current->Preset = Preset;
Status(FString::Printf(TEXT("Created a default preset: %s"), *DefaultPackageName));
return Preset;
}
bool UIconStudioSubsystem::GenerateForSubject(UObject* Subject)
{
TArray<UPackage*> Touched;
const bool bSuccess = GenerateInternal(Subject, Touched);
SavePackages(Touched);
return bSuccess;
}
bool UIconStudioSubsystem::GenerateCurrent()
{
return GenerateForSubject(GetSession()->Subject);
}
int32 UIconStudioSubsystem::GenerateBatch()
{
UIconStudioSession* const Current = GetSession();
if (Current->BatchTargets.IsEmpty())
{
Status(TEXT("Batch list is empty."), /*bIsError=*/true);
return 0;
}
TArray<UPackage*> Touched;
int32 SuccessCount = 0;
// Un lot de deux cents objets fige l'editeur plusieurs secondes : sans barre
// de progression, l'utilisateur croit a un plantage et tue le processus au
// milieu de l'ecriture des packages.
FScopedSlowTask SlowTask(static_cast<float>(Current->BatchTargets.Num()), LOCTEXT("GeneratingIcons", "Generating icons..."));
SlowTask.MakeDialog(/*bShowCancelButton=*/true);
for (const TObjectPtr<UObject>& Target : Current->BatchTargets)
{
if (SlowTask.ShouldCancel())
{
Status(TEXT("Batch cancelled."));
break;
}
SlowTask.EnterProgressFrame(1.f, FText::FromString(Target ? Target->GetName() : TEXT("...")));
if (GenerateInternal(Target, Touched))
{
++SuccessCount;
}
}
SavePackages(Touched);
Status(FString::Printf(TEXT("Batch finished: %d / %d icons generated."), SuccessCount, Current->BatchTargets.Num()));
return SuccessCount;
}
int32 UIconStudioSubsystem::AddContentBrowserSelectionToBatch()
{
FContentBrowserModule& ContentBrowser = FModuleManager::LoadModuleChecked<FContentBrowserModule>(TEXT("ContentBrowser"));
TArray<FAssetData> Selection;
ContentBrowser.Get().GetSelectedAssets(Selection);
UIconStudioSession* const Current = GetSession();
int32 AddedCount = 0;
for (const FAssetData& Asset : Selection)
{
// GetAsset charge l'objet : c'est voulu, on a besoin de lire ses
// proprietes, et il faudrait le charger de toute facon pour le rendre.
UObject* Loaded = Asset.GetAsset();
if (!Loaded)
{
continue;
}
UMaterialInterface* Unused = nullptr;
if (!ResolveMesh(Loaded, Unused))
{
continue;
}
if (!Current->BatchTargets.Contains(Loaded))
{
Current->BatchTargets.Add(Loaded);
++AddedCount;
}
}
Status(FString::Printf(TEXT("%d asset(s) added to the batch (%d total)."), AddedCount, Current->BatchTargets.Num()));
return AddedCount;
}
bool UIconStudioSubsystem::SetSubjectFromContentBrowser()
{
FContentBrowserModule& ContentBrowser = FModuleManager::LoadModuleChecked<FContentBrowserModule>(TEXT("ContentBrowser"));
TArray<FAssetData> Selection;
ContentBrowser.Get().GetSelectedAssets(Selection);
for (const FAssetData& Asset : Selection)
{
UObject* Loaded = Asset.GetAsset();
UMaterialInterface* Unused = nullptr;
if (Loaded && ResolveMesh(Loaded, Unused))
{
GetSession()->Subject = Loaded;
RefreshPreview();
return true;
}
}
Status(TEXT("No renderable asset selected in the Content Browser."), /*bIsError=*/true);
return false;
}
void UIconStudioSubsystem::ClearBatch()
{
GetSession()->BatchTargets.Reset();
Status(TEXT("Batch list cleared."));
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,362 @@
// Icon Studio - Mathew Simon
#include "SIconStudioTab.h"
#include "IconStudioPreset.h"
#include "IconStudioSession.h"
#include "IconStudioSubsystem.h"
#include "Editor.h"
#include "Engine/Texture2D.h"
#include "IDetailsView.h"
#include "Modules/ModuleManager.h"
#include "PropertyEditorModule.h"
#include "Styling/AppStyle.h"
#include "Widgets/Images/SImage.h"
#include "Widgets/Input/SButton.h"
#include "Widgets/Layout/SBorder.h"
#include "Widgets/Layout/SScaleBox.h"
#include "Widgets/Layout/SSeparator.h"
#include "Widgets/Layout/SSplitter.h"
#include "Widgets/SBoxPanel.h"
#include "Widgets/SOverlay.h"
#include "Widgets/Text/STextBlock.h"
#define LOCTEXT_NAMESPACE "IconStudio"
namespace IconStudioTabLocal
{
/** Un bouton de la barre d'actions, tous construits pareil. */
TSharedRef<SWidget> MakeButton(const FText& Label, const FText& Tooltip, FOnClicked OnClicked)
{
return SNew(SButton)
.Text(Label)
.ToolTipText(Tooltip)
.HAlign(HAlign_Center)
.ContentPadding(FMargin(10.f, 4.f))
.OnClicked(OnClicked);
}
}
UIconStudioSubsystem* SIconStudioTab::GetStudio()
{
return GEditor ? GEditor->GetEditorSubsystem<UIconStudioSubsystem>() : nullptr;
}
void SIconStudioTab::Construct(const FArguments& InArgs)
{
PreviewBrush.DrawAs = ESlateBrushDrawType::Image;
PreviewBrush.ImageSize = FVector2D(256.f, 256.f);
StatusMessage = LOCTEXT("Ready", "Pick a preset and a subject to start.");
ChildSlot
[
SNew(SSplitter)
.Orientation(Orient_Horizontal)
+ SSplitter::Slot()
.Value(0.62f)
[
BuildPreviewPanel()
]
+ SSplitter::Slot()
.Value(0.38f)
[
BuildSettingsPanel()
]
];
// L'abonnement vient apres la construction : les panneaux de details doivent
// exister avant qu'un premier rendu ne cherche a les remplir.
if (UIconStudioSubsystem* const Studio = GetStudio())
{
PreviewHandle = Studio->OnPreviewUpdated.AddSP(this, &SIconStudioTab::HandlePreviewUpdated);
StatusHandle = Studio->OnStatus.AddSP(this, &SIconStudioTab::HandleStatus);
SessionDetails->SetObject(Studio->GetSession());
// Ouvrir la fenetre suffit a avoir un preset utilisable : celui du
// projet s'il en existe un, un neuf sinon.
Studio->EnsurePreset();
Studio->RefreshPreview();
}
}
SIconStudioTab::~SIconStudioTab()
{
// Le sous-systeme survit a la fenetre : sans desabonnement il diffuserait
// dans un widget detruit a la premiere generation suivante.
if (UIconStudioSubsystem* const Studio = GetStudio())
{
Studio->OnPreviewUpdated.Remove(PreviewHandle);
Studio->OnStatus.Remove(StatusHandle);
}
}
TSharedRef<SWidget> SIconStudioTab::BuildPreviewPanel()
{
// Le damier est le seul moyen de VOIR la transparence : sur un aplat, une
// icone au fond noir et une icone au fond transparent sont identiques, et on
// ne s'apercoit du probleme qu'une fois la texture dans le jeu.
const FSlateBrush* Checkerboard = FAppStyle::Get().GetBrush(TEXT("Checkerboard"));
const TAttribute<EVisibility> PlaceholderVisibility = TAttribute<EVisibility>::CreateLambda(
[this]() { return PreviewBrush.GetResourceObject() ? EVisibility::Collapsed : EVisibility::Visible; });
return SNew(SVerticalBox)
+ SVerticalBox::Slot()
.FillHeight(1.f)
.Padding(6.f)
[
SNew(SBorder)
.BorderImage(FAppStyle::Get().GetBrush(TEXT("ToolPanel.GroupBorder")))
.Padding(8.f)
[
SNew(SOverlay)
+ SOverlay::Slot()
[
SNew(SImage).Image(Checkerboard)
]
+ SOverlay::Slot()
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(SScaleBox)
.Stretch(EStretch::ScaleToFit)
// Jamais au-dela de la taille native : une icone de 256
// pixels etiree sur tout le panneau parait floue et donne une
// fausse idee de sa nettete reelle. On la juge a sa taille.
.StretchDirection(EStretchDirection::DownOnly)
[
SNew(SImage)
.Image(&PreviewBrush)
.Visibility(this, &SIconStudioTab::GetPreviewVisibility)
]
]
+ SOverlay::Slot()
.HAlign(HAlign_Center)
.VAlign(VAlign_Center)
[
SNew(STextBlock)
.Text(LOCTEXT("NoPreview", "No preview.\nPick a preset, then select an asset and press Use Selection."))
.Justification(ETextJustify::Center)
.Visibility(PlaceholderVisibility)
]
]
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(6.f, 0.f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(0.f, 0.f, 4.f, 0.f)
[
IconStudioTabLocal::MakeButton(
LOCTEXT("UseSelection", "Use Selection"),
LOCTEXT("UseSelectionTip", "Preview the first renderable asset selected in the Content Browser."),
FOnClicked::CreateSP(this, &SIconStudioTab::OnUseSelectionClicked))
]
+ SHorizontalBox::Slot()
.AutoWidth()
.Padding(0.f, 0.f, 4.f, 0.f)
[
IconStudioTabLocal::MakeButton(
LOCTEXT("Refresh", "Refresh"),
LOCTEXT("RefreshTip", "Render the preview again."),
FOnClicked::CreateSP(this, &SIconStudioTab::OnRefreshClicked))
]
+ SHorizontalBox::Slot()
.FillWidth(1.f)
[
IconStudioTabLocal::MakeButton(
LOCTEXT("Generate", "Generate"),
LOCTEXT("GenerateTip", "Write the icon of the previewed subject and assign it."),
FOnClicked::CreateSP(this, &SIconStudioTab::OnGenerateClicked))
]
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(6.f)
[
SNew(STextBlock)
.Text(this, &SIconStudioTab::GetStatusText)
.AutoWrapText(true)
];
}
TSharedRef<SWidget> SIconStudioTab::BuildSettingsPanel()
{
FPropertyEditorModule& PropertyEditor = FModuleManager::LoadModuleChecked<FPropertyEditorModule>(TEXT("PropertyEditor"));
FDetailsViewArgs SessionArgs;
SessionArgs.bAllowSearch = false;
SessionArgs.bHideSelectionTip = true;
SessionArgs.bShowOptions = false;
// Le bandeau de nom afficherait "IconStudioSession", ce qui n'apprend rien.
SessionArgs.NameAreaSettings = FDetailsViewArgs::HideNameArea;
SessionDetails = PropertyEditor.CreateDetailView(SessionArgs);
FDetailsViewArgs PresetArgs = SessionArgs;
// Le preset a beaucoup plus de champs : la recherche y devient utile.
PresetArgs.bAllowSearch = true;
PresetDetails = PropertyEditor.CreateDetailView(PresetArgs);
return SNew(SVerticalBox)
+ SVerticalBox::Slot()
.AutoHeight()
[
SessionDetails.ToSharedRef()
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(0.f, 4.f)
[
SNew(SSeparator)
]
+ SVerticalBox::Slot()
.FillHeight(1.f)
[
PresetDetails.ToSharedRef()
]
+ SVerticalBox::Slot()
.AutoHeight()
.Padding(4.f)
[
SNew(SHorizontalBox)
+ SHorizontalBox::Slot()
.FillWidth(1.f)
.Padding(0.f, 0.f, 4.f, 0.f)
[
IconStudioTabLocal::MakeButton(
LOCTEXT("AddSelection", "Add Selection"),
LOCTEXT("AddSelectionTip", "Add every renderable asset selected in the Content Browser to the batch."),
FOnClicked::CreateSP(this, &SIconStudioTab::OnAddSelectionClicked))
]
+ SHorizontalBox::Slot()
.FillWidth(1.f)
.Padding(0.f, 0.f, 4.f, 0.f)
[
IconStudioTabLocal::MakeButton(
LOCTEXT("GenerateBatch", "Generate Batch"),
LOCTEXT("GenerateBatchTip", "Render every asset in the batch list and assign their icons."),
FOnClicked::CreateSP(this, &SIconStudioTab::OnGenerateBatchClicked))
]
+ SHorizontalBox::Slot()
.AutoWidth()
[
IconStudioTabLocal::MakeButton(
LOCTEXT("ClearBatch", "Clear"),
LOCTEXT("ClearBatchTip", "Empty the batch list."),
FOnClicked::CreateSP(this, &SIconStudioTab::OnClearBatchClicked))
]
];
}
EVisibility SIconStudioTab::GetPreviewVisibility() const
{
return PreviewBrush.GetResourceObject() ? EVisibility::Visible : EVisibility::Collapsed;
}
void SIconStudioTab::HandlePreviewUpdated(UTexture2D* PreviewTexture)
{
if (PreviewTexture)
{
PreviewBrush.SetResourceObject(PreviewTexture);
PreviewBrush.ImageSize = FVector2D(PreviewTexture->GetSizeX(), PreviewTexture->GetSizeY());
}
else
{
PreviewBrush.SetResourceObject(nullptr);
}
// Le panneau du bas suit le preset choisi dans celui du haut. SetObject sur
// le meme objet ne fait rien, donc editer une valeur ne coupe pas la saisie
// en cours malgre le rendu declenche a chaque frappe.
if (PresetDetails.IsValid())
{
UIconStudioSubsystem* const Studio = GetStudio();
UIconStudioPreset* const Preset = Studio ? Studio->GetSession()->Preset.Get() : nullptr;
PresetDetails->SetObject(Preset);
}
}
void SIconStudioTab::HandleStatus(const FString& Message)
{
StatusMessage = FText::FromString(Message);
}
FReply SIconStudioTab::OnRefreshClicked()
{
if (UIconStudioSubsystem* const Studio = GetStudio())
{
Studio->RefreshPreview();
}
return FReply::Handled();
}
FReply SIconStudioTab::OnGenerateClicked()
{
if (UIconStudioSubsystem* const Studio = GetStudio())
{
Studio->GenerateCurrent();
}
return FReply::Handled();
}
FReply SIconStudioTab::OnUseSelectionClicked()
{
if (UIconStudioSubsystem* const Studio = GetStudio())
{
Studio->SetSubjectFromContentBrowser();
}
return FReply::Handled();
}
FReply SIconStudioTab::OnAddSelectionClicked()
{
if (UIconStudioSubsystem* const Studio = GetStudio())
{
Studio->AddContentBrowserSelectionToBatch();
}
return FReply::Handled();
}
FReply SIconStudioTab::OnGenerateBatchClicked()
{
if (UIconStudioSubsystem* const Studio = GetStudio())
{
Studio->GenerateBatch();
}
return FReply::Handled();
}
FReply SIconStudioTab::OnClearBatchClicked()
{
if (UIconStudioSubsystem* const Studio = GetStudio())
{
Studio->ClearBatch();
}
return FReply::Handled();
}
#undef LOCTEXT_NAMESPACE
@@ -0,0 +1,67 @@
// Icon Studio - Mathew Simon
#pragma once
#include "CoreMinimal.h"
#include "Styling/SlateBrush.h"
#include "Widgets/SCompoundWidget.h"
class IDetailsView;
class UIconStudioSubsystem;
class UTexture2D;
/**
* La fenetre du studio, entierement en Slate.
*
* Aucun asset a creer dans l'editeur : l'outil est complet des le chargement du
* module. En contrepartie, retoucher la mise en page demande une recompilation --
* un echange raisonnable pour un outil dont l'interface ne bouge quasiment pas.
*
* Les deux panneaux de droite sont des IDetailsView, ceux du moteur : ils
* fabriquent leurs champs a partir des UPROPERTY de la session et du preset.
* Ajouter un reglage a UIconStudioPreset le fait donc apparaitre tout seul, sans
* une ligne d'interface a ecrire.
*/
class SIconStudioTab : public SCompoundWidget
{
public:
SLATE_BEGIN_ARGS(SIconStudioTab) {}
SLATE_END_ARGS()
void Construct(const FArguments& InArgs);
virtual ~SIconStudioTab() override;
private:
static UIconStudioSubsystem* GetStudio();
TSharedRef<SWidget> BuildPreviewPanel();
TSharedRef<SWidget> BuildSettingsPanel();
void HandlePreviewUpdated(UTexture2D* PreviewTexture);
void HandleStatus(const FString& Message);
FReply OnRefreshClicked();
FReply OnGenerateClicked();
FReply OnUseSelectionClicked();
FReply OnAddSelectionClicked();
FReply OnGenerateBatchClicked();
FReply OnClearBatchClicked();
FText GetStatusText() const { return StatusMessage; }
EVisibility GetPreviewVisibility() const;
TSharedPtr<IDetailsView> SessionDetails;
TSharedPtr<IDetailsView> PresetDetails;
/**
* La brosse pointe sur la texture de previsualisation, qui est recreee des
* que la resolution change : elle est donc reassignee a chaque rendu, jamais
* construite une fois pour toutes.
*/
FSlateBrush PreviewBrush;
FText StatusMessage;
FDelegateHandle PreviewHandle;
FDelegateHandle StatusHandle;
};
@@ -0,0 +1,176 @@
// Icon Studio - Mathew Simon
#pragma once
#include "CoreMinimal.h"
#include "Engine/DataAsset.h"
#include "IconStudioTypes.h"
#include "IconStudioPreset.generated.h"
class UTextureCube;
/**
* Le "look" d'une serie d'icones : angle, cadrage, lumieres, resolution.
*
* C'est la piece centrale de l'outil. Regler ces valeurs a la main objet par
* objet donnerait deux cents icones qui ne vont pas ensemble -- des tailles
* apparentes differentes, des ombres qui changent de cote. Un preset se regle
* une fois sur un objet representatif, puis tous les autres passent dedans.
*
* Creer autant de presets que de familles d'icones : un pour l'inventaire, un
* autre pour les grandes vignettes de recettes, par exemple.
*/
UCLASS(BlueprintType)
class ICONSTUDIO_API UIconStudioPreset : public UDataAsset
{
GENERATED_BODY()
public:
UIconStudioPreset();
// ------------------------------------------------------------------
// Camera et cadrage
// ------------------------------------------------------------------
/** Rotation horizontale autour de l'objet. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera", meta = (ClampMin = "-180", ClampMax = "180", Units = "deg"))
float CameraYaw = 135.f;
/** Negatif = on regarde l'objet de haut. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera", meta = (ClampMin = "-89", ClampMax = "89", Units = "deg"))
float CameraPitch = -25.f;
/**
* Projection orthographique par defaut : sans point de fuite, une caisse
* reste une caisse et deux objets de tailles differentes se comparent a
* l'oeil. La perspective ne sert que pour un rendu de presentation.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
bool bOrthographic = true;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera", meta = (EditCondition = "!bOrthographic", ClampMin = "5", ClampMax = "120", Units = "deg"))
float FieldOfView = 30.f;
/** Marge laissee entre l'objet et le bord de l'image, en fraction de sa taille. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera", meta = (ClampMin = "0", UIMax = "0.5"))
float FramingPadding = 0.06f;
/**
* Decalage fin du cadre, en fraction de la largeur d'image.
* Sert quand l'objet est visuellement desequilibre (un manche tres long).
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera", meta = (UIMin = "-0.5", UIMax = "0.5"))
FVector2D FramingOffset = FVector2D::ZeroVector;
/**
* Cadre tous les objets a la meme echelle au lieu de remplir l'image.
*
* Cadrage automatique : une pomme et un tronc occupent la meme place dans
* leur icone. Echelle fixe : le tronc est visiblement plus gros, ce qui aide
* a lire l'inventaire mais demande de choisir une taille de reference qui
* ne laisse rien deborder.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera")
bool bUseFixedScale = false;
/** Largeur du cadre en centimetres quand l'echelle est fixe. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Camera", meta = (EditCondition = "bUseFixedScale", ClampMin = "1", Units = "cm"))
float FixedFrameWidth = 100.f;
// ------------------------------------------------------------------
// Eclairage
// ------------------------------------------------------------------
/** Lumiere principale : celle qui dessine la forme. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Lighting")
FIconStudioLight KeyLight;
/** Deboucheur : remonte les ombres du cote oppose, sans ombre portee. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Lighting")
FIconStudioLight FillLight;
/** Contre-jour : decolle l'objet du fond par un liseré lumineux. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Lighting")
FIconStudioLight RimLight;
/**
* Cubemap d'ambiance. Sans elle, tout ce qui est metallique ou lisse rend
* noir : le speculaire n'a rien a reflechir dans une scene vide.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Lighting")
TSoftObjectPtr<UTextureCube> SkyCubemap;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Lighting", meta = (ClampMin = "0", UIMax = "5"))
float SkyIntensity = 1.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Lighting")
FLinearColor SkyColor = FLinearColor::White;
// ------------------------------------------------------------------
// Rendu
// ------------------------------------------------------------------
/** Cote de l'image finale, en pixels. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render", meta = (ClampMin = "16", ClampMax = "2048"))
int32 Resolution = 256;
/**
* Facteur de suranalyse : on rend a Resolution x ce facteur, puis on reduit.
*
* C'est le seul antialiasing utilisable ici. L'antialiasing temporel du
* moteur a besoin d'un historique de plusieurs images, qu'une capture unique
* n'a pas : il produirait un tremblement different a chaque rendu, et surtout
* un masque de transparence different entre les deux passes de fond.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render", meta = (ClampMin = "1", ClampMax = "8"))
int32 SupersampleFactor = 4;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render")
EIconStudioBackground BackgroundMode = EIconStudioBackground::Transparent;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render", meta = (EditCondition = "BackgroundMode == EIconStudioBackground::SolidColor", EditConditionHides))
FLinearColor BackgroundColor = FLinearColor(0.05f, 0.05f, 0.06f, 1.f);
/** Correction d'exposition, en diaphragmes. L'exposition auto est desactivee. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render", meta = (UIMin = "-5", UIMax = "5"))
float ExposureBias = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render", meta = (ClampMin = "0", UIMax = "2"))
float BloomIntensity = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render")
bool bAmbientOcclusion = true;
/**
* Etale la couleur des bords sur les pixels transparents.
*
* Un pixel entierement transparent n'a pas de couleur definie ; laisse a
* noir, il ressort en liseré sombre des que l'icone est filtree ou reduite.
* Le defaut classique des icones generees.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Render", meta = (ClampMin = "0", ClampMax = "8"))
int32 EdgeBleedPasses = 2;
// ------------------------------------------------------------------
// Sortie
// ------------------------------------------------------------------
/** Dossier de destination des textures generees. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Output", meta = (ContentDir))
FDirectoryPath OutputDirectory;
/** Prefixe des assets crees. Le suffixe vient du nom de l'objet source. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Output")
FString AssetPrefix = TEXT("T_Icon_");
/**
* Ecrit aussi un PNG dans Saved/IconStudio/.
* Utile pour retoucher une icone dans un logiciel externe ; l'asset, lui,
* est cree dans tous les cas.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Output")
bool bAlsoWritePngToDisk = true;
/** Resolution effective du rendu avant reduction. */
int32 GetRenderResolution() const { return Resolution * FMath::Clamp(SupersampleFactor, 1, 8); }
};
@@ -0,0 +1,112 @@
// Icon Studio - Mathew Simon
#pragma once
#include "CoreMinimal.h"
#include "UObject/GCObject.h"
#include "IconStudioTypes.h"
class FPreviewScene;
class UDirectionalLightComponent;
class UIconStudioPreset;
class UMaterialInterface;
class USceneCaptureComponent2D;
class USkyLightComponent;
class UStaticMesh;
class UStaticMeshComponent;
class UTexture2D;
class UTextureCube;
class UTextureRenderTarget2D;
/**
* Le moteur de rendu du studio : une scene isolee, trois lumieres et une
* capture, reutilisees d'un objet a l'autre.
*
* Rien de tout cela ne touche au niveau ouvert dans l'editeur. FPreviewScene
* cree son propre UWorld, invisible et non tique, comme celui de l'editeur de
* Static Mesh -- on peut donc generer un lot entier pendant qu'une carte est
* chargee, sans y poser le moindre acteur. La scene et ses composants sont
* construits une seule fois puis reconfigures a chaque rendu : recreer un UWorld
* par icone couterait bien plus cher que tout le reste du travail.
*
* COMMENT LA TRANSPARENCE EST OBTENUE
*
* Une capture de scene ne fournit pas de canal alpha exploitable : elle efface
* toujours sa cible en noir opaque, sans tenir compte de la couleur de fond du
* render target, et l'alpha qui ressort du post-process ne vaut quelque chose
* qu'avec le support alpha active pour le projet entier -- un reglage de rendu
* global qu'un outil d'icones n'a pas a imposer.
*
* D'ou deux captures du meme point de vue : une de couleur, une de PROFONDEUR.
* La profondeur dit exactement ou se trouve de la geometrie, et c'est elle qui
* sert de masque. Comme l'antialiasing du moteur est desactive, chaque pixel de
* rendu est franc : soit objet, soit vide. La douceur des bords vient de la
* suranalyse -- on rend a quatre fois la taille finale et on moyenne, ce qui
* donne un alpha a seize niveaux et une couleur de bord juste.
*
* Limite assumee : un materiau TRANSLUCIDE n'ecrit pas dans le tampon de
* profondeur, il sera donc absent du masque. Pour du verre ou du feuillage
* translucide, passer le materiau en Masked le temps du rendu.
*/
class ICONSTUDIO_API FIconStudioRenderer : public FGCObject
{
public:
FIconStudioRenderer();
virtual ~FIconStudioRenderer();
//~ FGCObject
virtual void AddReferencedObjects(FReferenceCollector& Collector) override;
virtual FString GetReferencerName() const override { return TEXT("FIconStudioRenderer"); }
//~ End FGCObject
/**
* Rend un mesh et renvoie l'image composee, alpha compris.
* MaterialOverride ne remplace que le premier slot, comme le fait le jeu.
*/
bool Render(UStaticMesh* Mesh, UMaterialInterface* MaterialOverride, const UIconStudioPreset* Preset, EIconStudioPass Pass, FIconStudioImage& OutImage);
/**
* Recopie une image dans la texture de previsualisation et renvoie celle-ci.
*
* Une UTexture2D transitoire, et non un render target : l'image finale est
* assemblee sur le CPU (masque, reduction, etalement des bords), donc le seul
* moyen de montrer exactement ce qui sera exporte est de reinjecter ces
* pixels-la dans une texture.
*/
UTexture2D* UpdatePreviewTexture(const FIconStudioImage& Image);
UTexture2D* GetPreviewTexture() const { return PreviewTexture; }
private:
void EnsureScene();
void ApplyLighting(const UIconStudioPreset* Preset, const FRotator& CameraRotation);
void ApplyCamera(const UIconStudioPreset* Preset, const FRotator& CameraRotation, const FBoxSphereBounds& Bounds);
void ApplyPostProcess(const UIconStudioPreset* Preset);
UTextureRenderTarget2D* EnsureTarget(TObjectPtr<UTextureRenderTarget2D>& Target, int32 Size, bool bFloatingPoint);
/** Une capture sur la cible donnee. Le mode de capture change ce qui est ecrit dans les pixels. */
bool CaptureTo(UTextureRenderTarget2D* Target, uint8 CaptureSource);
TUniquePtr<FPreviewScene> Scene;
TObjectPtr<UStaticMeshComponent> MeshComponent;
TObjectPtr<USceneCaptureComponent2D> CaptureComponent;
TObjectPtr<UDirectionalLightComponent> KeyLightComponent;
TObjectPtr<UDirectionalLightComponent> FillLightComponent;
TObjectPtr<UDirectionalLightComponent> RimLightComponent;
TObjectPtr<USkyLightComponent> SkyLightComponent;
/** Couleur en 8 bits sRGB, profondeur en virgule flottante : deux formats, deux cibles. */
TObjectPtr<UTextureRenderTarget2D> ColorTarget;
TObjectPtr<UTextureRenderTarget2D> DepthTarget;
TObjectPtr<UTexture2D> PreviewTexture;
/** Cubemap deja appliquee, pour ne pas recapturer le ciel a chaque rendu. */
TObjectPtr<UTextureCube> AppliedSkyCubemap;
float AppliedSkyIntensity = -1.f;
/** Au-dela de cette distance, un pixel est du vide. Recalcule a chaque cadrage. */
float DepthThreshold = 0.f;
};
@@ -0,0 +1,67 @@
// Icon Studio - Mathew Simon
#pragma once
#include "CoreMinimal.h"
#include "UObject/Object.h"
#include "IconStudioTypes.h"
#include "IconStudioSession.generated.h"
class UIconStudioPreset;
/**
* Ce sur quoi on travaille en ce moment : le preset choisi, l'objet affiche,
* la liste du prochain lot.
*
* Cet objet n'existe que pour etre pose dans un widget Details View. C'est ce
* qui evite d'avoir a dessiner une interface : le moteur genere lui-meme les
* champs, les listes deroulantes et les selecteurs d'assets a partir des
* UPROPERTY ci-dessous. Ajouter un reglage a l'outil ne demande donc aucune
* retouche du Widget Blueprint.
*
* Il est transitoire a dessein : une session de travail ne se sauvegarde pas,
* ce qui doit survivre est dans le preset.
*/
UCLASS(BlueprintType)
class ICONSTUDIO_API UIconStudioSession : public UObject
{
GENERATED_BODY()
public:
/** Le look applique. Sans preset, rien ne se rend. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Subject")
TObjectPtr<UIconStudioPreset> Preset;
/**
* L'objet affiche dans la previsualisation : un StaticMesh directement, ou
* un DataAsset dont on ira lire le mesh par reflexion.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Subject", meta = (AllowedClasses = "/Script/Engine.StaticMesh,/Script/Engine.DataAsset"))
TObjectPtr<UObject> Subject;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Subject")
EIconStudioPass Pass = EIconStudioPass::Final;
/** Les objets traites par le bouton de lot. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Batch", meta = (AllowedClasses = "/Script/Engine.StaticMesh,/Script/Engine.DataAsset"))
TArray<TObjectPtr<UObject>> BatchTargets;
/**
* Ecrit la texture generee dans le champ d'icone du DataAsset.
* Sans effet sur un StaticMesh, qui n'a pas de champ ou ecrire.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Batch")
bool bAssignIconToDataAsset = true;
/** Laisse tranquille les objets qui ont deja une icone. Utile pour completer un lot. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Batch")
bool bSkipIfIconAlreadySet = false;
/** Prevenu des qu'un champ change, pour relancer la previsualisation. */
DECLARE_MULTICAST_DELEGATE(FOnIconStudioSessionChanged);
FOnIconStudioSessionChanged OnSessionChanged;
#if WITH_EDITOR
virtual void PostEditChangeProperty(FPropertyChangedEvent& PropertyChangedEvent) override;
#endif
};
@@ -0,0 +1,61 @@
// Icon Studio - Mathew Simon
#pragma once
#include "CoreMinimal.h"
#include "Engine/DeveloperSettings.h"
#include "IconStudioSettings.generated.h"
/**
* Le pont entre l'outil et les DataAssets du jeu.
*
* Le plugin ne connait volontairement aucune classe du projet : il ne compile
* pas contre UItemDataAsset et n'inclut aucun de ses en-tetes. Il retrouve le
* mesh et le champ d'icone par REFLEXION, a partir des noms de proprietes
* donnes ici. C'est ce qui lui permet de servir tel quel a un autre projet, ou
* a une autre famille d'assets dans celui-ci -- il suffit de changer deux noms.
*
* Les valeurs par defaut correspondent a UItemDataAsset.
*/
UCLASS(config = Editor, defaultconfig, meta = (DisplayName = "Icon Studio"))
class ICONSTUDIO_API UIconStudioSettings : public UDeveloperSettings
{
GENERATED_BODY()
public:
UIconStudioSettings();
virtual FName GetContainerName() const override { return TEXT("Project"); }
virtual FName GetCategoryName() const override { return TEXT("Plugins"); }
/**
* Proprietes ou chercher le mesh a rendre, dans l'ordre.
* La premiere qui existe et pointe vers un StaticMesh gagne.
*/
UPROPERTY(config, EditAnywhere, Category = "Data Asset Binding")
TArray<FName> MeshPropertyNames;
/** Propriete ou chercher un materiau qui remplace celui du mesh. Vide pour ne pas en chercher. */
UPROPERTY(config, EditAnywhere, Category = "Data Asset Binding")
FName MaterialOverridePropertyName;
/** Propriete Texture2D a remplir avec l'icone generee. */
UPROPERTY(config, EditAnywhere, Category = "Data Asset Binding")
FName IconPropertyName;
/**
* Propriete servant a nommer l'asset produit (un FName ou un FString).
* Vide, ou introuvable, on retombe sur le nom de l'asset source.
*/
UPROPERTY(config, EditAnywhere, Category = "Data Asset Binding")
FName DisplayNamePropertyName;
/**
* Sauve les packages modifies a la fin d'un lot.
*
* Decoche, les textures generees et les DataAssets modifies restent
* seulement en memoire : un editeur ferme sans Save All perd tout le lot.
*/
UPROPERTY(config, EditAnywhere, Category = "Batch")
bool bSaveModifiedPackages = true;
};
@@ -0,0 +1,133 @@
// Icon Studio - Mathew Simon
#pragma once
#include "CoreMinimal.h"
#include "EditorSubsystem.h"
#include "IconStudioRenderer.h"
#include "IconStudioTypes.h"
#include "IconStudioSubsystem.generated.h"
class UIconStudioPreset;
class UIconStudioSession;
class UMaterialInterface;
class UStaticMesh;
class UTexture2D;
// Delegues C++ et non dynamiques : la fenetre est un widget Slate, qui n'est pas
// un UObject et ne peut donc pas s'abonner a un delegue dynamique. Les fonctions
// de l'outil restent BlueprintCallable pour rester pilotables depuis un script.
DECLARE_MULTICAST_DELEGATE_OneParam(FOnIconStudioPreviewUpdated, UTexture2D* /*PreviewTexture*/);
DECLARE_MULTICAST_DELEGATE_OneParam(FOnIconStudioStatus, const FString& /*Message*/);
/**
* L'API de l'outil, cote Blueprint comme cote C++.
*
* Un sous-systeme d'editeur plutot qu'un objet possede par le widget : la scene
* de rendu coute cher a construire, et un Editor Utility Widget est detruit et
* recree a chaque fois qu'on ferme son onglet. Poser l'etat ici, c'est retrouver
* son preset et sa liste de lot en rouvrant la fenetre, et ne payer la mise en
* place du monde de preview qu'une fois par session d'editeur.
*
* Le widget ne fait donc que trois choses : afficher la texture renvoyee par
* RefreshPreview, montrer la session dans un Details View, et appeler les deux
* boutons de generation.
*/
UCLASS()
class ICONSTUDIO_API UIconStudioSubsystem : public UEditorSubsystem
{
GENERATED_BODY()
public:
//~ USubsystem
virtual void Initialize(FSubsystemCollectionBase& Collection) override;
virtual void Deinitialize() override;
//~ End USubsystem
/** L'objet a poser dans le Details View de la fenetre. */
UFUNCTION(BlueprintCallable, BlueprintPure = false, Category = "Icon Studio")
UIconStudioSession* GetSession();
/**
* Garantit qu'un preset est selectionne : reprend celui du projet s'il en
* existe un, en cree un sinon.
*
* Sans lui, l'outil s'ouvre sur une fenetre qui ne fait rien tant qu'on n'a
* pas devine qu'il fallait aller creer un Data Asset dans le Content Browser.
* Un outil qui demande une manip prealable pour donner signe de vie est un
* outil qu'on croit casse.
*/
UFUNCTION(BlueprintCallable, Category = "Icon Studio")
UIconStudioPreset* EnsurePreset();
/**
* Relance le rendu et renvoie la texture a afficher.
* Renvoie null s'il manque le preset ou le sujet -- pas une erreur, juste
* une fenetre pas encore renseignee.
*/
UFUNCTION(BlueprintCallable, Category = "Icon Studio")
UTexture2D* RefreshPreview();
/** Genere l'icone d'un seul objet : PNG, texture, et affectation du champ. */
UFUNCTION(BlueprintCallable, Category = "Icon Studio")
bool GenerateForSubject(UObject* Subject);
/** Genere celle du sujet actuellement previsualise. */
UFUNCTION(BlueprintCallable, Category = "Icon Studio")
bool GenerateCurrent();
/** Traite toute la liste de lot. Renvoie le nombre d'icones ecrites. */
UFUNCTION(BlueprintCallable, Category = "Icon Studio")
int32 GenerateBatch();
/** Ajoute la selection du Content Browser au lot, sans doublon. */
UFUNCTION(BlueprintCallable, Category = "Icon Studio")
int32 AddContentBrowserSelectionToBatch();
/** Prend le premier asset selectionne dans le Content Browser comme sujet. */
UFUNCTION(BlueprintCallable, Category = "Icon Studio")
bool SetSubjectFromContentBrowser();
UFUNCTION(BlueprintCallable, Category = "Icon Studio")
void ClearBatch();
/**
* Emis a chaque tentative de rendu, reussie ou non -- avec un pointeur nul
* dans le second cas. La fenetre s'en sert aussi pour raccrocher son panneau
* de preset, qui doit suivre meme quand il n'y a rien a afficher.
*/
FOnIconStudioPreviewUpdated OnPreviewUpdated;
/** Ligne de statut lisible, affichee sous les boutons. */
FOnIconStudioStatus OnStatus;
private:
/** Retrouve le mesh a rendre, que le sujet soit un mesh ou un DataAsset. */
UStaticMesh* ResolveMesh(UObject* Subject, UMaterialInterface*& OutMaterialOverride) const;
/** Nom de l'asset produit, prefixe compris. */
FString BuildAssetName(UObject* Subject, const UIconStudioPreset* Preset) const;
UTexture2D* CreateOrUpdateTexture(const FIconStudioImage& Image, const FString& PackagePath, const FString& AssetName, TArray<UPackage*>& OutTouchedPackages);
bool AssignIcon(UObject* Subject, UTexture2D* Texture, TArray<UPackage*>& OutTouchedPackages) const;
bool WritePng(const FIconStudioImage& Image, const FString& AssetName) const;
bool GenerateInternal(UObject* Subject, TArray<UPackage*>& OutTouchedPackages);
/** Ecrit un package sur le disque, sans condition. */
static bool SavePackageToDisk(UPackage* Package);
/** Ecrit les packages touches par une generation, si le reglage l'autorise. */
void SavePackages(const TArray<UPackage*>& Packages) const;
void HandleSessionChanged();
void HandleObjectPropertyChanged(UObject* Object, struct FPropertyChangedEvent& Event);
void Status(const FString& Message, bool bIsError = false);
UPROPERTY(Transient)
TObjectPtr<UIconStudioSession> Session;
TUniquePtr<FIconStudioRenderer> Renderer;
FDelegateHandle PropertyChangedHandle;
};
@@ -0,0 +1,100 @@
// Icon Studio - Mathew Simon
#pragma once
#include "CoreMinimal.h"
#include "IconStudioTypes.generated.h"
/**
* Ce que contient l'image produite.
*
* Les passes autres que Final servent au debug et a la retouche : recuperer la
* base color seule permet de recolorer une icone dans un logiciel externe sans
* refaire le rendu. Elles gardent le meme masque de transparence que la passe
* finale -- c'est lui qui decoupe l'objet, pas la passe.
*/
UENUM(BlueprintType)
enum class EIconStudioPass : uint8
{
/** Rendu complet, eclairage et post-process compris. C'est la passe des icones. */
Final UMETA(DisplayName = "Final Color"),
/** Albedo brut, sans aucune lumiere. */
BaseColor UMETA(DisplayName = "Base Color"),
/** Normales monde encodees en RGB. */
Normal UMETA(DisplayName = "World Normal"),
/** Profondeur encodee sur RGB. Utilisable comme masque, pas comme image. */
Depth UMETA(DisplayName = "Device Depth"),
};
/** Ce qu'on trouve derriere l'objet. */
UENUM(BlueprintType)
enum class EIconStudioBackground : uint8
{
/** Canal alpha decoupe sur l'objet. C'est ce qu'il faut pour l'inventaire. */
Transparent UMETA(DisplayName = "Transparent"),
/** Aplat de couleur opaque, compose apres coup sur le meme masque. */
SolidColor UMETA(DisplayName = "Solid Color"),
};
/**
* Une des trois lumieres du studio.
*
* Les angles sont RELATIFS A LA CAMERA, jamais au monde. Sans ca, changer
* l'angle de vue d'un objet deplacerait ses ombres et deux icones de la meme
* serie n'auraient pas le meme eclairage -- exactement ce qu'on cherche a
* eviter en travaillant avec des presets.
*/
USTRUCT(BlueprintType)
struct FIconStudioLight
{
GENERATED_BODY()
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Light")
bool bEnabled = true;
/** Positif = la lumiere part vers la droite de la camera. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Light", meta = (EditCondition = "bEnabled", ClampMin = "-180", ClampMax = "180", Units = "deg"))
float Yaw = 0.f;
/** Negatif = la lumiere vient d'en haut. */
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Light", meta = (EditCondition = "bEnabled", ClampMin = "-90", ClampMax = "90", Units = "deg"))
float Pitch = 0.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Light", meta = (EditCondition = "bEnabled", ClampMin = "0", UIMax = "20"))
float Intensity = 3.f;
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Light", meta = (EditCondition = "bEnabled"))
FLinearColor Color = FLinearColor::White;
/**
* Une ombre portee n'a rien a se poser dessus dans une scene vide, mais elle
* fait toute la lisibilite des creux d'un objet un peu detaille.
*/
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Light", meta = (EditCondition = "bEnabled"))
bool bCastShadows = true;
FIconStudioLight() = default;
FIconStudioLight(float InYaw, float InPitch, float InIntensity, bool bInShadows)
: bEnabled(true), Yaw(InYaw), Pitch(InPitch), Intensity(InIntensity), bCastShadows(bInShadows)
{
}
};
/**
* Image produite par le studio : BGRA 8 bits, espace sRGB, alpha NON premultiplie.
*
* Non premultiplie parce que c'est ce que UMG attend d'une texture d'interface,
* et ce que tout logiciel d'image comprend a l'ouverture du PNG.
*/
struct FIconStudioImage
{
int32 Size = 0;
TArray<FColor> Pixels;
bool IsValid() const { return Size > 0 && Pixels.Num() == Size * Size; }
};