These scripts/editor widgets were created during my time at Crowbar Collective, in order to generate batch reports on assets selected within the content browser. Various functions are defined to evaluate assets against a reporting condition. When an asset matches the specific reporting condition, its path and a contextual message are printed to the output log. There are functions to operate on StaticMesh, Texture2D, Material and MaterialInstance assets, as well as to report on references and metadata tags.

The Unreal asset audit tool (pictured below, opened with Alt-Shift-A) can provide a great deal of information, but it only presents a tabular view of properties instead of supporting more advanced functions which can be executed against assets. Those functions could be as simple as comparing a value against a constant reference, or as complex as performing some evaluation of the asset's data itself (eg, directly reading pixels from a Texture2D).

All of the reporting functions are defined in a Python script, while the widget provides buttons which execute the relevant Python functions. Implementing the functions in this way was meant to allow for easy updates and additions without the need for C++ (re)compilation or dealing with many Blueprint functions.


For extensibility, a generic "check selection" function wraps around the reporting behaviour. When assets are selected for a report, they have to be loaded first. In the case of heavy assets or a large selection, the editor may become temporarily unresponsive. Unreal's ScopedSlowTask can be used to display a progress bar in such cases. The function which evaluates an individual asset, the input checkFunc, is nested inside a second ScopedSlowTask for cases where the reporting behaviour isn't instantaneous.

This was also done to create a separation between progression of the inner "check" task, and the outer "get assets" task. The progress bar isn't accurate for the outer task - in order to provide an accurate scope, we should know the number of objects we need to retrieve. However (as far as I'm aware) we can't know that without calling get_selected_assets() to understand the content browser selection, which will block and load the assets if necessary.

Despite this inaccuracy, having some context to why the editor is unresponsive seems better than having none. When it comes to the nested tasks, the assets will already be loaded, so the progress display is meaningful.

def check_asset_selection(checkFunc, dialogLabel = "Checking selected assets"):
    def checkSelectionNoParam(label = dialogLabel):
        with unreal.ScopedSlowTask(1, "Checking selected assets...") as get_asset_task:
            get_asset_task.make_dialog(True)
            get_asset_task.enter_progress_frame(1)
            if get_asset_task.should_cancel():
                return
            objs = unreal.EditorUtilityLibrary.get_selected_assets()
            with unreal.ScopedSlowTask(len(objs), label) as check_task:
                check_task.make_dialog(True)
                for o in objs:
                    if check_task.should_cancel():
                        break
                    check_task.enter_progress_frame(1)
                    checkFunc(o)

    return checkSelectionNoParam

The checkFunc input which we pass in above is what evaluates a given object o from the content browser selection. The selection could contain objects of disparate types, so it's up to the reporting functions to decide whether or not to execute for a specific type. In most cases, this is as simple as testing the object's class. The function below is used as the checkFunc for reporting on Texture2D assets with the "never stream" (no mipmaps) property set. The second block is the editor-side function which wraps everything together. It can be executed directly from the Python console, and is used for the button binding within the widget Blueprint shown at the top of the page.

def check_t2d_no_mips(o):
    if o.get_class() == unreal.Texture2D.static_class():
        if o.get_editor_property("never_stream") == True:
            print("%s has no mips / is never streamed" %o.get_full_name())
            return True
        return False
check_selection_t2d_no_mips = check_asset_selection(check_t2d_no_mips, "Looking for T2D with no mips...")

When an asset inside of the selection satisfies the reporting condition, something like the following is printed to the output log:

LogPython: Texture2D /Game/Art/Airport/Signs/Textures/adScroll00a_E.adScroll00a_E has no mips / is never streamed

Additional C++ Functions


For certain reporting behaviours, it was necessary to create some C++ functions which could be called from Python or Blueprint.

Certain asset properties (like static mesh lightmap indices) weren't exposed to Blueprint, but it was simple to retrieve their values:

int32 UMyEditorUtilityLibrary::GetMeshLightmapIndex(const UStaticMesh* Mesh)
{
    return Mesh->GetLightMapCoordinateIndex();
}
int32 UMyEditorUtilityLibrary::GetMeshLightmapDestinationIndex(const UStaticMesh* Mesh)
{
    return Mesh->GetSourceModel(0).BuildSettings.DstLightmapIndex;
}

