3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[UE5 ポストプロセス] 『都市伝説解体センター』風シェーダーを作ってみた

3
Last updated at Posted at 2025-11-10

1 はじめに

『都市伝説解体センター』は、最近プレイして気に入ったゲームです。ゲームのストーリー自体ももちろん好きですが、最も印象に残ったのは、やはり本作の画面表現です。基本は低解像度のドット絵で構成されたレトロな2D風ゲームですが、カットシーンではドット絵でありながら3D風のアニメーション演出も見られました。レトロな雰囲気と現代的な要素が融合した表現は、本作の魅力のひとつと言えるでしょう。

そこで今回は、UEでのシェーダー作りの勉強も兼ねて、『都市伝説解体センター』風の画面をUEの3Dシーンで再現してみました。

完成したセットアップはこちら (GitHub)

実行環境

Unreal Engine 5.6.1
Windows 11 25H2
Demo Project: Hillside Sample Project - Epic Games

この記事では、画面を分析するために、原作ゲームのスクリーンショットをいくつ引用しています。ネタバレが含まれている可能性がありますので、ご注意ください。

Demo

Hero_Dark2.png
Hero_Light2.png
Slide 16_9 - 17HERO.png
Hero_Light1.png
BLR_01.png

2 原作考察と全体構成

2.1 再現対象

まず、再現する対象を明確にします。今回はゲームプレイ中の2D画面ではなく、3D調のカットシーン演出画面を再現します。また、カットシーンでは本来赤色を使って怪異を表現していますが、今回はそれも省き、ブルートーンを基調としたベース画面のみに注目します。

IMG_1685.JPG
©︎Hakababunko / SHUEISHA, SHUEISHA GAMES

2.2 構成分析

カットシーンを見ると、「ピクセル(ドット絵)化」「アウトライン」「カラーマップ」という3つのエフェクト(ポストプロセスマテリアル)が必要であることが分かります。しかし、具体的な実行順序や実装方法を決めるには、原作画面をもっと深堀って分析しなければなりません。

最初に注目すべきのは、「ボケ」(bokeh) 効果です。リアルのカメラ映像のような背景のぼかしによって、3D空間の遠近感を自然に表現されています。スクリーンショットをよく見ると、ボケがかかっている部分でも、ドット絵のピクセルが一つ一つはっきり見えます。

Slide 16_9 - 1.png
©︎Hakababunko / SHUEISHA, SHUEISHA GAMES

つまり、このボケ効果は、「ピクセルをぼかす」ではなく、「ぼかした画面をピクセル化」することによって生まれるものです。したがって、Unreal Engine内では、「ピクセル化」ステップのBlendable LocationをScene Color After DOF以降に設定する必要があります。

さらに、一部のカットシーンでは、明るい光源によるBloom効果も見られます。この場合もボケと同様に、ピクセルの境界がはっきりと確認できます。これらを踏まえると、「ピクセル化」マテリアルはBloomの計算が終わった後のScene Color After Tonemappingに配置するのが合理的です。

Slide 16_9 - 2.png
©︎Hakababunko / SHUEISHA, SHUEISHA GAMES

一方、アウトライン描画とカラーマッピング2つのマテリアルは、一番最初のScene Color Before DOFで実行しないといけません。その理由も、ボケの部分に示されています。本作のカラーパレットはメイン8色+赤と黄で構成されます。しかし実際には、ボケ部分の色は段階的に変わるのではなく、連続的なグラデーションになります。つまり、UEがDOFを計算する段階では、最終的なカラーパレットとアウトラインはすでに描画された状態になっています。ボケ効果によって、自然な色の変化が実現されます。

6fc9d81f0661ce0bc494681eddb15b1c88994358.jpg
本作のカラーパレット。CGWORLD.jp

Slide 16_9 - 3.png
©︎Hakababunko / SHUEISHA, SHUEISHA GAMES

最後に残したのは、「アウトライン」と「カラーマップ」の順序です。この問題を解き明かすために、原作のアウトライン描画のパターンを探してみました。

