Question about MaterialAttribute CustomOutput

In our layered materials we used the BentNormal attribute purely as an internal data-passing lane between material layers — written via SetMaterialAttributes, read back via GetMaterialAttributes, and fed into a normal connected output. We never connected it as an actual BentNormal output; we relied on it staying inert.

This worked because of how GetWorldBentNormalZero (MaterialTemplate.ush) is gated:

#if NUM_MATERIAL_OUTPUTS_GETBENTNORMAL > 0

… return GetBentNormal0(MaterialParameters);

#else

return MaterialParameters.WorldNormal;

#endif

In our 5.5-era base, NUM_MATERIAL_OUTPUTS_GETBENTNORMAL was never defined for an attribute-driven BentNormal, so this compiled to the WorldNormal fallback and our data lane stayed invisible to lighting.

In 5.7, FHLSLMaterialTranslator::CompileMaterialAttributesCustomOutputs now compiles BentNormal as a real custom output whenever it appears connected in the MaterialAttributes chain. The GetWorldBentNormalZero source is unchanged — but because the translator

now emits #define NUM_MATERIAL_OUTPUTS_GETBENTNORMAL 1, the gated #if now compiles to the GetBentNormal0() branch instead of the WorldNormal fallback. Our data-lane value (a layer-blend weight) is therefore consumed by ApplyBentNormal as a real bent normal,

producing large black areas on the affected layer.

Questions

  1. Is reverting to that prior behavior safe? Specifically, what engine functionality or content depends on the “custom output set via MaterialAttributes” path, such that disabling it locally could have unintended consequences we should be aware of?
  2. If reverting does carry risk, is there a recommended way to pass arbitrary custom data between material layers (through the layer / MaterialAttributes interface) and read it back downstream — without repurposing a custom-output attribute like BentNormal?

Our reuse of BentNormal was a workaround for the lack of a clean inter-layer data channel; we’d prefer a supported mechanism that threads auxiliary data through the layer stack without being picked up as a real shading output.

[Attachment Removed]

The goal of the engine changes was to make it so layered materials could opt into rendering features, without needing to manually enable those features in the base material, and to standardize custom outputs (all generate the same define and Get function). A new version of the material translator (system that converts material graphs to HLSL shaders) is being implemented, and the original will eventually be deprecated, so any changes you make to the internals of the translator could potentially break if not also applied to the new version.

As a workaround, it would probably be fairly harmless to simply modify the USH files to ignore the NUM_MATERIAL_OUTPUTS_GETBENTNORMAL define, by switching the places it is referenced to something like “#if 0 && (NUM_MATERIAL_OUTPUTS_GETBENTNORMAL > 0)”, so you can still see where the define was in the code when integrating new versions. You’d want to check each new version for additional references to the define. A USH only change wouldn’t run into issues with new translator work. Some other custom outputs set flags (changing engine behavior) or compiler options, which could cause issues if used for attribute flow purposes, and there is some risk that flags or compiler options will be added to that custom output in the future, but it’s probably a low risk.

The more recommended way to pass custom data would be to define your own custom output. This can be done in a plugin, without modifying the engine itself. In the future, it may be possible to do this fully data driven, without a plugin. A material expression is defined that inherits from UMaterialExpressionCustomOutput, with some boilerplate implementation functions. Here’s what the header “MaterialExpressionAuxDataOutput.h” might look like:

#pragma once
 
#include "CoreMinimal.h"
#include "UObject/ObjectMacros.h"
#include "MaterialExpressionIO.h"
#include "MaterialValueType.h"
#include "Materials/MaterialExpressionCustomOutput.h"
#include "MaterialExpressionAuxDataOutput.generated.h"
 
/**
 * Inert custom output that threads arbitrary per-pixel data through the MaterialAttributes / material-layer interface.
 *
 * NOTE: You do NOT need to place this node to use the channel.  The intended workflow uses Set/GetMaterialAttributes
 * pins, rather than the standalone node, but an implementation still needs to be defined.
 */
UCLASS(collapsecategories, hidecategories=Object, MinimalAPI)
class UMaterialExpressionAuxDataOutput : public UMaterialExpressionCustomOutput
{
	GENERATED_UCLASS_BODY()
 
	UPROPERTY(meta = (RequiredInput = "true"))
	FExpressionInput Input;
 
#if WITH_EDITOR
	virtual int32 Compile(class FMaterialCompiler* Compiler, int32 OutputIndex) override;   // legacy HLSL translator
	virtual void Build(MIR::FEmitter& Emitter) override;                                     // new MIR translator (MIR::FEmitter fwd-declared by base header)
	virtual void GetCaption(TArray<FString>& OutCaptions) const override;
	virtual EMaterialValueType GetInputValueType(int32 InputIndex) override { return MCT_Float4; }
#endif
 
