search
LoginSignup
11
Help us understand the problem. What are the problem?

More than 1 year has passed since last update.

Unreal Engine 4 (UE4) Advent Calendar 2019 Day 12

posted at

updated at

MaterialExpressionのひみつ

Material Expressionのひみつ

この記事はUnreal Engine 4 (UE4) Advent Calendar 2019の12日目の記事です。

この記事はUE4 4.23.1をベースに書いています。

Material Expression

この記事でのMaterial ExpressionはUE4のマテリアルノードのノードのことです。
割とみなさん便利に使っていると思いますが、実際にMaterialExpressionがどういう仕組なのか追ってみたいと思います。

Fresnelノード

例としてみんな大好きFresnelノードを調べてみたいと思います。
Fresnelノードはカメラと法線の向きが直角に近いほど大きな値が返ってくるノードです。趣旨が違うのでFresnelについて詳しくは説明しませんが、メッシュのエッジ部分を強調したい場合などに良く使われます。

Fersnelノードだけのマテリアルを作成してみました。
image.png
周辺に行くほど明るくなる球が表示されています。
ウィンドウ->Shader Code->HLSL Codeで実際にできあがったシェーダーコードを参照できます。
と、言っても2332行もあるコードが出てきます。使われないコードや他のパスで使うコードも含まれているので殆どは関係ありません。みるべき関数はひとつだけです。
CalcPixelMaterialInputs関数を探します。

    MaterialFloat Local0 = dot(Parameters.WorldNormal, Parameters.CameraVector);
    MaterialFloat Local1 = max(0.00000000,Local0);
    MaterialFloat Local2 = (1.00000000 - Local1);
    MaterialFloat Local3 = abs(Local2);
    MaterialFloat Local4 = PositiveClampedPow(Local3,5.00000000);
    MaterialFloat Local5 = (Local4 * (1.00000000 - 0.04000000));
    MaterialFloat Local6 = (Local5 + 0.04000000);

一行ずつ説明しましょう。
MateerialFloatはプラットフォームごとの変数の型をラップするための定義でfloatと同じと考えておいてください。
1行目

MaterialFloat Local0 = dot(Parameters.WorldNormal, Parameters.CameraVector);

Parameter.WorldNotmalはそのピクセルのワールドスペースの法線ベクトル、Parameter.CameraVectorはカメラの向きのベクトルです。この2つのdot、つまり内積を取っています。結果は法線とカメラの向きが近いほど1に近く、完全に同じなら1、完全に反対向きなら-1、90度の角度を成す場合に0になります。
2行目

MaterialFloat Local1 = max(0.00000000,Local0);

負になる部分を0に切り詰めます。負になるということはカメラと法線の角度が90度より大きくなるということです。90度で最大になれば良いので、ここで切り詰めてます。
3行目

MaterialFloat Local2 = (1.00000000 - Local1);

1から引いて値を反転させています。90度以上で最大、カメラの方向を向くほど小さくしたいので、反転させるわけです。
4行目

MaterialFloat Local3 = abs(Local2);

Local0が-1~1で、0~1にクランプして1から引いてるので、この時点での値は0~1なので、絶対値を取る必要は無いと思うのですがはて?この行要らない気がします。
5行目

MaterialFloat Local4 = PositiveClampedPow(Local3,5.00000000);

PositiveClampPow

MaterialFloat PositiveClampedPow(MaterialFloat X,MaterialFloat Y)
{
    return pow(max(X,0.0f),Y);
}

なので、入力が負にならないようにクリップしてpowをかける関数です。すでにクリップされているので、単純にpowで良いはずですが。
5.0でpowなので、入力の5乗ということになります。
この5が何かというと、FresnelノードのExponentInのデフォルト値です。
6行目

MaterialFloat Local5 = (Local4 * (1.00000000 - 0.04000000));

この0.04はBaseReflectFractionInのデフォルト値です。この数値にも意味があるのですが、その辺は後述します。
7行目

MaterialFloat Local6 = (Local5 + 0.04000000);

そしてまたBase Reflect Fractionを加算しています。
以上がFresnelノードで行われていることです。
後半不思議な計算をしていますが、CGエンジニアならピンと来ると思います。そう、おなじみのSchlickのフレネル近似式です。

Schlickのフレネル近似式、F_0は反射率係数、cosθは法線とカメラのなす角度\\
F_0 + (1-F_0)(1-cosθ)^5\\

つまり、Fresnelノードの正体はコンピュータグラフィックでよく使用されるオーソドックスなフレネル項の計算式です。
Fresnelノードの入力値のデフォルトの5はこの式のマジックナンバー、0.04は一般的な非金属の反射率とされている4%です。

