Files
UnrealEngineUWP/Engine/Source/Runtime/TextureUtilitiesCommon/Private/NormalMapIdentification.cpp
charles bloom 6786ffdffe start fixing texture import for very large textures
can now import 32K dim VT
disable broken PNG alpha fill on 16bit

#preflight 6243a450df7d23dbfef393dc
#rb fabian.giesen

[CL 19558522 by charles bloom in ue5-main branch]
2022-03-30 12:05:31 -04:00

535 lines
18 KiB
C++

// Copyright Epic Games, Inc. All Rights Reserved.
#include "NormalMapIdentification.h"
#if WITH_EDITOR
#include "Engine/Texture2D.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;
}
////////////////////////////////////////////////////////////////////////////////
// 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.LockMipReadOnly(0);
}
UTexture* SourceTexture;
int64 TextureSizeX;
int64 TextureSizeY;
const 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;
const 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;
const 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 )
{
const uint8* PixelToSample = SourceTextureData + ((Y * TextureSizeX + X) * sizeof(FFloat16Color));
// 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)
return ((const FFloat16Color*)PixelToSample)->GetFloats();
}
float ScaleAndBiasComponent( float Value ) const
{
// no need to scale and bias floating point components.
return Value;
}
};
class SampleNormalMapPixelF32 : public NormalMapSamplerBase
{
public:
SampleNormalMapPixelF32() {}
~SampleNormalMapPixelF32() {}
FLinearColor DoSampleColor( int32 X, int32 Y )
{
const uint8* PixelToSample = SourceTextureData + ((Y * TextureSizeX + X) * sizeof(FLinearColor));
return *((const FLinearColor*)PixelToSample);
}
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 )
{
TRACE_CPUPROFILER_EVENT_SCOPE(IsTextureANormalMap);
#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_RGBA32F:
{
TNormalMapAnalyzer<SampleNormalMapPixelF32> 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 %f 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;
};
bool UE::NormalMapIdentification::HandleAssetPostImport( UTexture* Texture )
{
if( Texture != NULL)
{
// Try to automatically identify a normal map
if ( 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;
}
// 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);
}
}
return true;
}
}
return false;
}
#undef LOCTEXT_NAMESPACE
#endif //WITH_EDITOR