GENSEKIマガジンのインタビューによると、本作の主線は主に2色の紺色が使用されていますが、実際には所々で例外も見られます。例えば、明るい顔(「ベージュ・明」)の主線には、よく「ベージュ・暗」が使われています。また、前景と背景の明るさのコントラストが低い場合、アウトラインが環境に近い色になることがあります。

一言で言うと、アウトラインも固定の色ではなく、カラーパレットに従っています。そのため、アウトラインを先に描画し、元の画面と一緒にカラーマッピングする方が効率的だと思います。

Slide 16_9 - 4.png
©︎Hakababunko / SHUEISHA, SHUEISHA GAMES
Slide 16_9 - 5.png
©︎Hakababunko / SHUEISHA, SHUEISHA GAMES

まとめると、ポストプロセスの構成以下の通りです。

Scene Color Before DOF[アウトライン描画カラーマッピング]→ Scene Color After Tonemapping[ピクセル化](+α?)

3 実装

次に、各パートのマテリアルを実装していきます。文章の流れのため、「ピクセル化」ステップについて先に説明したいと思います。

3.1 ピクセル(ドット絵)化

原作は、1080p出力の8分の1で、240×135ピクセルのキャンバスサイズを使用しています。低解像度だからこそのインパクトは原作の魅力ですが、フォトリアリスティックのUEシーンで使用すると、さすが限界を感じます。3D画面の情報量はここまで詰め込んだら、背景がほとんどノイズのようになって、何がいるのか分からなくなってしまいます。

なので、今回私は、1440p出力で6倍のダウンサンプル(427×240、原作の1.78倍)を使っています。比較的にシンプルのシーンであれば、8倍(320×180、原作の1.33倍)でも良い仕上がりになります。状況に応じて調整し、バランスを取ることが重要だと思います。

Slide 16_9 - 6PXL_.png

Material Graph & HLSL

CODE_PXL.png

float2 DSResolution = View.ViewSizeAndInvSize.xy * View.ResolutionFractionAndInv.y / DSScale;
float2 DSPos = floor(Parameters.TexCoords[0].xy * DSResolution) + 0.5f;
float2 DSUV = DSPos / DSResolution;

return SceneTextureLookup(ClampSceneTextureUV(ViewportUVToSceneTextureUV(DSUV, 14), 14), 14, true);

ピクセル化マテリアルにおいて、注意すべきポイントが2つあります。

1つ目は、ピクセルのUV計算をViewport UVに基づいて行う必要があるという点です。SceneTexture UV(GetDefaultSceneTextureUV 関数)を使わない理由は、UEのEditor内では、Viewportの範囲は必ずScene Texture UVの [0, 1] 区間になるわけではありません。リサイズによってScene Textureは実際のViewportより大きくなる場合がよくあります。その場合、描画されたピクセルが正方形ではなく長方形になってしまいます。

Slide 16_9 - 7PXL_.png

2つ目は、ピクセルのサイズ一貫性を確保することです。View.ViewSizeAndInvSizeの戻り値はScreen Resolutionではなく、Render Resolutionです。ViewSizeにView.ResolutionFractionAndInv.y(Screen Percentageの逆数)を掛けることで、最終的な出力解像度になります。これにより、Screen Percentageの設定に左右されず、ピクセルのサイズを制御できます。

Slide 16_9 - 8PXL_.png

同様の理由で、他のマテリアルのKernel SizeやUV Offsetなどを計算する際にも、View.ResolutionFractionAndInv係数を掛けることが多いため、以下では個別の説明は省略します。

3.2 アウトライン描画

アウトラインのマテリアルは、95%はVisual Tech Artさんのセルルックシェーダーで使われた、DepthとNormal Bufferを活用したエッジ検出アルゴリズムを利用しています。詳しい理論や実装方法については、ぜひVisual Tech Artさんの動画をご覧ください。

Slide 16_9 - 9OLN.png
アウトラインとピクセルのサイズを合わせて調整する必要があります

Material Graph & HLSL

CODE_OLN.png

FilterSize = max(FilterSize * View.ResolutionFractionAndInv.x, 1.0f);

float2 DepthUV = GetDefaultSceneTextureUV(Parameters, 1);
float2 NormalUV = GetDefaultSceneTextureUV(Parameters, 8);

float2 SceneUV = GetDefaultSceneTextureUV(Parameters, 14);
float3 Scene = SceneTextureLookup(SceneUV, 14, false);
// Reapply Pre-Exposure Scale
Scene *= View.OneOverPreExposure;

// Decreasing filter size (line width) based on distance
float MinDistance = MaxDistance;
for (int j = -FilterSize; j <= FilterSize; j += FilterSize)
{
    for (int i = -FilterSize; i <= FilterSize; i += FilterSize)
    {
        float3 DepthSample = min(MaxDistance, SceneTextureLookup(ClampSceneTextureUV(
            DepthUV + float2(i, j) * InvSize * View.ResolutionFractionAndInv.x, 1), 1, false).xyz);
        if (DepthSample.x <= MinDistance) MinDistance = DepthSample.x;
    }
}
float DistLowThreshold = 500.0f;
FilterSize = FilterSize * 
        (1.0f - smoothstep(DistLowThreshold, MaxDistance, MinDistance));
// No lines when filter size smaller than 1
if (FilterSize < 1.0f) return Scene;

// Set up Laplacian weight matrix
int KernelSize = floor(2 * FilterSize) + 1;
float KernelRadius = KernelSize / 2.0f;
float KernelRadiusSquared = KernelRadius * KernelRadius;
int CenterWeight = 0;

float DepthMagnitude = 0.0f;
float3 NormalMagnitude = float3(0.0f, 0.0f, 0.0f);

for (int j = -FilterSize; j <= FilterSize; j++)
{
    for (int i = -FilterSize; i <= FilterSize; i++)
    {
        // Check if pixel is inside circular area
        if (float(i * i + j * j) > KernelRadiusSquared) continue;

        CenterWeight++;

        float DepthSample = SceneTextureLookup(ClampSceneTextureUV(
            DepthUV + float2(i, j) * InvSize * View.ResolutionFractionAndInv.x, 1), 1, false).x;
        DepthMagnitude += DepthSample * -1.0f;

        float3 NormalSample = SceneTextureLookup(ClampSceneTextureUV(
            NormalUV + float2(i, j) * InvSize * View.ResolutionFractionAndInv.x, 8), 8 ,false).xyz;
        NormalMagnitude += NormalSample * -1.0f;
    }
}

// Add back (CenterWeight + 1) for center pixel
float DepthSample = SceneTextureLookup(DepthUV, 1, false).x;
DepthMagnitude += DepthSample * CenterWeight;

float3 NormalSample = SceneTextureLookup(NormalUV, 8 ,false).xyz;
NormalMagnitude += NormalSample.xyz * CenterWeight;

CenterWeight -= 1;
float InvCenterWeight = 1.0f / float(CenterWeight);

NormalMagnitude *= InvCenterWeight;
DepthMagnitude *= InvCenterWeight;
// Filter Depth-only edges
DepthMagnitude *= smoothstep(0.001f, 0.01f, abs(NormalMagnitude));

// Calculate final edge strength
float NormalAdjust = dot(NormalMagnitude, Parameters.CameraVector);
// Scale Depth Threshold based on distance
float DepthThreshold = 10.0f * smoothstep(DistLowThreshold, 
                        0.5f * MaxDistance, MinDistance) + 0.1f;
float DepthAdjust = sign(DepthMagnitude) * max(abs(DepthMagnitude) - DepthThreshold, 0.0f);
float EdgeDetection = clamp(NormalAdjust + DepthAdjust, 0.0f, 1.0f);

// Apply edge to scene texture
EdgeDetection = max(EdgeDetection, 0.0f);
Scene *= pow(2, -EdgeDetection * EdgeStrength);

return Scene;

ここで一番大事なことは、アウトラインの描画方式です。色で元の画像を覆うのではなく、画像の色のEV(露出値)を下げることで、アウトラインを作り出します。これは後のカラーマッピング処理で、線の色を変化させるための基礎となります。