マテリアルノードで書くと
image.png
となります。
このマテリアルをコードで見ると

    MaterialFloat Local0 = dot(Parameters.WorldNormal, Parameters.CameraVector);
    MaterialFloat Local1 = max(0.00000000,Local0);
    MaterialFloat Local2 = (1.00000000 - Local1);
    MaterialFloat Local3 = abs(Local2);
    MaterialFloat Local4 = PositiveClampedPow(Local3,5.00000000);
    MaterialFloat Local5 = (Local4 * (1.00000000 - 0.04000000));
    MaterialFloat Local6 = (Local5 + 0.04000000);

完全に一致しました。
MaterialExpressionは便利ですが、中でどういう計算が行われているかはShaderCodeに出力して確認するのが良いですね。

余談・Fresnelについて

ちょっと余談ですが、エフェクトアーティストなどが作成するマテリアルでFresnelノードを多用しているマテリアルを良くみかけます。
実はFresnelの計算そのものはUE4の標準的なスペキュラ計算の中でも行われているので、マテリアル内でわざわざFresnel計算をする必要はありません。実際のコードを見てみますと

4.23のスペキュラ計算
float3 SpecularGGX( float Roughness, float3 SpecularColor, BxDFContext Context, float NoL, FAreaLight AreaLight )
{
    float a2 = Pow4( Roughness );
    float Energy = EnergyNormalization( a2, Context.VoH, AreaLight );

    // Generalized microfacet specular
    float D = D_GGX( a2, Context.NoH ) * Energy;
    float Vis = Vis_SmithJointApprox( a2, Context.NoV, NoL );
    float3 F = F_Schlick( SpecularColor, Context.VoH );

    return (D * Vis) * F;
}

F_Schlick関数でフレネル項を求め、乗算しているのが確認できます。
なので、基本に忠実にPBRなマテリアルを作成する限り、Fresnelノードは不要なノードというか、使うべきでないノードだったりします。
詳しく説明しないと言ったな、あれは嘘だ

じゃあ、何に使うかというと、擬似的なリムライトだったり、選択したキャラクターのエッジを発光させたりといった用途に使われるケースが多いように思います。その辺意識して実際の計算式を見た上で使ってもらえればと思います。
ところで式を見れば気づくと思いますが、BaseReflectFractionInに0を指定しないとこのノードの出力は0になりません。0~1の値が欲しい場合は気をつけましょう。まあ、個人的にはエッジを強調などにはそれ用のMaterialFunction書いた方が無駄が無いと思うのですが。

MaterialExpressionの中身

本題に戻ります。
では、実際のFresnelNodeの中身はというと、MaterialExpressions.cppの中にあります。
UMaterialExpressionFresnelというクラスのCompileメソッドでノードが生成されます。
ヘッダは別途MaterialExpresionFresnel.hが存在し、入力パラーメーターなどはそちらで定義されています。

int32 UMaterialExpressionFresnel::Compile(class FMaterialCompiler* Compiler, int32 OutputIndex)
{
    // pow(1 - max(0,Normal dot Camera),Exponent) * (1 - BaseReflectFraction) + BaseReflectFraction
    //
    int32 NormalArg = Normal.GetTracedInput().Expression ? Normal.Compile(Compiler) : Compiler->PixelNormalWS();
    int32 DotArg = Compiler->Dot(NormalArg,Compiler->CameraVector());
    int32 MaxArg = Compiler->Max(Compiler->Constant(0.f),DotArg);
    int32 MinusArg = Compiler->Sub(Compiler->Constant(1.f),MaxArg);
    int32 ExponentArg = ExponentIn.GetTracedInput().Expression ? ExponentIn.Compile(Compiler) : Compiler->Constant(Exponent);
    // Compiler->Power got changed to call PositiveClampedPow instead of ClampedPow
    // Manually implement ClampedPow to maintain backwards compatibility in the case where the input normal is not normalized (length > 1)
    int32 AbsBaseArg = Compiler->Abs(MinusArg);
    int32 PowArg = Compiler->Power(AbsBaseArg,ExponentArg);
    int32 BaseReflectFractionArg = BaseReflectFractionIn.GetTracedInput().Expression ? BaseReflectFractionIn.Compile(Compiler) : Compiler->Constant(BaseReflectFraction);
    int32 ScaleArg = Compiler->Mul(PowArg, Compiler->Sub(Compiler->Constant(1.f), BaseReflectFractionArg));

    return Compiler->Add(ScaleArg, BaseReflectFractionArg);
}

エンジニアの方はざっと見てわかるかと思いますが、平たく言えば、ノードを生成してインデックスを取得、そのインデックスをパラメーターに新しいノードを作成・・・と言ったことを順番にやってるだけです。

