LoginSignup
17
15

More than 1 year has passed since last update.

UE4の描画パスをプラグインで追加する

Posted at

はじめに

 UE4 4.25くらいからかと思いますが、描画パスをエンジン改造無しで追加する方法を発見したので広めたいと思いました。
 きっかけはRenderDocで描画を調べている時に、PostOpaqueExtentionsというパスを見つけた事でした。これはなんだろうと調べてみると、文字通り不透明関連の描画が一通り終わって、これから半透明描画に入るタイミングで描画用のDelegateを呼び出すという機能でした。
 実際にこの機能を使用しているのは何だろうとつらつら調べると、GPULightmassのデバッグ描画が使用していました。
 この機能を使うと、半透明描画前だけという制約があるものの、独自の描画パスをエンジン改造無しに追加できそうです。使いようによっては新しい未来が開けるかも!

PostOpaqueExtentionsのタイミング

 もう少し詳しく、この描画パスのタイミングを解説します。
image.png

 直前のVolumetricComposeOverSceneはレンダーターゲットに描画されたVolumetric Cloudなどを画面に合成するパスです。
その後Translucencyで半透明を描画しますが、Separate Translucencyが有効になっている場合、半透明は実際にはこの後のPostprocessingの中で描画されます。Separate Translucencyを無効にしている場合や、Render After Dofを無効にしているマテリアルはこのタイミングで描画されます。

 ざっくり言うと「ライティングが終わってフォグやら空やら雲やらが描画されたところ」でこのパスが呼び出されます。

 「じゃあPostProcess MaterialでBefore Translucencyでやれば良いんじゃね?」と思われるかもしれません。はい。だいたいそうです。ただ、PostProcess Materialには様々な制約があり出来ない事も多いのですがC++コードとシェーダーコードの組み合わせでより複雑な処理が可能になります。デバッグ情報などを描画することもできますし、マテリアルだけで組めない複雑な処理や文字やグラフなども描画できそうです。
 また、Postprocess MaterialのBefore TranslucencyはSeparate Translucencyよりは前ですが、通常半透明やディストーションよりは後に描画されます。PostOpaqueExtentionsはディストーションの前にユーザーが描画を定義できる唯一の機会になります。
 実はこれがけっこう重要で、例えばポストプロセスでアウトラインなどを描画した場合に、ディストーションで歪んだシーンカラーとGBufferの内容が一致しないため、ディストーションがかかった部分のアウトラインがズレるという現象が発生します。

どんな描画が可能?

 さて、では実際にPostOpaqueExtentionsで何ができるのでしょうか。それを知るためにはDelegateに渡されるパラメーターを見れば類推できます。
 Delegateはこのように呼び出されます。