Slide 16_9 - 10OLN.png
低コントラストのエッジ

3.3 カラーマッピング

本作のカラーパレットでは、メインカラーの8色は「白」「ベージュ」「青」「紺」という4つのベースカラーと、それぞれの暗いバリエーションで構成されています。そのため、私が最初に考えたのは、ピクセルのBase Color(もしくはDiffuse Color)をこの4色に分類し、さらに明るさによって明暗のバリエーションを決める、というプランでした。しかし、よく考えてみると、このアプローチにはいくつかの問題点があることに気付きました。

まず、必要な情報の一部が存在していません。たとえば、空や水などは、一般のMeshとShading Modelが違うため、Base Color・Diffuse Colorに正しい色を読み込むことができません。

Slide 16_9 - 11CLM.png

加えて、仮にBase Color Bufferを使えるとしても、そもそも原作のビジュアルはこのようなルールに従って作られているわけではありません。分かりやすい例としては、一つの面の明るさが広い範囲で変化していることが挙げられます。明暗2色だけでなく、3色、場合によっては6色も使われていることもあります。つまり、この表は厳密な作画原則ではなく、色の分類やガイドラインとして捉えるのがより適切だと言えるでしょう。

Slide 16_9 - 12.png
©︎Hakababunko / SHUEISHA, SHUEISHA GAMES
Slide 16_9 - 13.png
©︎Hakababunko / SHUEISHA, SHUEISHA GAMES

原作画面を分析して発見した一般的なパターンを以下にまとめました。(あくまで個人の考察であり、公式情報ではありません。)

  • 明るい(昼間)シーンと暗い(夜)シーンで、パレットが2つに分かれています。
  • 明るいシーンでは暗バージョンの四色が基本的に使用しません。一方、暗いシーンでは紺‐明は使用しません。
  • シャドウ:明るいときは紺‐明のみで表現し、暗いときは青‐暗と紺‐暗の二段階になります。
  • ミッドトーン:暗いときは白系、ベージュ系、その他(青)の3種類に分かれて、それぞれ明・暗の2色があります。明るいシーンでは、白→ベージュ→青の順で明暗を表現します。
  • ハイライト:すべて白‐明です。まれにブルーム効果があります。

Slide 16_9 - 14OLN.png
明るいシーン ©︎Hakababunko / SHUEISHA, SHUEISHA GAMES
Slide 16_9 - 15OLN.png
暗いシーン ©︎Hakababunko / SHUEISHA, SHUEISHA GAMES

グラフにすると、この感じになります。

Slide 16_9 - 16OLN.png

UEで実装する際は、まずlog2(View.OneOverPreExposure)で画面全体のEVを計算し、明・暗どちらのパレットを適用するか判断します。その後、ピクセルのRGB値をHSV値に変換します。ピクセルの輝度(V値)から相対EVを算出し、H・S値によって白系(彩度が低い)、ベージュ系(色相が赤や黄色)、青系(その他)に分類します。最後に、それぞれの色系ごとにカラーパレットを用いてカラーマッピングを行う形となります。

Material Graph & HLSL

マテリアルのDisable Pre Exposure Scaleを有効にします。

CODE_CLM.png

static float3 ColorPalette[8] = {Color0, Color1, Color2, Color3,
                                Color4, Color5, Color6, Color7};
// EV Thresholds for color palette
// Dark Scene
static float DBlue[3] = {-5.0f, -2.0f, 0.5f};
static float DYellow[4] = {-5.0f, -3.0f, -1.0f, 2.0f};
static float DWhite[3] = {-5.0f, -3.0f, 0.5f};
// Bright Scene
static float LBlue[2] = {-1.5f, 0.0f};
static float LYellow[3] = {-4.0f, -1.5f, 2.0f};
static float LWhite[3] = {-4.0f, -3.0f, -0.5f};