int32 NormalArg = Normal.GetTracedInput().Expression ? Normal.Compile(Compiler) : Compiler->PixelNormalWS();

のような分岐がありますが、これはNormalピンに入力があればそれをパラメーターに、無ければPixelNormalWSを使用するということです。PixelNormalWSはそのピクセルのワールドスペースでの法線です。

基本はCompiler->hogeでなにかの機能を呼ぶと、それに対応したノードが生成され、そのノードのインデックスが返ってくるという構造です。

三角関数系

さて、次に三角関数系を見てみましょう。
このノードのShaderCodeはどうなってるのでしょうか。
image.png

    PixelMaterialInputs.EmissiveColor = Material.VectorExpressions[2].rgb;

あれ?
sin関数呼ばれて無いですね。Material.VectorExpressions[2]ってなんでしょうか。VectorExpressionsはCPU側でセットされるパラメータですね。なるほど。あらかじめ計算できる部分はCPUが計算して定数にしてくれてるんですね。賢い!

では定数にならないようにしてみます。
image.png

    MaterialFloat Local0 = (Parameters.TexCoords[0].xy.r * 6.28318548);
    MaterialFloat Local1 = sin(Local0);
    MaterialFloat3 Local2 = (Local1 + Material.VectorExpressions[1].rgb);

    PixelMaterialInputs.EmissiveColor = Local2;

今度はsin関数が呼ばれました。しかしちょっと注意。6.28318548という数字が乗算されてからsin関数に入力されています。
つまりは入力値に2πが乗算されてます。三角関数の周期である0~2πを0~1にマッピングしていることになります。
なんか余計なことしやがってと思わなくもないです。これを知らないで普通にラジアンを入力してると期待した結果が出ないことになるのでご注意を。反面、Timeノードなどを入力するときにコントロールはしやすいのかな?

C++コードの方を見てみましょう。

    virtual int32 Sine(int32 X) override
    {
        if(X == INDEX_NONE)
        {
            return INDEX_NONE;
        }

        if(GetParameterUniformExpression(X))
        {
            return AddUniformExpression(new FMaterialUniformExpressionTrigMath(GetParameterUniformExpression(X),TMO_Sin),MCT_Float,TEXT("sin(%s)"),*CoerceParameter(X,MCT_Float));
        }
        else
        {
            return AddCodeChunk(GetParameterType(X),TEXT("sin(%s)"),*GetParameterCode(X));
        }
    }

GetParameterUniformExpressionで定数化できるなら定数として出力、そうでないならsin関数を呼ぶコードを出力といった感じのようです。

三角関数注意点

  • 入力値は2πが乗算されるよ
  • 入力値がシェーダー内で変動しないなら事前計算された定数になるよ

定数を入力して使うなら三角関数を使うと負荷が上がるなんてことは無さそうです。

Desaturation

Desaturationノードを見てみます。
image.png

カラーからモノクロに変換するノードです。
今度は最初からコードを見てみましょう。
MaterialExpressions.cppのUMaterialExpressionDesaturationの実装に説明コメントを入れました。

// 初期化とノードとして登録
UMaterialExpressionDesaturation::UMaterialExpressionDesaturation(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
    // Structure to hold one-time initialization
    struct FConstructorStatics
    {
        FText NAME_Color;
        FText NAME_Utility;
        FConstructorStatics()
            : NAME_Color(LOCTEXT( "Color", "Color" ))
            , NAME_Utility(LOCTEXT( "Utility", "Utility" ))
        {
        }
    };
    static FConstructorStatics ConstructorStatics;              // ColorとUtilityカテゴリーに属するという宣言

    LuminanceFactors = FLinearColor(0.3f, 0.59f, 0.11f, 0.0f);  // モノトーン変換のための係数

#if WITH_EDITORONLY_DATA
    MenuCategories.Add(ConstructorStatics.NAME_Color);          // Colorカテゴリーに登録
    MenuCategories.Add(ConstructorStatics.NAME_Utility);        // Utilityカテゴリーに登録
#endif
}

// ノードのコンパイル
int32 UMaterialExpressionDesaturation::Compile(class FMaterialCompiler* Compiler, int32 OutputIndex)
{
    if(!Input.GetTracedInput().Expression)
        return Compiler->Errorf(TEXT("Missing Desaturation input"));    // 入力引数が無いよ?

    // 入力カラーからモノトーンカラーを生成
    int32 Color = Compiler->ForceCast(Input.Compile(Compiler), MCT_Float3, MFCF_ExactMatch|MFCF_ReplicateValue),
        Grey = Compiler->Dot(Color,Compiler->Constant3(LuminanceFactors.R,LuminanceFactors.G,LuminanceFactors.B));

    // Fractionパラメーターが指定されていれば、その数値で補完した値を返す。指定されてなければモノトーンカラーを返す
    if(Fraction.GetTracedInput().Expression)
        return Compiler->Lerp(Color,Grey,Fraction.Compile(Compiler));
    else
        return Grey;
}

モノトーンへの変換を
R * 0.3 + G * 0.59 + B * 0.11
で計算しています。モノトーン変換の係数はいろいろあるのですが、かなりシンプルなものを使用していますね。
一般的なYUV変換の係数、BT.601という規格の値、0.299,0.587,0.114を少数以下2桁に丸めたもののようです。
この係数にはBT.709という規格もあり、実際にどれを使ってるかはソースを見ないとわからないですね。
ちなみにBT.601は標準のテレビ、BT.709はデジタルテレビの規格なので、いまどきはBT.709の方が良かったりしないかな?とか。
BT.709で変換するマテリアルファンクションを作ってみたので結果を比べてみると面白いかもしれません。
image.png
なおMaterialFunctionだとFractionを省略してもLerpを挟まないといけなくなってしまいます。

MaterialExpressionとMaterialFunction

一見同じようにマテリアル内で使えるノードにもMaterialExpressionとして実装されているものとMaterialFunctionで実装されているものがあります。
何が違うのでしょうか。

例えばSmoothstepはMaterialFunctionで実装されています。
image.png

ノードの色が違いますね。ダブルクリックすると中を見ることができます。
image.png

Customノードの中はというと

return smoothstep(varMin,varMax,varA);

シンプルです。ただ、このSmoothstepちょっと使い勝手が悪いです。なぜか言うと、入出力がScalarに限定されているからです。Vectorで入力したいことありますよね。入力の型に適切に対応するといった処理はCustomノードでは難しいです。

また、先程のDesaturationのように、パラメーターを省略したときの分岐もMaterialFunctionではできません。

MaterialExpressionとMaterialFunctionの違いをまとめると
- 同じ処理は同じように出力される。
- MaterialExpressionでは入力値の省略や型の違いに対応できる。
- MaterialFunctionはエディタだけで簡単に作成できる。

といったところでしょうか。パラメータの省略が必要だったり、シェーダー内で分岐したくないといった特殊な場合以外はMaterialFunctionでさくさく作る方がお手軽で良さそうです。
どちらで作成してもほとんどの場合同じようなシェーダーコードになるのでどちらが有利ということもあまり無いようですが、sineのように入力に応じて定数にしてくれるものはMaterialExpressionの方が軽くなる場合がありそうです。

MaterialExpressionやMaterialFunctionで気をつけること

別にMaterialExpressionやMaterialFunctionに限ったことでは無いですが、シェーダー内で0除算が発生すると、プラットフォームやグラフィックドライバによって挙動が違う場合があります。基本的には0除算を起こすべきではありません。とあるプラットフォームではマテリアルごと真っ黒になってしまったりします。エディタで大丈夫だと思っていると、気づくにくいバグの原因になったりします。
普段マテリアルノードを組むときは注意していても、除算を含むMaterialExpressionやMaterialFunctionで気づかずに0を入力してしまい、特定のケースでおかしな描画をされてしまうケースがあります。エンジン側で用意されているMaterialFunctionで除算が含まれているものはたくさんあるので、MaterialFunctionなら開いて中を見て確認することをおすすめします。MaterialExpressionの場合はこの記事のようにShader Codeを出力してみたり、ソースコードを見たりして確認しましょう。

例を挙げるとScaleUVsByCenterノードは便利ですが、TextureScaleに0を入力すると0除算が起きてしまいます。Maxノードで最低値にクリップするなどの安全対策をしたほうが良いかもしれません。
MaterialExpression内では最低値クリップを行っているノードが多いみたいです。
例えばSphereMaskノードでは

    ArgInvRadius = Compiler->Div(Compiler->Constant(1.0f), Compiler->Max(Compiler->Constant(0.00001f), Radius.Compile(Compiler)));

といった感じでMaxノードが挿入されています。
ちょっとでもインストラクション数を減らしたい場合は削れるものではありますが、汎用的に使われるノードの場合は安全策は必要かと思います。

他にもLerpの係数が0-1に収まっているかといった数値の範囲判定なども行われていない場合が多いので入力値の範囲には気をつける必要があります。まあこれはシェーダー全般に言えることで、MaterialExpressionやMaterialFunctionに限ったことではありませんが。

MaterialExpressionを作りたい

さて、だいたいのことはMaterialFunctionで解決できるのですが、どうしても独自のMaterialExpressionを作りたいと思ったことはありませんか?
挑戦してみました。

MaterialExpressionの要素

クラス定義と実装

MaterialExpressionはひとつひとつクラスとして定義されています。
クラス定義はEngine\Source\Runtime\Engine\Classes\Materials\フォルダに配置されています。
クラスの実装はEngine\Source\Runtime\Engine\Private\Materials\MaterialExpressions.cppにまとめられています。
じゃあ、拡張時にもEngineフォルダに置かないといけないかというと、そうでもありません。
Desaturationのところで軽く解説しましたが、クラス内で自分をマテリアルノードに追加する処理があるので、別に場所はどこでも問題なさそうです。
なので、独自のノードを作成するのは割と簡単です。

実際に作ってみた

簡単な例として、入力値の正負を保存するPower関数、SignedPowerというのを作ってみます。
SignedPower(-2,4) = -16
みたいな結果になる関数です。負の数をPowerにかけると正負が乗数によって変化してしまいますが、それを無視して入力値と同じ符号にして返します。

ヘッダはMaterialExpressionPower.hをコピーしてクラス名を変えただけです。

MaterialExpressionSignedPower.h
#pragma once

#include "CoreMinimal.h"
#include "UObject/ObjectMacros.h"
#include "MaterialExpressionIO.h"
#include "Materials/MaterialExpression.h"
#include "MaterialExpressionSignedPower.generated.h"

UCLASS(MinimalAPI, collapsecategories, hidecategories=Object)
class UMaterialExpressionSignedPower : public UMaterialExpression
{
    GENERATED_UCLASS_BODY()

    UPROPERTY()
    FExpressionInput Base;

    UPROPERTY(meta = (RequiredInput = "false", ToolTip = "Defaults to 'ConstExponent' if not specified"))
    FExpressionInput Exponent;

    /** only used if Exponent is not hooked up */
    UPROPERTY(EditAnywhere, Category=MaterialExpressionPower, meta=(OverridingInputProperty = "Exponent"))
    float ConstExponent;

    //~ Begin UMaterialExpression Interface
#if WITH_EDITOR
    virtual int32 Compile(class FMaterialCompiler* Compiler, int32 OutputIndex) override;
    virtual void GetCaption(TArray<FString>& OutCaptions) const override;
    virtual void GetExpressionToolTip(TArray<FString>& OutToolTip) override;
#endif
    //~ End UMaterialExpression Interface

};

実装部分もMaterialExpressions.cppを編集して適当に作りました。

MaterialExpressionSignedPower.cpp
//
//  UMaterialExpressionSignedPower
//

#include "CoreMinimal.h"
#include "Materials/MaterialExpression.h"
#include "Materials/MaterialExpressionMaterialFunctionCall.h"
#include "Materials/MaterialExpressionMaterialAttributeLayers.h"
#include "Materials/MaterialFunctionInterface.h"
#include "Materials/MaterialFunction.h"
#include "Materials/MaterialFunctionMaterialLayer.h"
#include "Materials/MaterialFunctionMaterialLayerBlend.h"
#include "Materials/MaterialFunctionInstance.h"
#include "Materials/Material.h"
#include "Engine/Texture2D.h"
#include "Engine/TextureRenderTarget2D.h"
#include "Engine/Texture2DDynamic.h"
#include "Engine/TextureCube.h"
#include "Engine/TextureRenderTargetCube.h"
#include "Engine/VolumeTexture.h"

#include "MaterialCompiler.h"
#if WITH_EDITOR
#include "MaterialGraph/MaterialGraphNode_Comment.h"
#include "MaterialGraph/MaterialGraphNode.h"
#include "Framework/Notifications/NotificationManager.h"
#include "Widgets/Notifications/SNotificationList.h"
#endif //WITH_EDITOR
#include "Materials/MaterialInstanceConstant.h"
#include "Curves/CurveLinearColorAtlas.h"
#include "Interfaces/ITargetPlatform.h"
#include "Interfaces/ITargetPlatformManagerModule.h"

#include "MaterialExpressionSignedPower.h"

#define LOCTEXT_NAMESPACE "MaterialExpressionSignedPower"

UMaterialExpressionSignedPower::UMaterialExpressionSignedPower(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
    // Structure to hold one-time initialization
    struct FConstructorStatics
    {
        FText NAME_Math;
        FConstructorStatics()
            : NAME_Math(LOCTEXT( "Math", "Math" ))
        {
        }
    };
    static FConstructorStatics ConstructorStatics;

#if WITH_EDITORONLY_DATA
    MenuCategories.Add(ConstructorStatics.NAME_Math);
#endif

    ConstExponent = 2;
}