Some functions return the result of simple property validations, like in this case, used to find mismatched lightmap destination and index values.

bool UMyEditorUtilityLibrary::CheckLightmapIndexMatchesDestination(const UStaticMesh* Mesh, int32& Index, int32& Dest)
{
    // out Index, Dest
    Index = GetMeshLightmapIndex(Mesh);
    Dest = GetMeshLightmapDestinationIndex(Mesh);
    return (Index == Dest);
}

More complex behaviour was required for evaluating Texture2D assets to check for 'empty' R/G/B/A channels, ones where all pixels share the same values. This was useful to identify redundant packed mask textures, or cases where a texture asset could be removed and replaced with a constant value from the material instead.

Isara Tech wrote an article on reading the pixel data from a Texture2D which was quite helpful. As explained there, and in the forum post which is referenced, it's necesary to apply some specific settings to the texture in order to actually receive the pixel data instead of nullptr.

The actual pixel check is simple: assume that the channel is 'empty' by default and read/store the value of the first pixel, before attempting to loop and read all subsequent pixels. If encountering any pixel with a value different to the first, we know the channel isn't empty.

bool UMyEditorUtilityLibrary::CheckTextureHasEmptyChannel(UTexture2D* Texture, ETextureChannel Channel)
{
    // assume that the channel is empty by default
    bool emptyChannel = true;

    // store the old texture settings since we need to change them (temporarily) to avoid a null reference
    TextureCompressionSettings oldTCS = Texture->CompressionSettings;
    TextureMipGenSettings oldTMGS = Texture->MipGenSettings;
    bool oldSRGB = Texture->SRGB;

    // the texture settings must be very specific to read the pixel data, or there will be a crash
    // see https://isaratech.com/ue4-reading-the-pixels-from-a-utexture2d/
    Texture->CompressionSettings = TextureCompressionSettings::TC_VectorDisplacementmap;
    Texture->MipGenSettings = TextureMipGenSettings::TMGS_NoMipmaps;
    Texture->SRGB = false;
    Texture->UpdateResource();

    // now, get bulkdata for the texture image and store the first pixel's color
    const FColor* ImageData = static_cast(Texture->PlatformData->Mips[0].BulkData.LockReadOnly());
    const FColor firstPx = ImageData[0];

    // get value of the specified r/g/b/a channel of first pixel
    uint8 firstChannelValue = GetChannelPixel(&firstPx, Channel);

    // sample pixel color, then sample the value for the specified r/g/b/a channel. compare to first px value
    for (int32 x = 0; x < Texture->GetSizeX(); x++)
    {
        // exit early from the nested loop if non-empty channel was detected
        if (!emptyChannel)
        {
            break;
        }

        for (int32 y = 0; y < Texture->GetSizeY(); y++)
        {
            // sample the pixel's color
            FColor pxColor = ImageData[y * Texture->GetSizeX() + x];

            // sample pixel value and check: this pixel's value isn't the same as the first pixel value we sampled?
            if (GetChannelPixel(&pxColor, Channel) != firstChannelValue)
            {
                // therefore the channel isn't empty
                emptyChannel = false;
                break;
            }
        }
    }

    //release the bulkdata and reset the texture settings
    Texture->PlatformData->Mips[0].BulkData.Unlock();
    Texture->CompressionSettings = oldTCS;
    Texture->MipGenSettings = oldTMGS;
    Texture->SRGB = oldSRGB;
    Texture->UpdateResource();

    return emptyChannel;
}

For convenience, a TextureChannel enumeration was created, as well as a GetChannelPixel function to return the R/G/B/A component of an FColor.

UENUM(BlueprintType)
enum class ETextureChannel : uint8
{
    R UMETA(DisplayName = "Red"),
    G UMETA(DisplayName = "Green"),
    B UMETA(DisplayName = "Blue"),
    A UMETA(DisplayName = "Alpha"),
    MAX UMETA(Hidden)
};
uint8 GetChannelPixel(const FColor* Color, ETextureChannel Channel)
{
    if (Channel == ETextureChannel::A)
        return Color->A;
    else if (Channel == ETextureChannel::B)
        return Color->B;
    else if (Channel == ETextureChannel::G)
        return Color->G;
    else
        return Color->R;
}

For artists and asset reviewers, all functionality was documented in the company's internal knowledge base: