EM_Task/UnrealEd/Private/NormalMapIdentification.cpp
Boshuang Zhao 5144a49c9b add
2026-02-13 16:18:33 +08:00

504 lines
20 KiB
C++

// Copyright Epic Games, Inc. All Rights Reserved.
#include "NormalMapIdentification.h"
#include "Engine/Texture2D.h"
#include "Factories/TextureFactory.h"
#include "Framework/Notifications/NotificationManager.h"
#include "Widgets/Notifications/SNotificationList.h"
#define NORMALMAP_IDENTIFICATION_TIMING (0)
#define LOCTEXT_NAMESPACE "NormalMapIdentification"
////////////////////////////////////////////////////////////////////////////////
// Constant values
namespace
{
// These values may need tuning, but results so far have been good
// These values are the threshold values for the average vector's
// length to be considered within limits as a normal map normal
const float NormalMapMinLengthConfidenceThreshold = 0.55f;
const float NormalMapMaxLengthConfidenceThreshold = 1.1f;
// This value is the threshold value for the average vector to be considered
// to be going in the correct direction.
const float NormalMapDeviationThreshold = 0.8f;
// Samples from the texture will be taken in blocks of this size^2
const int32 SampleTileEdgeLength = 4;
// We sample up to this many tiles in each axis. Sampling more tiles
// will likely be more accurate, but will take longer.
const int32 MaxTilesPerAxis = 16;
// This is used in the comparison with "mid-gray"
const float ColorComponentNearlyZeroThreshold = (2.0f / 255.0f);
// This is used when comparing alpha to zero to avoid picking up sprites
const float AlphaComponentNearlyZeroThreshold = (1.0f / 255.0f);
// These values are chosen to make the threshold colors (from uint8 textures)
// discard the top most and bottom most two values, i.e. 0, 1, 254 and 255 on
// the assumption that these are likely invalid values for a general normal map
const float ColorComponentMinVectorThreshold = (2.0f / 255.0f) * 2.0f - 1.0f;
const float ColorComponentMaxVectorThreshold = (253.0f / 255.0f) * 2.0f - 1.0f;
// This is the threshold delta length for a vector to be considered as a unit vector
const float NormalVectorUnitLengthDeltaThreshold = 0.45f;
// Rejected to taken sample ratio threshold.
const float RejectedToTakenRatioThreshold = 0.33f;
} // namespace
////////////////////////////////////////////////////////////////////////////////
// Texture sampler classes
class NormalMapSamplerBase
{
public:
NormalMapSamplerBase()
: SourceTexture(NULL), TextureSizeX(0), TextureSizeY(0), SourceTextureData(NULL)
{
}
~NormalMapSamplerBase()
{
if (SourceTexture != NULL)
{
SourceTexture->Source.UnlockMip(0);
}
}
void SetSourceTexture(UTexture* Texture)
{
SourceTexture = Texture;
TextureSizeX = Texture->Source.GetSizeX();
TextureSizeY = Texture->Source.GetSizeY();
SourceTextureData = Texture->Source.LockMip(0);
}
UTexture* SourceTexture;
int32 TextureSizeX;
int32 TextureSizeY;
uint8* SourceTextureData;
};
template <int RIdx, int GIdx, int BIdx, int AIdx>
class SampleNormalMapPixel8: public NormalMapSamplerBase
{
public:
SampleNormalMapPixel8() {}
~SampleNormalMapPixel8() {}
FLinearColor DoSampleColor(int32 X, int32 Y)
{
FLinearColor Result;
uint8* PixelToSample = SourceTextureData + ((Y * TextureSizeX + X) * 4);
const float OneOver255 = 1.0f / 255.0f;
Result.B = (float)PixelToSample[BIdx] * OneOver255;
Result.G = (float)PixelToSample[GIdx] * OneOver255;
Result.R = (float)PixelToSample[RIdx] * OneOver255;
Result.A = (float)PixelToSample[AIdx] * OneOver255;
return Result;
}
float ScaleAndBiasComponent(float Value) const
{
return Value * 2.0f - 1.0f;
}
};
typedef SampleNormalMapPixel8<2, 1, 0, 3> SampleNormalMapPixelBGRA8;
typedef SampleNormalMapPixel8<0, 1, 2, 3> SampleNormalMapPixelRGBA8;
class SampleNormalMapPixelRGBA16: public NormalMapSamplerBase
{
public:
SampleNormalMapPixelRGBA16() {}
~SampleNormalMapPixelRGBA16() {}
FLinearColor DoSampleColor(int32 X, int32 Y)
{
FLinearColor Result;
uint8* PixelToSample = SourceTextureData + ((Y * TextureSizeX + X) * 8);
const float OneOver65535 = 1.0f / 65535.0f;
Result.R = (float)((uint16*)PixelToSample)[0] * OneOver65535;
Result.G = (float)((uint16*)PixelToSample)[1] * OneOver65535;
Result.B = (float)((uint16*)PixelToSample)[2] * OneOver65535;
Result.A = (float)((uint16*)PixelToSample)[3] * OneOver65535;
return Result;
}
float ScaleAndBiasComponent(float Value) const
{
return Value * 2.0f - 1.0f;
}
};
class SampleNormalMapPixelF16: public NormalMapSamplerBase
{
public:
SampleNormalMapPixelF16() {}
~SampleNormalMapPixelF16() {}
FLinearColor DoSampleColor(int32 X, int32 Y)
{
FLinearColor Result;
uint8* PixelToSample = SourceTextureData + ((Y * TextureSizeX + X) * 8);
// this assume the normal map to be in linear (not the case if Photoshop converts a 8bit normalmap to float and saves it as 16bit dds)
Result.R = (float)((FFloat16*)PixelToSample)[0];
Result.G = (float)((FFloat16*)PixelToSample)[1];
Result.B = (float)((FFloat16*)PixelToSample)[2];
Result.A = (float)((FFloat16*)PixelToSample)[3];
return Result;
}
float ScaleAndBiasComponent(float Value) const
{
// no need to scale and bias floating point components.
return Value;
}
};
////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////
////////////////////////////////////////////////////////////////////////////////
template <class SamplerClass>
class TNormalMapAnalyzer
{
public:
TNormalMapAnalyzer()
: NumSamplesTaken(0), NumSamplesRejected(0), NumSamplesThreshold(0), AverageColor(0.0f, 0.0f, 0.0f, 0.0f)
{
}
/**
* EvaluateSubBlock
* Iterates over all pixels in the specified rectangle, if the resulting pixel
* isn't black, mid grey or would result in X or Y being -1 or +1 then it is
* added to the average color and the number of samples count is incremented.
*/
void EvaluateSubBlock(int32 Left, int32 Top, int32 Width, int32 Height)
{
for (int32 Y = Top; Y != (Top + Height); Y++)
{
for (int32 X = Left; X != (Left + Width); X++)
{
FLinearColor ColorSample = Sampler.DoSampleColor(X, Y);
// Nearly black or transparent pixels don't contribute to the calculation
if (FMath::IsNearlyZero(ColorSample.A, AlphaComponentNearlyZeroThreshold) ||
ColorSample.IsAlmostBlack())
{
continue;
}
// Scale and bias, if required, to get a signed vector
float Vx = Sampler.ScaleAndBiasComponent(ColorSample.R);
float Vy = Sampler.ScaleAndBiasComponent(ColorSample.G);
float Vz = Sampler.ScaleAndBiasComponent(ColorSample.B);
const float Length = FMath::Sqrt(Vx * Vx + Vy * Vy + Vz * Vz);
if (Length < ColorComponentNearlyZeroThreshold)
{
// mid-grey pixels representing (0,0,0) are also not considered as they may be used to denote unused areas
continue;
}
// If the vector is sufficiently different in length from a unit vector, consider it invalid.
if (FMath::Abs(Length - 1.0f) > NormalVectorUnitLengthDeltaThreshold)
{
NumSamplesRejected++;
continue;
}
// If the vector is pointing backwards then it is an invalid sample, so consider it invalid
if (Vz < 0.0f)
{
NumSamplesRejected++;
continue;
}
AverageColor += ColorSample;
NumSamplesTaken++;
}
}
}
/**
* DoesTextureLookLikelyToBeANormalMap
*
* Makes a best guess as to whether a texture represents a normal map or not.
* Will not be 100% accurate, but aims to be as good as it can without usage
* information or relying on naming conventions.
*
* The heuristic takes samples in small blocks across the texture (if the texture
* is large enough). The assumption is that if the texture represents a normal map
* then the average direction of the resulting vector should be somewhere near {0,0,1}.
* It samples in a number of blocks spread out to decrease the chance of hitting a
* single unused/blank area of texture, which could happen depending on uv layout.
*
* Any pixels that are black, mid-gray or have a red or green value resulting in X or Y
* being -1 or +1 are ignored on the grounds that they are invalid values. Artists
* sometimes fill the unused areas of normal maps with color being the {0,0,1} vector,
* but that cannot be relied on - those areas are often black or gray instead.
*
* If the heuristic manages to sample enough valid pixels, the threshold being based
* on the total number of samples it will be looking at, then it takes the average
* vector of all the sampled pixels and checks to see if the length and direction are
* within a specific tolerance. See the namespace at the top of the file for tolerance
* value specifications. If the vector satisfies those tolerances then the texture is
* considered to be a normal map.
*/
bool DoesTextureLookLikelyToBeANormalMap(UTexture* Texture)
{
int32 TextureSizeX = Texture->Source.GetSizeX();
int32 TextureSizeY = Texture->Source.GetSizeY();
// Calculate the number of tiles in each axis, but limit the number
// we interact with to a maximum of 16 tiles (4x4)
int32 NumTilesX = FMath::Min(TextureSizeX / SampleTileEdgeLength, MaxTilesPerAxis);
int32 NumTilesY = FMath::Min(TextureSizeY / SampleTileEdgeLength, MaxTilesPerAxis);
Sampler.SetSourceTexture(Texture);
if ((NumTilesX > 0) &&
(NumTilesY > 0))
{
// If texture is large enough then take samples spread out across the image
NumSamplesThreshold = (NumTilesX * NumTilesY) * 4; // on average 4 samples per tile need to be valid...
for (int32 TileY = 0; TileY < NumTilesY; TileY++)
{
int Top = (TextureSizeY / NumTilesY) * TileY;
for (int32 TileX = 0; TileX < NumTilesX; TileX++)
{
int Left = (TextureSizeX / NumTilesX) * TileX;
EvaluateSubBlock(Left, Top, SampleTileEdgeLength, SampleTileEdgeLength);
}
}
}
else
{
NumSamplesThreshold = (TextureSizeX * TextureSizeY) / 4;
// Texture is small enough to sample all texels
EvaluateSubBlock(0, 0, TextureSizeX, TextureSizeY);
}
// if we managed to take a reasonable number of samples then we can evaluate the result
if (NumSamplesTaken >= NumSamplesThreshold)
{
const float RejectedToTakenRatio = static_cast<float>(NumSamplesRejected) / static_cast<float>(NumSamplesTaken);
if (RejectedToTakenRatio >= RejectedToTakenRatioThreshold)
{
// Too many invalid samples, probably not a normal map
return false;
}
AverageColor /= (float)NumSamplesTaken;
// See if the resulting vector lies anywhere near the {0,0,1} vector
float Vx = Sampler.ScaleAndBiasComponent(AverageColor.R);
float Vy = Sampler.ScaleAndBiasComponent(AverageColor.G);
float Vz = Sampler.ScaleAndBiasComponent(AverageColor.B);
float Magnitude = FMath::Sqrt(Vx * Vx + Vy * Vy + Vz * Vz);
// The normalized value of the Z component tells us how close to {0,0,1} the average vector is
float NormalizedZ = Vz / Magnitude;
// if the average vector is longer than or equal to the min length, shorter than the max length
// and the normalized Z value means that the vector is close enough to {0,0,1} then we consider
// this a normal map
return ((Magnitude >= NormalMapMinLengthConfidenceThreshold) &&
(Magnitude < NormalMapMaxLengthConfidenceThreshold) &&
(NormalizedZ >= NormalMapDeviationThreshold));
}
// Not enough samples, don't trust the result at all
return false;
}
int32 NumSamplesTaken;
int32 NumSamplesRejected;
int32 NumSamplesThreshold;
FLinearColor AverageColor;
SamplerClass Sampler;
};
/**
* Attempts to evaluate the pixels in the texture to see if it is a normal map
*
* @param Texture The texture to examine
*
* @return bool true if the texture is likely a normal map (although it's not necessarily guaranteed)
*/
static bool IsTextureANormalMap(UTexture* Texture)
{
#if NORMALMAP_IDENTIFICATION_TIMING
double StartSeconds = FPlatformTime::Seconds();
#endif
// Analyze the source texture to try and figure out if it's a normal map.
// First check is to make sure it's an appropriate surface format.
ETextureSourceFormat SourceFormat = Texture->Source.GetFormat();
bool bIsNormalMap = false;
switch (SourceFormat)
{
// The texture could be a normal map if it's one of these formats
case TSF_BGRA8: {
TNormalMapAnalyzer<SampleNormalMapPixelBGRA8> Analyzer;
bIsNormalMap = Analyzer.DoesTextureLookLikelyToBeANormalMap(Texture);
}
break;
case TSF_RGBA16: {
TNormalMapAnalyzer<SampleNormalMapPixelRGBA16> Analyzer;
bIsNormalMap = Analyzer.DoesTextureLookLikelyToBeANormalMap(Texture);
}
break;
case TSF_RGBA16F: {
TNormalMapAnalyzer<SampleNormalMapPixelF16> Analyzer;
bIsNormalMap = Analyzer.DoesTextureLookLikelyToBeANormalMap(Texture);
}
break;
case TSF_RGBA8: {
TNormalMapAnalyzer<SampleNormalMapPixelRGBA8> Analyzer;
bIsNormalMap = Analyzer.DoesTextureLookLikelyToBeANormalMap(Texture);
}
break;
default:
// assume the texture is not a normal map
break;
}
#if NORMALMAP_IDENTIFICATION_TIMING
double EndSeconds = FPlatformTime::Seconds();
FString Msg = FString::Printf(TEXT("NormalMapIdentification took %.2f seconds to analyze %s"), (EndSeconds - StartSeconds), *Texture->GetFullName());
GLog->Log(Msg);
#endif
return bIsNormalMap;
}
/** Class to handle callbacks from notifications informing the user a texture was imported as a normal map */
class NormalMapImportNotificationHandler: public TSharedFromThis<NormalMapImportNotificationHandler>
{
public:
NormalMapImportNotificationHandler(): Texture(NULL)
{
}
~NormalMapImportNotificationHandler()
{
}
/** This method is invoked when the user clicks the "OK" button on the notification */
void OKSetting(TSharedPtr<NormalMapImportNotificationHandler>)
{
if (Notification.IsValid())
{
Notification.Pin()->SetCompletionState(SNotificationItem::ECompletionState::CS_Success);
Notification.Pin()->Fadeout();
}
}
/* This method is invoked when the user clicked the "Revert" button on the notification */
void RevertSetting(TSharedPtr<NormalMapImportNotificationHandler>)
{
UTexture2D* Texture2D = Texture.IsValid() ? Cast<UTexture2D>(Texture.Get()) : NULL;
if (Texture2D)
{
if (Texture2D->CompressionSettings == TC_Normalmap)
{
// Must wait until the texture is done with previous operations before changing settings and getting it to rebuild.
Texture2D->WaitForPendingInitOrStreaming();
Texture2D->SetFlags(RF_Transactional);
Texture2D->Modify();
Texture2D->PreEditChange(NULL);
{
Texture2D->CompressionSettings = TC_Default;
Texture2D->SRGB = true;
Texture2D->LODGroup = TEXTUREGROUP_World;
}
Texture2D->PostEditChange();
}
}
if (Notification.IsValid())
{
Notification.Pin()->SetCompletionState(SNotificationItem::ECompletionState::CS_Success);
Notification.Pin()->Fadeout();
}
}
TWeakObjectPtr<UTexture> Texture;
TWeakPtr<SNotificationItem> Notification;
};
void NormalMapIdentification::HandleAssetPostImport(UTextureFactory* TextureFactory, UTexture* Texture)
{
if (TextureFactory != NULL && Texture != NULL)
{
// Try to automatically identify a normal map
if (!TextureFactory->bUsingExistingSettings && IsTextureANormalMap(Texture))
{
// Set the compression settings and no gamma correction for a normal map
{
Texture->SetFlags(RF_Transactional);
Texture->Modify();
Texture->CompressionSettings = TC_Normalmap;
Texture->SRGB = false;
Texture->LODGroup = TEXTUREGROUP_WorldNormalMap;
Texture->bFlipGreenChannel = TextureFactory->bFlipNormalMapGreenChannel;
}
// Show the user a notification indicating that this texture will be imported as a normal map.
// Offer two options to the user, "OK" dismisses the notification early, "Revert" reverts the settings to that of a diffuse map.
TSharedPtr<NormalMapImportNotificationHandler> NormalMapNotificationDelegate(new NormalMapImportNotificationHandler);
{
NormalMapNotificationDelegate->Texture = Texture;
// this is a cheat to make sure the notification keeps the callback thing alive while it's active...
FText OKText = LOCTEXT("ImportTexture_OKNormalMapSettings", "OK");
FText OKTooltipText = LOCTEXT("ImportTexture_OKTooltip", "Accept normal map settings");
FText RevertText = LOCTEXT("ImportTexture_RevertNormalMapSettings", "Revert");
FText RevertTooltipText = LOCTEXT("ImportTexture_RevertTooltip", "Revert to diffuse map settings");
FFormatNamedArguments Args;
Args.Add(TEXT("TextureName"), FText::FromName(Texture->GetFName()));
FNotificationInfo NormalMapNotification(FText::Format(LOCTEXT("ImportTexture_IsNormalMap", "Texture {TextureName} was imported as a normal map"), Args));
NormalMapNotification.ButtonDetails.Add(FNotificationButtonInfo(OKText, OKTooltipText, FSimpleDelegate::CreateSP(NormalMapNotificationDelegate.Get(), &NormalMapImportNotificationHandler::OKSetting, NormalMapNotificationDelegate)));
NormalMapNotification.ButtonDetails.Add(FNotificationButtonInfo(RevertText, RevertTooltipText, FSimpleDelegate::CreateSP(NormalMapNotificationDelegate.Get(), &NormalMapImportNotificationHandler::RevertSetting, NormalMapNotificationDelegate)));
NormalMapNotification.bFireAndForget = true;
NormalMapNotification.bUseLargeFont = false;
NormalMapNotification.bUseSuccessFailIcons = false;
NormalMapNotification.bUseThrobber = false;
NormalMapNotification.ExpireDuration = 10.0f;
NormalMapNotificationDelegate->Notification = FSlateNotificationManager::Get().AddNotification(NormalMapNotification);
if (NormalMapNotificationDelegate->Notification.IsValid())
{
NormalMapNotificationDelegate->Notification.Pin()->SetCompletionState(SNotificationItem::CS_Pending);
}
}
}
}
}
#undef LOCTEXT_NAMESPACE