#if WITH_EDITOR
int32 UMaterialExpressionSignedPower::Compile(class FMaterialCompiler* Compiler, int32 OutputIndex)
{
    if(!Base.GetTracedInput().Expression)
    {
        return Compiler->Errorf(TEXT("Missing Power Base input"));
    }

    int32 Arg1 = Base.Compile(Compiler);
    int32 Arg2 = Exponent.GetTracedInput().Expression ? Exponent.Compile(Compiler) : Compiler->Constant(ConstExponent);
    int32 PowerArg = Compiler->Power(
        Arg1,
        Arg2
        );
    int32 SignArg = Compiler->Sign(Arg1);
    int32 AbsArg = Compiler->Abs(PowerArg);
    return Compiler->Mul(AbsArg, SignArg);
}

void UMaterialExpressionSignedPower::GetCaption(TArray<FString>& OutCaptions) const
{
    FString ret = TEXT("SignedPower");

    if (!Exponent.GetTracedInput().Expression)
    {
        ret += FString::Printf( TEXT("(X, %.4g)"), ConstExponent);
    }

    OutCaptions.Add(ret);
}

void UMaterialExpressionSignedPower::GetExpressionToolTip(TArray<FString>& OutToolTip) 
{
    ConvertToMultilineToolTip(TEXT("Returns the Base value raised to the power of Exponent. Base value must be positive, values less than 0 will be clamped."), 40, OutToolTip);
}
#endif // WITH_EDITOR

こんな感じのファイルを自分のプロジェクトに配置してビルドします。
ビルドができたらマテリアルエディタで配置してみます。
ちゃんとMathカテゴリに追加されていますね。
image.png

こんなノードつないでみます。
image.png

Shader Codeを見ると

    MaterialFloat Local0 = (View.GameTime * 6.28318548);
    MaterialFloat Local1 = sin(Local0);
    MaterialFloat Local2 = PositiveClampedPow(Local1,2.00000000);
    MaterialFloat Local3 = sign(Local1);
    MaterialFloat Local4 = abs(Local2);
    MaterialFloat Local5 = (Local4 * Local3);

良い感じですね。割と簡単にMaterialExpressionを追加できます。プラグイン化も問題なし。

・・・と思ったのですが、世の中そんなに甘く無かった。

独自MaterialExpressionで作れるもの作れないもの

実はこの方法で作成できるMaterialExpressionには大きな制約があります。それはFMaterialCompilerクラスでpublic定義されている機能しか使えないことです。
具体的に言うと、もともと公開されているMaterialExpressionノードの機能以外はほぼ使えません。Compiler->Hogeで呼び出せるものだけで構成しないといけません。
それでもある程度の事はできるのですが、直にシェーダーコードを送り込むAddCodeChunkといった関数がprotectedになっているため、呼び出せません。
なので少し高度なことをやろうとすると途端に躓いてしまいます。CustomExpression関数を使ってCustomノードを作成して送り込むことは一応可能ですが、なかなか煩雑です。AddInlineCodeChunkだけでも使えればけっこう色々できるのですが、この先はエンジン改造しないと厳しそうです。
Epicさん、AddCodeChunkAddInlineCodeChunkをpubic公開してもらえませんか?

エンジン改造

せっかくなのでエンジン改造に手を染めます。
何をするかというと、AddInlineCodeChunkをプロジェクトコードから呼べるようにします。
まずはHLSLMaterialTranslator.hのAddInlineCodeChunkCereceParameter関数をpublicにします。
CereceParameterAddInlineCodeChunkに与えるパラメーターを作るのに必要です。

HLSLMaterialTranslator.h
    // 中略
public: //<- 追加
    /** 
     * Constructs the formatted code chunk and creates an inlined code chunk from it. 
     * This should be used instead of AddCodeChunk when the code chunk does not add any actual shader instructions, for example a component mask.
     */
    int32 AddInlinedCodeChunk(EMaterialValueType Type, const TCHAR* Format,...)
    {
    // 中略
    }

protected:  //<-追加

    // 中略

public: //<-追加
    // CoerceParameter
    FString CoerceParameter(int32 Index, EMaterialValueType DestType) override //<- override追加
    {
    // 中略
    }
protected:  //<-追加

次にMaterialCompiler.hにもこの2つの関数の宣言を追加します。

MaterialCompiler.h
    // 中略
class FMaterialCompiler
{
public:
    virtual ~FMaterialCompiler() { }

    virtual int32 AddInlinedCodeChunk(EMaterialValueType Type, const TCHAR* Format, ...) = 0;   //<-追加
    virtual FString CoerceParameter(int32 Index, EMaterialValueType DestType) = 0;              //<-追加