SceneRendering.cpp
void FRendererModule::RenderPostOpaqueExtensions(FRDGBuilder& GraphBuilder, TArrayView<const FViewInfo> Views, FSceneRenderTargets& SceneContext)
{
// 中略
                FPostOpaqueRenderParameters RenderParameters;
                RenderParameters.ViewMatrix = View.ViewMatrices.GetViewMatrix();
                RenderParameters.ProjMatrix = View.ViewMatrices.GetProjectionMatrix();
                RenderParameters.DepthTexture = SceneContext.GetSceneDepthSurface()->GetTexture2D();
                RenderParameters.NormalTexture = SceneContext.GBufferA.IsValid() ? SceneContext.GetGBufferATexture() : nullptr;
                RenderParameters.VelocityTexture = SceneContext.SceneVelocity.IsValid() ? SceneContext.SceneVelocity->GetRenderTargetItem().ShaderResourceTexture->GetTexture2D() : nullptr;
                RenderParameters.SmallDepthTexture = SceneContext.GetSmallDepthSurface()->GetTexture2D();
                RenderParameters.ViewUniformBuffer = View.ViewUniformBuffer;
                RenderParameters.SceneTexturesUniformParams = CreateSceneTextureUniformBuffer(RHICmdList, View.FeatureLevel, ESceneTextureSetupMode::SceneColor | ESceneTextureSetupMode::SceneDepth | ESceneTextureSetupMode::SceneVelocity | ESceneTextureSetupMode::GBuffers);
                RenderParameters.GlobalDistanceFieldParams = &View.GlobalDistanceFieldInfo.ParameterData;

                RenderParameters.ViewportRect = View.ViewRect;
                RenderParameters.RHICmdList = &RHICmdList;

                RenderParameters.Uid = (void*)(&View);
                PostOpaqueRenderDelegate.Broadcast(RenderParameters);
// 後略

ViewMatrix ビュー行列
ProjMatrix プロジェクション行列
DepthTexture 深度テクスチャ
NormalTexture 法線テクスチャ
VelocityTexture ヴェロシティテクスチャ、モーションブラーなどに使用するピクセルの移動速度が格納されてる
SmallDepthTexture 1/2縮小された深度テクスチャ。オクルージョンやチェッカーボードに使用する
ViewUniformBuffer ビュー関連のシェーダーパラメーター
SceneTexturesUniformParams 各種テクスチャのシェーダーパラメーター
GlobalDistanceFieldParams ディスタンスフィールド関連データ。これは調べてないので良くわからない。
ViewportRect ビューのサイズ
RHICmdList 描画コマンド関連

豪華ですね。大体の描画に必要なものは概ね揃っています。
さらにScenneTexturesUniformParamsの中身はというと

BEGIN_GLOBAL_SHADER_PARAMETER_STRUCT(FSceneTextureUniformParameters, RENDERER_API)
    // Scene Color / Depth
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, SceneColorTexture)
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, SceneDepthTexture)

    // GBuffer
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, GBufferATexture)
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, GBufferBTexture)
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, GBufferCTexture)
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, GBufferDTexture)
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, GBufferETexture)
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, GBufferFTexture)
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, GBufferVelocityTexture)

    // SSAO
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, ScreenSpaceAOTexture)

    // Custom Depth / Stencil
    SHADER_PARAMETER_RDG_TEXTURE(Texture2D, CustomDepthTexture)
    SHADER_PARAMETER_SRV(Texture2D<uint2>, CustomStencilTexture)

    // Misc
    SHADER_PARAMETER_SAMPLER(SamplerState, PointClampSampler)
END_GLOBAL_SHADER_PARAMETER_STRUCT()

 SceneColorや各種GBufferも取得できます。

SceneColorをテクスチャとして参照は4.26ではできるのですが、4.27ではできませんでした。自力でResolveなど、処理を足せば使えそうな気がします。

 マテリアルノードでお気楽とは行かないまでも、C++コードとShaderコードを書けば大抵の描画機能は実装できそうです。夢がひろがりますね!

プラグインを作ってみよう

 さて、せっかくなので実際にこの機能を使って描画してみたいと思います。
 ということでプラグインを作ってみたのですが、そこそこ引っかかるポイントがあったのでそれもお教えします。
 そこまで詳しくは説明しないので、プラグインの作成が初めての人は詳しい作り方を検索するなどしてください。

プロジェクトを準備

 UE4エディタとC++でビルドできる環境を用意します。そして作業用に適当なC++プロジェクトを作って起動しましょう。今回はUE4 4.27を使用していますが、4.26でも大丈夫です。それ以前のバージョンではテストしていません。

空のプラグインを作成

 UE4エディタを起動し、編集->プラグインでプラグイン管理画面を開き、新しいプラグインを選択。
 今回はブループリントライブラリを選択します。プラグイン名はPostOpaqueDrawTestにしてみました。
image.png
 プラグインを作成を選択するとプラグインのソースコードが作成されます。

C++プロジェクトを読みこんで編集する。

 いよいよプラグイン作成を開始します。実際には試行錯誤で少しずつ作業しましたがここでは一気に説明していきます。
 Visual StudioなどでC++プロジェクトを読み込みます。Windows以外の環境はテストしていませんが、多分動作可能かと思います。

プラグイン設定の変更

 まず最初にupluginファイルを編集します。
 LoadingPhaseをPostConfigInitにします。

    "Modules": [
        {
            "Name": "PostOpaqueDrawTest",
            "Type": "Runtime",
            "LoadingPhase": "PostConfigInit"
        }
    ]

 このプラグインはShader Codeを含むので、LoadingPhaseがデフォルトのままだとShader Compileができなくてクラッシュします。PostConfigInitにしておきましょう。

モジュールの設定

 PostOpaqueDrawTest.Build.csを開きます。

 このプラグインではUE4のPrivateフォルダにあるヘッダをインクルードする必要があるのですが、これが結構厄介です。UE4のソースコードフォルダを指定する必要があるのですが、プロジェクトとエンジンのフォルダの位置が人それぞれなので一旦絶対パスで記述します。

 PrivateIncludePathsにエンジンのソースコードパスを追加します。

        PrivateludePaths.AddRange(
            new string[] {
                "D:/Epic/UE_4.27/Engine/Source/Runtime/Renderer/Private"
            }
            );

プラグインが完成したらプラグインごとEngine/Plugins/MarketPlaceフォルダなどに移動して相対パスにするのが良いです。 その場合は "../../../../Source/Runtime/Renderer/Private" になります。

 PrivateDependencyModuleNamesにこのプラグインで使用するモジュールを追加します。
 Projects,Renderer,RenderCoreの3つを追加します。

        PrivateDependencyModuleNames.AddRange(
            new string[]
            {
                "CoreUObject",
                "Engine",
                "Slate",
                "SlateCore",
                // ... add private dependencies that you statically link with here ...  
                "Projects",
                "Renderer",
                "RenderCore",
                "RHI"
            }
            );

 Projectsはこのプラグインのパスを取得するのに必要、Renderer,RenderCoreは描画機能を実装するために必要です。

モジュールコードの編集

 PostOpaqueDrawTest.cppを編集します。
 モジュールコードに追加する機能は2つ。プラグインフォルダに用意したシェーダーコードを読み込むための設定と、テスト描画用のクラスの生成と破棄です。

PostOpaquesDrawTest.cpp
// Copyright Epic Games, Inc. All Rights Reserved.

#include "PostOpaqueDrawTest.h"
#include "Interfaces/IPluginManager.h"
#include "TestRenderer.h"

#define LOCTEXT_NAMESPACE "FPostOpaqueDrawTestModule"

// 描画クラスオブジェクト
static ATestRenderer* TestRenderer = nullptr;

// 描画登録コマンド
static FAutoConsoleCommand CCmdPostOpaqueDrawTestRegister(TEXT("PostOpaqueDrawTest.Register"),TEXT(""),
    FConsoleCommandDelegate::CreateLambda([]()
    {
        FPostOpaqueDrawTestModule::RegisterDrawTest();
    }
));

// 描画解除コマンド
static FAutoConsoleCommand CCmdPostOpaqueDrawTestUnRegister(TEXT("PostOpaqueDrawTest.UnRegister"),TEXT(""),
    FConsoleCommandDelegate::CreateLambda([]()
    {
        FPostOpaqueDrawTestModule::UnRegisterDrawTest();
    }
));

void FPostOpaqueDrawTestModule::StartupModule()
{
    // This code will execute after your module is loaded into memory; the exact timing is specified in the .uplugin file per-module
    // Shader Codeのフォルダをエンジンに登録
    FString PluginShaderDir = FPaths::Combine(IPluginManager::Get().FindPlugin(TEXT("PostOpaqueDrawTest"))->GetBaseDir(), TEXT("Shaders"));
    AddShaderSourceDirectoryMapping(TEXT("/Plugin/PostOpaqueDrawTest"), PluginShaderDir);

    // 描画クラスオブジェクトを作成
    TestRenderer = new ATestRenderer();
}

void FPostOpaqueDrawTestModule::ShutdownModule()
{
    // This function may be called during shutdown to clean up your module.  For modules that support dynamic reloading,
    // we call this function before unloading the module.
    delete TestRenderer;
}

void FPostOpaqueDrawTestModule::RegisterDrawTest()
{
    // テスト描画クラスをエンジンに登録
    TestRenderer->Register();
}

void FPostOpaqueDrawTestModule::UnRegisterDrawTest()
{
    // テスト描画クラスを解除
    TestRenderer->UnRegister(); 
}
#undef LOCTEXT_NAMESPACE

IMPLEMENT_MODULE(FPostOpaqueDrawTestModule, PostOpaqueDrawTest)
PostOpaqueDrawTest.h
// Copyright Epic Games, Inc. All Rights Reserved.

#pragma once