// RGB to HSV conversion
// Source: https://www.chilliant.com/rgb2hsv.html
struct ColorConversion
{
    float3 HUEtoRGB(in float H)
    {
        float R = abs(H * 6 - 3) - 1;
        float G = 2 - abs(H * 6 - 2);
        float B = 2 - abs(H * 6 - 4);
        return saturate(float3(R,G,B));
    }

    float3 RGBtoHCV(in float3 RGB)
    {
        float Epsilon = 1e-10;
        // Based on work by Sam Hocevar and Emil Persson
        float4 P = (RGB.g < RGB.b) ? float4(RGB.bg, -1.0, 2.0/3.0) : float4(RGB.gb, 0.0, -1.0/3.0);
        float4 Q = (RGB.r < P.x) ? float4(P.xyw, RGB.r) : float4(RGB.r, P.yzx);
        float C = Q.x - min(Q.w, Q.y);
        float H = abs((Q.w - Q.y) / (6 * C + Epsilon) + Q.z);
        return float3(H, C, Q.x);
    }

    float3 RGBtoHSV(in float3 RGB)
    {
        float Epsilon = 1e-10;
        float3 HCV = RGBtoHCV(RGB);
        float S = HCV.y / (HCV.z + Epsilon);
        return float3(HCV.x, S, HCV.z);
    }

    float3 HSVtoRGB(in float3 HSV)
    {
        float3 RGB = HUEtoRGB(HSV.x);
        return ((RGB - 1) * HSV.y + 1) * HSV.z;
    }

} CC;

struct SColorMap
{
    float3 BlueD(float EV)
    {
        float3 Color; 

        if (EV < DBlue[0]) Color = ColorPalette[0];
        else if (EV < DBlue[1]) Color = ColorPalette[2];
        else if (EV < DBlue[2]) Color = ColorPalette[3];
        else Color = ColorPalette[7] * max(1.44f, pow(2, floor(EV)));

        return Color;
    }

    float3 YellowD(float EV)
    {
        float3 Color; 

        if (EV < DYellow[0]) Color = ColorPalette[0];
        else if (EV < DYellow[1]) Color = ColorPalette[2];
        else if (EV < DYellow[2]) Color = ColorPalette[4];
        else if (EV < DYellow[3]) Color = ColorPalette[5];
        else Color = ColorPalette[7] * max(1.44f, pow(2, floor(EV)));

        return Color;
    }

    float3 WhiteD(float EV)
    {
        float3 Color; 

        if (EV < DWhite[0]) Color = ColorPalette[0];
        else if (EV < DWhite[1]) Color = ColorPalette[2];
        else if (EV < DWhite[2]) Color = ColorPalette[6];
        else Color = ColorPalette[7] * max(1.44f, pow(2, floor(EV)));

        return Color;
    }

    float3 BlueL(float EV)
    {
        float3 Color; 

        if (EV < LBlue[0]) Color = ColorPalette[1];
        else if (EV < LBlue[1]) Color = ColorPalette[3];
        else Color = ColorPalette[7] * max(1.44f, pow(2, floor(EV)));

        return Color;
    }

    float3 YellowL(float EV)
    {
        float3 Color; 

        if (EV < LYellow[0]) Color = ColorPalette[1];
        else if (EV < LYellow[1]) Color = ColorPalette[3];
        else if (EV < LYellow[2]) Color = ColorPalette[5];
        else Color = ColorPalette[7] * max(1.44f, pow(2, floor(EV)));

        return Color;
    }

    float3 WhiteL(float EV)
    {
        float3 Color; 

        if (EV < LWhite[0]) Color = ColorPalette[1];
        else if (EV < LWhite[1]) Color = ColorPalette[3];
        else if (EV < LWhite[2]) Color = ColorPalette[5];
        else Color = ColorPalette[7] * max(1.44f, pow(2, floor(EV)));

        return Color;
    }
} CM;

float EV = log2(View.OneOverPreExposure);

float2 SceneUV = GetDefaultSceneTextureUV(Parameters, 14);
float3 Scene = SceneTextureLookup(SceneUV, 14, false).xyz;
Scene *= View.OneOverPreExposure;

float3 SceneHSV = CC.RGBtoHSV(Scene);
float Brightness = log2(SceneHSV.z);

float3 Result = float3(0.0f, 0.0f, 0.0f);
if (EV <= EVThreshold)
{
    if (SceneHSV.y < 0.2f)
    {
        Result = CM.WhiteD(Brightness - EV);
    }
    else
    {
        SceneHSV.y = 1.0f;

        if (SceneHSV.x <= 0.1667f)
        {
            Result = CM.YellowD(Brightness - EV);
        }
        else 
        {
            Result = CM.BlueD(Brightness - EV);
        }
    }
}
else
{
    if (SceneHSV.y < 0.2f)
    {
        Result = CM.WhiteL(Brightness - EV);
    }
    else
    {
        SceneHSV.y = 1.0f;

        if (SceneHSV.x <= 0.1667f)
        {
            Result = CM.YellowL(Brightness - EV);
        }
        else 
        {
            Result = CM.BlueL(Brightness - EV);
        }
    }
}

Result *= pow(2, Offset);

return Result;

色の表現について、いくつかの注意点やポイントを挙げます。

  • 色が変わるEVの境界値については、物理法則に基づいた計算ではないため、シーンによっては個別に調整が必要になる場合があります。
  • マッピングされた色はTonemapperを通してHDRからSDR色域へ変換する必要があるため、出力がカラーパレットと少し異なる場合があります。Post Process VolumeのColor Gradingセクションで補正できます。