    // 中略

class FProxyMaterialCompiler : public FMaterialCompiler
{
public:

    // 中略

    //<-ここから追加
    virtual int32 AddInlinedCodeChunk(EMaterialValueType Type, const TCHAR* Format, ...) override
    {
        return Compiler->AddInlinedCodeChunk(Type, Format);
    }
    virtual FString CoerceParameter(int32 Index, EMaterialValueType DestType) override
    {
        return Compiler->CoerceParameter(Index, DestType);
    }
    //<-ここまで

これで独自のUMaterialExpression継承クラスから上記の2つの関数が呼べるようになります。

自前のsmoothstepを作ってみました。本家のLerpを参考にしました。

MaterialExpressionSmoothstep.h
#pragma once

#include "CoreMinimal.h"
#include "UObject/ObjectMacros.h"
#include "MaterialExpressionIO.h"
#include "Materials/MaterialExpression.h"
#include "MaterialExpressionSmoothstep.generated.h"

UCLASS(MinimalAPI, collapsecategories, hidecategories=Object)
class UMaterialExpressionSmoothstep : public UMaterialExpression
{
    GENERATED_UCLASS_BODY()

    UPROPERTY(meta = (RequiredInput = "false", ToolTip = "Defaults to 'ConstA' if not specified"))
    FExpressionInput A;

    UPROPERTY(meta = (RequiredInput = "false", ToolTip = "Defaults to 'ConstB' if not specified"))
    FExpressionInput B;

    UPROPERTY(meta = (RequiredInput = "false", ToolTip = "Defaults to 'ConstX' if not specified"))
    FExpressionInput X;

    /** only used if A is not hooked up */
    UPROPERTY(EditAnywhere, Category=MaterialExpressionLinearInterpolate, meta=(OverridingInputProperty = "A"))
    float ConstA;

    /** only used if B is not hooked up */
    UPROPERTY(EditAnywhere, Category=MaterialExpressionLinearInterpolate, meta=(OverridingInputProperty = "B"))
    float ConstB;

    /** only used if Alpha is not hooked up */
    UPROPERTY(EditAnywhere, Category=MaterialExpressionLinearInterpolate, meta=(OverridingInputProperty = "X"))
    float ConstX;