#include "Modules/ModuleManager.h"

class FPostOpaqueDrawTestModule : public IModuleInterface
{
public:

    /** IModuleInterface implementation */
    virtual void StartupModule() override;
    virtual void ShutdownModule() override;

    static void RegisterDrawTest();
    static void UnRegisterDrawTest();
};

描画テストクラス

 実際に描画テストを行うクラスを作成します。

TestRenderer.h
// Fill out your copyright notice in the Description page of Project Settings.

#pragma once

#include "CoreMinimal.h"

/**
 * 
 */
class POSTOPAQUEDRAWTEST_API ATestRenderer
{
public:
    ATestRenderer();
    ~ATestRenderer();

    void Register();
    void UnRegister();

    void Renderer(FPostOpaqueRenderParameters& Parameters);

private:
    FDelegateHandle RendererDelegateHandle; // 描画delegateのハンドル
};
TestRenderer.cpp
// Fill out your copyright notice in the Description page of Project Settings.

#include "TestRenderer.h"
#include "EngineModule.h"
#include "SceneRendering.h"
#include "PostProcess/SceneFilterRendering.h"
#include "PostProcess/PostProcessing.h"

ATestRenderer::ATestRenderer()
{
}

ATestRenderer::~ATestRenderer()
{
}

// レンダーモジュールに登録 
void ATestRenderer::Register()
{
    RendererDelegateHandle = GetRendererModule().RegisterPostOpaqueRenderDelegate(FPostOpaqueRenderDelegate::CreateRaw(this, &ATestRenderer::Renderer));
}

// レンダーモジュールから解除
void ATestRenderer::UnRegister()
{
    if (RendererDelegateHandle.IsValid())
    {
        GetRendererModule().RemovePostOpaqueRenderDelegate(RendererDelegateHandle);
        RendererDelegateHandle.Reset();
    }
}

//ピクセルシェーダーの定義
class FTestRendererPS : public FGlobalShader
{
public:
    DECLARE_GLOBAL_SHADER(FTestRendererPS);
    SHADER_USE_PARAMETER_STRUCT(FTestRendererPS, FGlobalShader)

    BEGIN_SHADER_PARAMETER_STRUCT(FParameters, )
        SHADER_PARAMETER_STRUCT_REF(FViewUniformShaderParameters, View)
        SHADER_PARAMETER_STRUCT_REF(FSceneTextureUniformParameters, SceneTextures)
        RENDER_TARGET_BINDING_SLOTS()
    END_SHADER_PARAMETER_STRUCT()

    static bool ShouldCompilePermutation(const FGlobalShaderPermutationParameters& Parameters)
    {
        return IsFeatureLevelSupported(Parameters.Platform, ERHIFeatureLevel::SM5);
    }
};

IMPLEMENT_SHADER_TYPE(, FTestRendererPS, TEXT("/Plugin/PostOpaqueDrawTest/Private/TestRenderer.usf"), TEXT("TestPS"), SF_Pixel);