	virtual int32 GetNumOutputs() const override { return 1; }
	// FunctionName drives the emitted define NUM_MATERIAL_OUTPUTS_GETAUXDATA and GetAuxData0().
	virtual FString GetFunctionName() const override { return TEXT("GetAuxData"); }
	virtual FString GetDisplayName() const override { return TEXT("AuxData"); }
};

Here’s the MaterialExpressionAuxDataOutput.cpp source with boilerplate implementations:

#include "MaterialExpressionAuxDataOutput.h"
 
#if WITH_EDITOR
#include "MaterialCompiler.h"
#include "Materials/MaterialExpressionsToMIRCommon.h"
#include "Materials/MIR/MIREmitter.h"
#include "Materials/MIR/MIRTypes.h"
#endif
 
UMaterialExpressionAuxDataOutput::UMaterialExpressionAuxDataOutput(const FObjectInitializer& ObjectInitializer)
	: Super(ObjectInitializer)
{
#if WITH_EDITORONLY_DATA
	// Custom outputs have no graph output pin.
	Outputs.Reset();
#endif
}
 
#if WITH_EDITOR
int32 UMaterialExpressionAuxDataOutput::Compile(FMaterialCompiler* Compiler, int32 OutputIndex)
{
	// Only reached when this node is placed directly in the base material graph.  The layer / MaterialAttributes workflow
	// never calls this (it compiles the attribute via CompileMaterialAttributesCustomOutputs -> CompilePropertyEx).
	if (Input.GetTracedInput().Expression)
	{
		return Compiler->CustomOutput(this, OutputIndex, Input.Compile(Compiler));
	}
 
	return Compiler->Constant4(0.0f, 0.0f, 0.0f, 0.0f);
}
 
void UMaterialExpressionAuxDataOutput::Build(MIR::FEmitter& Em)
{
	// MIR equivalent of Compile.  Name matches the registered AttributeName ("AuxData"), without the "Get".
	MIR::FValueRef InputValue = Em.CastToFloat(Em.Input(&Input), 4);
	Em.SetCustomOutputs(TEXTVIEW("AuxData"), { &InputValue, 1 }, MIR::EMaterialOutputFrequency::PerPixel);
}
 
void UMaterialExpressionAuxDataOutput::GetCaption(TArray<FString>& OutCaptions) const
{
	OutCaptions.Add(TEXT("Aux Data output"));
}
#endif // WITH_EDITOR

And finally, here is the module implementation, which registers the custom output:

#include "Modules/ModuleManager.h"
#include "MaterialValueType.h"
#include "Materials/MaterialAttributeDefinitionMap.h"
#include "MaterialExpressionAuxDataOutput.h"
 
DEFINE_LOG_CATEGORY_STATIC(LogAuxDataAttribute, Log, All);
 
class FAuxDataCustomAttributeModule : public IModuleInterface
{
public:
	virtual void StartupModule() override
	{
		// GUID that uniquely identifies this attribute in every material that references it.
		static const FGuid AuxDataAttributeID(0x7E1C4A02, 0x9B3D4F18, 0xA5C6E7D0, 0x1234ABCD);
 
		FMaterialAttributeDefinitionMap::AddCustomAttribute(
			AuxDataAttributeID,
			UMaterialExpressionAuxDataOutput::StaticClass(),
			TEXT("AuxData"),      // AttributeName - shown in Set/GetMaterialAttributes pin picker.
			TEXT("GetAuxData"),   // FunctionName - emits GetAuxData0() + NUM_MATERIAL_OUTPUTS_GETAUXDATA.
			0,                    // OutputIndex.
			MCT_Float4,           // ValueType - must match the node's GetInputValueType.
			FVector4(0, 0, 0, 0), // DefaultValue - the inert default read when unset.
			SF_Pixel);
 
		UE_LOG(LogAuxDataAttribute, Log, TEXT("Registered custom material attribute 'AuxData' (%s)."),
			*AuxDataAttributeID.ToString(EGuidFormats::Digits));
	}
};
 
IMPLEMENT_MODULE(FAuxDataCustomAttributeModule, AuxDataCustomAttributeModule)

You’ll also need boilerplate .uplugin and .Build.cs files needed for any plugin. Sorry for the late response -- I had to write a test plugin to confirm that everything works as expected!

--Jason

[Attachment Removed]