    //~ Begin UMaterialExpression Interface
#if WITH_EDITOR
    virtual int32 Compile(class FMaterialCompiler* Compiler, int32 OutputIndex) override;
    virtual void GetCaption(TArray<FString>& OutCaptions) const override;
#endif
    //~ End UMaterialExpression Interface

};
MaterialExpressionSmoothstep.cpp
//
//  UMaterialExpressionSmoothstep
//

#include "CoreMinimal.h"
#include "Materials/MaterialExpression.h"
#include "Materials/MaterialExpressionMaterialFunctionCall.h"
#include "Materials/MaterialExpressionMaterialAttributeLayers.h"
#include "Materials/MaterialFunctionInterface.h"
#include "Materials/MaterialFunction.h"
#include "Materials/MaterialFunctionMaterialLayer.h"
#include "Materials/MaterialFunctionMaterialLayerBlend.h"
#include "Materials/MaterialFunctionInstance.h"
#include "Materials/Material.h"
#include "Engine/Texture2D.h"
#include "Engine/TextureRenderTarget2D.h"
#include "Engine/Texture2DDynamic.h"
#include "Engine/TextureCube.h"
#include "Engine/TextureRenderTargetCube.h"
#include "Engine/VolumeTexture.h"

#include "MaterialCompiler.h"
#if WITH_EDITOR
#include "MaterialGraph/MaterialGraphNode_Comment.h"
#include "MaterialGraph/MaterialGraphNode.h"
#include "Framework/Notifications/NotificationManager.h"
#include "Widgets/Notifications/SNotificationList.h"
#endif //WITH_EDITOR
#include "Materials/MaterialInstanceConstant.h"
#include "Curves/CurveLinearColorAtlas.h"
#include "Interfaces/ITargetPlatform.h"
#include "Interfaces/ITargetPlatformManagerModule.h"

#include "MaterialExpressionSmoothstep.h"

#define LOCTEXT_NAMESPACE "MaterialExpressionSmoothstep"

UMaterialExpressionSmoothstep::UMaterialExpressionSmoothstep(const FObjectInitializer& ObjectInitializer)
    : Super(ObjectInitializer)
{
    // Structure to hold one-time initialization
    struct FConstructorStatics
    {
        FText NAME_Math;
        FText NAME_Utility;
        FConstructorStatics()
            : NAME_Math(LOCTEXT( "Math", "Math" ))
            , NAME_Utility(LOCTEXT( "Utility", "Utility" ))
        {
        }
    };
    static FConstructorStatics ConstructorStatics;

    ConstA = 0;
    ConstB = 1;
    ConstX = 0.5f;

#if WITH_EDITORONLY_DATA
    MenuCategories.Add(ConstructorStatics.NAME_Math);
    MenuCategories.Add(ConstructorStatics.NAME_Utility);
#endif
}

#if WITH_EDITOR
int32 UMaterialExpressionSmoothstep::Compile(class FMaterialCompiler* Compiler, int32 OutputIndex)
{
    // if the input is hooked up, use it, otherwise use the internal constant
    int32 Arg1 = A.GetTracedInput().Expression ? A.Compile(Compiler) : Compiler->Constant(ConstA);
    // if the input is hooked up, use it, otherwise use the internal constant
    int32 Arg2 = B.GetTracedInput().Expression ? B.Compile(Compiler) : Compiler->Constant(ConstB);
    // if the input is hooked up, use it, otherwise use the internal constant
    int32 Arg3 = X.GetTracedInput().Expression ? X.Compile(Compiler) : Compiler->Constant(ConstX);

    EMaterialValueType ResultType = Compiler->GetParameterType(Arg3);
    EMaterialValueType AType = Compiler->GetParameterType(Arg1);
    EMaterialValueType BType = Compiler->GetParameterType(Arg2);
    return Compiler->AddInlinedCodeChunk(ResultType, TEXT("smoothstep(%s,%s,%s)"),
        *Compiler->CoerceParameter(Arg1,AType),
        *Compiler->CoerceParameter(Arg2,BType),
        *Compiler->CoerceParameter(Arg3,ResultType));
}

void UMaterialExpressionSmoothstep::GetCaption(TArray<FString>& OutCaptions) const
{
    FString ret = TEXT("smoothstep");

    FExpressionInput ATraced = A.GetTracedInput();
    FExpressionInput BTraced = B.GetTracedInput();
    FExpressionInput XTraced = X.GetTracedInput();

    if(!ATraced.Expression || !BTraced.Expression || !XTraced.Expression)
    {
        ret += TEXT("(");
        ret += ATraced.Expression ? TEXT(",") : FString::Printf( TEXT("%.4g,"), ConstA);
        ret += BTraced.Expression ? TEXT(",") : FString::Printf( TEXT("%.4g,"), ConstB);
        ret += XTraced.Expression ? TEXT(")") : FString::Printf( TEXT("%.4g)"), ConstX);
    }

    OutCaptions.Add(ret);
}
#endif // WITH_EDITOR

このノードにテクスチャを入れてみます。
image.png

    MaterialFloat4 Local0 = ProcessMaterialColorTextureLookup(Texture2DSampleBias(Material.Texture2D_0, Material.Texture2D_0Sampler,Parameters.TexCoords[0].xy,View.MaterialTextureMipBias));
    MaterialFloat3 Local1 = (smoothstep(0.50000000,1.00000000,Local0.rgb) + Material.VectorExpressions[1].rgb);

RGBにまとめてsmoothstepをかけることができるノードをつくることができました。

こんなふうにシェーダーコードを送り込むMaterialExpressionをプロジェクトに実装することができるようになります。

おまけ

SceneTextureノードのPostprocessInput0~6ってなに?

そのポストプロセスが実行される時点の描画ターゲットがPostprocssInput0に入っているので、基本画面を加工するポストプロセスはPostprocessInput0を参照するわけですが、1~6には何が入ってるんだろうと疑問に感じたことはありませんか?
ちょっと調べてみましたが、結論は「特に決まっていない」です。その都度いろいろなバッファが割り当てられたり割り当てられなかったりのようのです。AmbientOcclusionやDoFなどの多数のバッファを作成、参照するポストプロセスでその都度使用されています。つまりエンジンのポストプロセスで適当に使われているというのが実情のようです。

とはいってもなにか使いみちが無いかな?とコードを追うと、SeparateTranslucencyバッファがPostprocessInput1にアサインされていました。そこで、こんなポストプロセスマテリアルを作ってみました。
image.png

半透明の球を置いたシーンにこのポストプロセスマテリアルをアサインすると

image.png

半透明オブジェクトの部分以外をモノトーンにする効果になります。

エンジンのバージョンが変わったらPostporcessInput1のアサインも変わるかもしれませんけど。

まとめ

ということで今年のアドベントカレンダーはUE4のMaterialExpressionについていろいろ調査してみました。
エンジン改造しなくてもどんどん独自のMaterialExpressionを追加できるようになると良いですね。

それではまた来年お会いしましょう。良いお年を!

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
What you can do with signing up
11
Help us understand the problem. What are the problem?