// 実際の描画処理 
void ATestRenderer::Renderer(FPostOpaqueRenderParameters& Parameters)
{
    FRHICommandListImmediate& RHICmdList = *Parameters.RHICmdList;

    FTestRendererPS::FParameters PassParameters;
    TUniformBufferRef<FViewUniformShaderParameters> Ref;
    *Ref.GetInitReference() = Parameters.ViewUniformBuffer;
    PassParameters.View = Ref;
    PassParameters.SceneTextures = CreateSceneTextureUniformBuffer(RHICmdList, GMaxRHIFeatureLevel, ESceneTextureSetupMode::All);

    FUniformBufferStaticBindings GlobalUniformBuffers(PassParameters.SceneTextures);
    SCOPED_UNIFORM_BUFFER_GLOBAL_BINDINGS(RHICmdList, GlobalUniformBuffers);

    // 描画先をSceneColorに設定
    FRHIRenderPassInfo RPInfo(FSceneRenderTargets::Get(RHICmdList).GetSceneColor()->GetTargetableRHI(), ERenderTargetActions::Load_Store);
    RHICmdList.BeginRenderPass(RPInfo, TEXT("PostOpaqueDrawTest"));
    RHICmdList.SetViewport(0, 0, 0.0f, Parameters.ViewportRect.Width(), Parameters.ViewportRect.Height(), 1.0f);

    TShaderMapRef<FPostProcessVS> VertexShader(GetGlobalShaderMap(GMaxRHIFeatureLevel));
    TShaderMapRef<FTestRendererPS> PixelShader(GetGlobalShaderMap(GMaxRHIFeatureLevel));

    FGraphicsPipelineStateInitializer GraphicsPSOInit;
    RHICmdList.ApplyCachedRenderTargets(GraphicsPSOInit);
    GraphicsPSOInit.RasterizerState = TStaticRasterizerState<FM_Solid, CM_None>::GetRHI();
    GraphicsPSOInit.DepthStencilState = TStaticDepthStencilState<false, CF_Always>::GetRHI();
    // 半透明描画用のBlendStateの設定
    GraphicsPSOInit.BlendState = TStaticBlendState<CW_RGBA, BO_Add, BF_SourceAlpha, BF_InverseSourceAlpha, BO_Add, BF_Zero, BF_InverseSourceAlpha>::GetRHI();
    GraphicsPSOInit.PrimitiveType = PT_TriangleList;
    GraphicsPSOInit.BoundShaderState.VertexDeclarationRHI = GFilterVertexDeclaration.VertexDeclarationRHI;
    GraphicsPSOInit.BoundShaderState.VertexShaderRHI = VertexShader.GetVertexShader();
    GraphicsPSOInit.BoundShaderState.PixelShaderRHI = PixelShader.GetPixelShader();

    SetGraphicsPipelineState(RHICmdList, GraphicsPSOInit);

    SetShaderParameters(RHICmdList, PixelShader, PixelShader.GetPixelShader(), PassParameters);

    // 実際の描画、View全体に描画する。
    DrawRectangle(
        RHICmdList,
        0, 0,
        Parameters.ViewportRect.Width(), Parameters.ViewportRect.Height(),
        0, 0,
        Parameters.ViewportRect.Width(), Parameters.ViewportRect.Height(),
        FIntPoint(Parameters.ViewportRect.Width(), Parameters.ViewportRect.Height()),
        FSceneRenderTargets::Get(RHICmdList).GetBufferSizeXY(),
        VertexShader);

    RHICmdList.EndRenderPass();
}

#include "SceneRendering.h" #include "PostProcess/SceneFilterRendering.h" #include "PostProcess/PostProcessing.h" をincludeするために、PostOpaqueDrawTest.Build.csにエンジンパスの設定が必要です。

シェーダーコード

Plugins/PostOpaqueDrawTest/Shaders/Pricate/TestRenderer.usfを作成します。

TestRenderer.usf
#pragma once

#include "/Engine/Private/Common.ush"
#include "/Engine/Private/DeferredShadingCommon.ush"

void TestPS(
    in float4 UVAndScreenPos : TEXCOORD0, 
    in float4 SVPos : SV_POSITION,
    out float4 OutColor : SV_Target0)
{
    float2 ScreenUV = SvPositionToBufferUV(SVPos);      // UVを取得
    float3 Normal0 = GetGBufferData(ScreenUV).WorldNormal;  // 法線取得
    // 法線アウトラインを計算(適当)
    float ndlsum = 
        saturate(dot(Normal0, GetGBufferData(ScreenUV+ float2(-1,-1) * View.BufferSizeAndInvSize.zw).WorldNormal)) +
        saturate(dot(Normal0, GetGBufferData(ScreenUV+ float2(-1,0) * View.BufferSizeAndInvSize.zw).WorldNormal)) +
        saturate(dot(Normal0, GetGBufferData(ScreenUV+ float2(1,0) * View.BufferSizeAndInvSize.zw).WorldNormal)) +
        saturate(dot(Normal0, GetGBufferData(ScreenUV+ float2(1,1) * View.BufferSizeAndInvSize.zw).WorldNormal));
    float outline = smoothstep(0.9, 1.0, (ndlsum * 0.25));

    // アウトラインを半透明描画で合成
    OutColor = float4(1,0,0,1-outline);
}

 テストサンプルとして、GBufferからワールド法線を読み出して簡単なアウトラインを生成して半透明で描画するものを作成してみました。