Slide 16_9 - 18CLM.png

  • これもTonemapperに関連する問題ですが、白‐明(#FFFFFF)の色はそのままにすると、最終的な出力が白ではなく、ベージュに近い灰色になってしまいます。白の輝度を少し上げる(HDRカラー)ことで、ベージュとのコントラストが出やすくなります。同じように、元のハイライトのHDR輝度を維持すると、原作で時々見られるブルーム効果も再現できます。

Slide 16_9 - 19CLM.png
Slide 16_9 - 20CLM.png

3.4 エッジ保存平滑化

ここまで来ると、シェーダーの主要部分は完成していますが、まだ改善の余地があります。ドット絵の特徴のひとつといえば、色と色間の境界線は比較的に鮮明です。一方、3DCGの画面では、DiffuseやNormal Map、GIなどの影響により、明暗の変化が不規則なグラデーションになります。
もちろん、Mesh自体のシェーディングを変更するのも一つの解決策ですが、エッジ保存平滑化(Edge-Preserving Smoothing)というフィルターを活用することで、ポストプロセス段階でもより鮮明な色の変化を得ることができます。

エッジ保存平滑化は、画面が大きく変化する境界(エッジ)を維持しつつ、他の部分だけをぼかすフィルターです。これを用いることで、Mesh表面にあるノイズや色の変化を滑らかにすることができます。今回は、「Bilateral Filter」と「Kuwahara Filter」二種類のフィルターを実装し、その効果を比較しました。他にもっと高速なアルゴリズムも存在しますが、UEのCustom Nodeだけで実装するは現実的ではないと思うので、今回は見送りました。

Slide 16_9 - 21EPS.png

Bilateral Filterとは、普通のガウスぼかしの上で、Range Kernelという係数を加え、ピクセル間の色が近いほどウェイトが重くなるアルゴリズムです。表面のテクスチャを消すのに優れています。効率は少し低いですが、今回は小さいσ値を使用しているため、それほど影響はありません。

Kuwahara Filterは、画像を油彩画のような雰囲気に変える、少し芸術的なフィルターです。植物などに点在するハイライトを除去するのにとても有効です。

Slide 16_9 - 22HERO.png

Bilateral Filter

マテリアルのDisable Pre Exposure Scaleを有効にします。

CODE_BLT.png

struct GaussianBlur
{
    float Gaussian1D(float x, float sigma)
    {
        float PI = 3.14159265f;
        return exp(-(x * x) / (2.0f * sigma * sigma)) / (sqrt(2.0f * PI) * sigma);
    }
} GB;

float2 SceneUV = GetDefaultSceneTextureUV(Parameters, 14);
float3 SceneSample = SceneTextureLookup(SceneUV, 14, false).xyz;

int HalfKernelSize = ceil(2.57f * max(SigmaS, SigmaR) * View.ResolutionFractionAndInv.x);

float WeightSum = 0.0f;
float3 Result = float3(0.0f, 0.0f, 0.0f);
for (int j = -HalfKernelSize; j <= HalfKernelSize; j++)
{
    for (int i = -HalfKernelSize; i <= HalfKernelSize; i++)
    {
        float3 SceneOffsetSample = SceneTextureLookup(ClampSceneTextureUV(
            SceneUV + float2(i, j) * InvSize * View.ResolutionFractionAndInv.x, 14), 14, false).xyz;

        float GaussianSWeight = GB.Gaussian1D(length(float2(i, j)), SigmaS);
        float GaussianRWeight = GB.Gaussian1D(length(SceneOffsetSample - SceneSample), SigmaR);

        Result += SceneOffsetSample * GaussianSWeight * GaussianRWeight;
        WeightSum += GaussianSWeight * GaussianRWeight;
    }
}

Result /= WeightSum;

return Result;

Kuwahara Filter

マテリアルのDisable Pre Exposure Scaleを有効にします。

CODE_KWH.png

// Radius
int Radius = max(floor(FilterRadius * View.ResolutionFractionAndInv.x), 1.0f);

float2 SceneUV = GetDefaultSceneTextureUV(Parameters, 14);

float2 KernelOffset[4] = {
    float2(-Radius, -Radius), // kernel a
    float2(0.0f, -Radius), // kernel b
    float2(-Radius, 0.0f), // kernel c
    float2(0.0f, 0.0f), // kernel a
};

float SubKernelSize = (Radius + 1.0f) * (Radius + 1.0f);

float3 Mean = float3(0.0f, 0.0f, 0.0f);
float3 Variance = float3(0.0f, 0.0f, 0.0f);

float MinVariance = 1e10;
float3 Result = float3(0.0f, 0.0f, 0.0f);

for (int k = 0; k <= 4; k++)
{
    Mean = float3(0.0f, 0.0f, 0.0f);
    Variance = float3(0.0f, 0.0f, 0.0f);

    for (int j = 0; j <= Radius; j++)
    {
        for (int i = 0; i <= Radius; i++)
        {
            float3 OffsetSample = SceneTextureLookup(ClampSceneTextureUV(
                SceneUV + (float2(i, j) + KernelOffset[k]) * InvSize 
                * View.ResolutionFractionAndInv.x, 14), 14, false);

            Mean += OffsetSample;
            Variance += OffsetSample * OffsetSample;
        }
    }

    Mean /= SubKernelSize;
    Variance = Variance / SubKernelSize - Mean * Mean;

    float VarianceScalar = Variance.x + Variance.y + Variance.z;

    if (VarianceScalar < MinVariance)
    {
        MinVariance = VarianceScalar;
        Result = Mean;
    }
}

return Result;

3.5 まとめ

最終的に、ポストプロセスのセットアップはこのようになります。

ポストプロセスマテリアル Blendable Location
Bilateral/Kuwahara Scene Texture Before DOF
エッジ検出・アウトライン Scene Texture Before DOF
カラーマッピング Scene Texture Before DOF
ピクセル化 Scene Texture After Tonemapper

パフォーマンスについて厳密な計測は行っていませんが、この記事で使用したパラメータの場合、Epic GamesのHillside Sampleではおおよそ1.5~2ms、+10%程度のコストとなります。もちろん、解像度とKernel Sizeを上げると遅くなりますが、ドット絵のエフェクトは特に高いレンダリング解像度を要らないので、納得できる結果ではないかと思います。

4 おわりに

『都市伝説解体センター』風のポストプロセスシェーダーの作り方をまとめました。ドット絵やモノクロームなど、そういった「2Dならでは」のスタイルを、あえて3Dで再現しようとするのは、とても楽しく、良いチャレンジだと思います。

3
4
0

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
3
4

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?