機能の制御

 このテスト描画を有効にする場合は、コンソールコマンドから
PostOpaqueDrawTest.Register
 と入力してください。C++コードから呼び出す場合は
FPostOpaqueDrawTestModule::RegisterDrawTest();
 で。BluePrintFunctionLibraryから呼び出せばBluePrintから呼び出せます。

ちょっとしたコンソールコマンドはCheatManagerなどを使用しなくてもこういった方法で簡単に追加できて便利です。

実行結果

 プラグインをビルドして実行した結果です。
 わかりやすいように屈折率を設定した透明な球を置いてみました。ポストプロセスでアウトラインを生成するとDistortionパスの後で描画されるため、屈折を無視してしまいますが、この方法ならアウトラインにも屈折がかかります。

image.png

実装のポイントまとめ

  • LoadingPhaseを変更しないとシェーダーコンパイルでクラッシュする
  • エンジンコードへのパス設定が必要
  • プラグイン内のシェーダーパスの登録が必要

あたりがつまづきやすいポイントかと思います。

雑多な注意事項

エンジンバージョンによって仕様が変わりそう

 割と古いバージョンのUE4からこの機能は存在していたみたいですが、実際に運用されはじめたのは4.25あたりからのようです。GPULightmassの実装が始まったころからと思われます。今後も仕様が変わるかもしれないので同じ様に使用し続けられるかはなんとも言えません。エンジンのアップデートごとに対応が必要になるかもしれません。

SceneColorを参照してSceneColorに描画するときには注意が必要

 どういうことかというと、描画対象と参照テクスチャが同じ場合には読み込むピクセルと書き込むピクセルの位置が違うと正しい結果にならない場合があります。書き換わったピクセルを読んでしまったり。具体的な例ではSceneColorをブラー処理してSceneColorに書き込みをしようとしてもおかしな描画になります。この場合はSceneColorを一旦別のバッファにコピーしてそれを参照するといった対応が必要になります。

ゲームに組み込むには自己責任で

 恐らくエンジン開発者がデバッグ描画などに使う意図で実装した機能なので、そのままゲームに組み込んだ場合の動作は保証されないと思うのであくまでも自己責任で。独自のデバッグ描画を実装するのには便利そうです。

まとめ

 エンジンの描画パスを拡張可能なPostOpaqueExtentionsについて記事にしてみました。UE4のエンジン内部をいろいろ探索すると面白い発見があります。
 SceneColorを参照してSceneColorに書き出すといったPostprocess Materialのような使い方をするには手間が必要だと思いますが、アイディア次第ではいろいろ面白いことができるかもしれません。

 余談ですが、PostProcessMaterialの描画パス、内部的にはいろいろ定義はされていますがコメントアウトされて実装されていません。

/** Where to place a material node in the post processing graph. */
UENUM()
enum EBlendableLocation
{
    /** Input0:former pass color, Input1:SeparateTranslucency. */
    BL_AfterTonemapping UMETA(DisplayName="After Tonemapping"),
    /** Input0:former pass color, Input1:SeparateTranslucency. */
    BL_BeforeTonemapping UMETA(DisplayName="Before Tonemapping"),
    /** Input0:former pass color, Input1:SeparateTranslucency. */
    BL_BeforeTranslucency UMETA(DisplayName="Before Translucency"),
    /**
    * Input0:former pass color, Input1:SeparateTranslucency, Input2: BloomOutput
    * vector parameters: Engine.FilmWhitePoint
    * scalar parameters: Engine.FilmSaturation, Engine.FilmContrast
    */
    BL_ReplacingTonemapper UMETA(DisplayName="Replacing the Tonemapper"),
//  BL_AfterOpaque,
//  BL_AfterFog,
//  BL_AfterTranslucency,
//  BL_AfterPostProcessAA,

    /** Input0:former pass color. */
    BL_SSRInput UMETA(DisplayName = "SSR Input"),

    BL_MAX,
};

 仕事ではどうしてもフォグやアトモスフィアの前に描画したいポストプロセスがあるのでBL_AfterOpaqueを自力で実装してたりしますが、いつの日か公式に実装されるのでしょうか。

17
15
1

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
  3. You can use dark theme
What you can do with signing up
17
15