ピクセルシェーダを使ってPhotoshop風のベベル、光彩エフェクトを作る


こちらは、Siv3D Advent Calendar 2015 の17日目の記事です。

ゲーム制作などでPhotoshopにお世話になっているという方も多いと思いますが、Photoshopにはレイヤースタイルという画像にいろいろなエフェクトを付けられる機能があります。

このエフェクトをプログラムの中で扱えたら面白いと思ったので一部分だけ実装してみました。

作ったもの

べベル

べベルは、各ピクセルから最も近い透明ピクセルまでの距離を計算した後、その距離に応じて高さを設定し、高さとマウスの位置からライティングを計算しました。あと何か石っぽい質感になっていますが、これは別にそうしたわけではなくて透明ピクセルまでの距離計算の精度が悪いだけです。

光彩(内側)

べベルとほとんど同じです。透明ピクセルからの距離に応じて色をそのまま加算しました。

組み合わせるとこんな感じ

ちなみに自分のノートPCだと遅すぎてほとんど動かなかったので、ゲームに組み込むにはまだいろいろ改善が必要そうです。

実装

各ピクセルから透明ピクセルまでの距離が分かれば9割完成したようなものなので、この計算が一番大事です。

最も簡単な方法は各ピクセルから1つずつ隣接ピクセルを見ていく方法ですが、これだと多分遅いので今回ミップマップを使った実装を試してみました。

大きいミップレベルの1ピクセルの色は、小さいミップレベルのそれぞれのピクセルの色の平均になっているので、大きいミップレベルのアルファ値を調べればその範囲内に半透明ピクセルが存在するかどうかが判定できます。(多分)

したがって、それぞれのピクセルにおいて最大ミップマップレベルから1つずつ下げながら、近いピクセルのアルファ値が1かどうかを見ていけば、効率よく最も近い半透明ピクセルにたどり着けるのではないかと思いました。

Main.cpp
# include <Siv3D.hpp>

class EffectTexture
{
public:

    struct CB
    {
        Float2 lightDir;
        float lightAltitude;
        float intensity;
    };

    EffectTexture(const Image& image)
        :m_texture(image, TextureDesc::Mipped)
        , m_shader(L"EffectTexture.hlsl")
    {
        m_cb->lightAltitude = 200.0;
        m_cb->intensity = 0.0;
    }

    EffectTexture(const FilePath& filepath)
        : EffectTexture(Image(filepath))
    {}

    EffectTexture()
        : EffectTexture(Image())
    {}

    void update()
    {
        const float speed = Input::KeyShift.pressed ? 0.5 : 0.03;
        if (Input::KeyZ.pressed)
        {
            m_cb->lightAltitude += speed;
        }
        if (Input::KeyX.pressed)
        {
            m_cb->lightAltitude -= speed;
        }

        if (Input::KeyUp.pressed)
        {
            m_cb->intensity += speed;
        }
        if (Input::KeyDown.pressed)
        {
            m_cb->intensity -= speed;
        }

        m_cb->intensity = Clamp(m_cb->intensity, 0.0f, 1.0f);
    }

    void draw(const Vec2& pos = { 0, 0 })
    {
        Graphics2D::BeginShader(m_shader);
        m_cb->lightDir = Mouse::Pos();
        Graphics2D::SetConstant(ShaderStage::Pixel, 1, m_cb);

        m_texture.draw(pos);

        Graphics2D::EndShader();
    }

    void set(const Image& image)
    {
        m_texture = Texture(image, TextureDesc::Mipped);
    }

private:

    Texture m_texture;
    PixelShader m_shader;
    ConstantBuffer<CB> m_cb;
};

void Main()
{
    Graphics::SetBackground(Palette::Black);

    EffectTexture texture(L"Example/siv3D-kun.png");

    /*Font font(128, Typeface::Black);
    Image image(640, 480, Alpha(0));
    font.overwrite(image, L"Siv3D", 50, 50, Palette::Orange);
    texture.set(image);*/

    while (System::Update())
    {
        if (Input::KeyZ.clicked)
        {
            ScreenCapture::BeginGIF();
        }
        if (Input::KeyX.clicked)
        {
            ScreenCapture::EndGIF();
        }

        texture.update();
        texture.draw();

        Circle(Mouse::Pos(), 5).draw();
    }
}
EffectTexture.hlsl
Texture2D texture0 : register( t0 );
SamplerState sampler0 : register( s0 );

struct VS_OUTPUT
{
    float4 position : SV_POSITION;
    float4 color : COLOR0;
    float2 tex : TEXCOORD0;
};

cbuffer CB : register(b1)
{
    float2 lightDir;
    float lightAltitude;
    float intensity;
};

#define BUFFER_SIZE 5

//http://holger.dammertz.org/stuff/notes_HammersleyOnHemisphere.html#sec-SourceCode
float radicalInverse_VdC(uint bits)
{
    bits = (bits << 16u) | (bits >> 16u);
    bits = ((bits & 0x55555555u) << 1u) | ((bits & 0xAAAAAAAAu) >> 1u);
    bits = ((bits & 0x33333333u) << 2u) | ((bits & 0xCCCCCCCCu) >> 2u);
    bits = ((bits & 0x0F0F0F0Fu) << 4u) | ((bits & 0xF0F0F0F0u) >> 4u);
    bits = ((bits & 0x00FF00FFu) << 8u) | ((bits & 0xFF00FF00u) >> 8u);
    return float(bits) * 2.3283064365386963e-10; // / 0x100000000
}

float2 hammersley2d(uint i, uint N)
{
    return float2((float(i) + 0.5) / float(N), radicalInverse_VdC(i));
}

void sortNearBuffer(inout float2 nears[BUFFER_SIZE], in float2 pos)
{
    for (int i = 0; i < BUFFER_SIZE - 1; ++i)
    {
        for (int j = BUFFER_SIZE - 1; j > i; --j)
        {
            const float2 previous = nears[j - 1];
            const float2 current = nears[j];
            const float dp2 = dot(previous - pos, previous - pos);
            const float dc2 = dot(current - pos, current - pos);
            if (dc2 < dp2)
            {
                nears[j] = previous;
                nears[j - 1] = current;
            }
        }
    }
}

void tryInsert(inout float2 nears[BUFFER_SIZE], in float2 pos, in float2 newElement)
{
    int left = 0;
    int right = BUFFER_SIZE;
    int mid;

    const float lengthSq = dot(newElement - pos, newElement - pos);

    while (left <= right)
    {
        mid = ((left + right) >> 1);
        const float midLengthSq = dot(nears[mid] - pos, nears[mid] - pos);
        if (midLengthSq < lengthSq)
        {
            left = mid + 1;
        }
        else
        {
            right = mid - 1;
        }
    }

    for (int i = BUFFER_SIZE - 1; mid + 1 <= i; --i)
    {
        nears[i] = nears[i - 1];
    }
    if (mid <= BUFFER_SIZE - 1)
    {
        nears[mid] = newElement;
    }
}

float4 PS(in VS_OUTPUT input) : SV_Target
{
    const float2 uv = input.tex;

    float2 size_;
    float textureNumOfLevels;
    texture0.GetDimensions(0, size_.x, size_.y, textureNumOfLevels);

    float4 srcColor = texture0.SampleLevel(sampler0, uv, 0);

    float2 maxLevelSize;
    texture0.GetDimensions(textureNumOfLevels, maxLevelSize.x, maxLevelSize.y, textureNumOfLevels);
    float4 maxLevelColor = texture0.Load(int3(int2(maxLevelSize*uv), textureNumOfLevels - 1));
    if (maxLevelColor.a == 1)
    {
        return float4(0, 0, 0, 1);
    }

    int currentMip = textureNumOfLevels - 2;

    const int numOfSumples = 10;

    float2 nullUV = float2(-10, -10);
    float2 nearTransperenntPixels[BUFFER_SIZE];
    nearTransperenntPixels[0] = uv;
    for (int j = 1; j < BUFFER_SIZE; ++j)
    {
        nearTransperenntPixels[j] = nullUV;
    }

    float2 posUV[4];
    int indices[4] = { 0, 1, 2, 3 };
    int iterations = 0;

    const float scl = 2.5;
    int d = -3;
    for (; 0 <= currentMip; --currentMip)
    {
        int2 currentSize;
        texture0.GetDimensions(currentMip, currentSize.x, currentSize.y, textureNumOfLevels);

        float2 uvPerPixel = float2(1.0, 1.0) / currentSize;

        for (int i = 0; i < BUFFER_SIZE; ++i)
        {
            if (nearTransperenntPixels[i].x == nullUV.x && nearTransperenntPixels[i].y == nullUV.y)
            {
                continue;
            }

            int2 pixelPos = (nearTransperenntPixels[i] + uvPerPixel*0.5) / uvPerPixel;
            float2 uvCenter = float2(pixelPos) / (currentSize);

            const float alphaCeenter = texture0.SampleLevel(sampler0, uvCenter, currentMip).a;
            if (alphaCeenter == 1.0)
            {
                nearTransperenntPixels[i] = nullUV;
                sortNearBuffer(nearTransperenntPixels, uv);
            }

            for (int j = 0; j < numOfSumples; ++j)
            {
                const float2 dx = (hammersley2d(j, numOfSumples) - float2(0.5, 0.5))*2.0;
                const float2 jitteredUV = uvCenter + dx*uvPerPixel*scl;
                if (texture0.SampleLevel(sampler0, jitteredUV, currentMip + d).a != 1.0)
                {
                    tryInsert(nearTransperenntPixels, uv, jitteredUV);
                }
            }
        }
    }

    sortNearBuffer(nearTransperenntPixels, uv);
    //失敗した時の動作
    if (nearTransperenntPixels[0].x == nullUV.x && nearTransperenntPixels[0].y == nullUV.y)
    {
        //return float4(1, 0, 1, 1);
        nearTransperenntPixels[0] = uv;
    }

    //透明ピクセルへの相対ベクトル
    const float2 rel = nearTransperenntPixels[0] - uv;

    //べベルの高さ
    float height = min(dot(rel, rel)*1000.0, 1.0)*500.0;

    //光彩の強さ
    float light = 1.0 - min(dot(rel, rel)*10000.0 * (1.0 - intensity), 1.0);

    float x = input.position.x;
    float y = input.position.y;

    const float3 tangent = normalize(ddx(float3(x, height, y)));
    const float3 binormal = normalize(ddy(float3(x, height, y)));

    const float3 normal = normalize(cross(tangent, binormal));

    float2 pixelToLight = lightDir - input.position.xy;
    const float3 lightDir = normalize(-float3(pixelToLight.x, lightAltitude, pixelToLight.y));
    const float3 eyeDir = float3(0, -1, 0);
    const float3 halfDir = normalize((lightDir + eyeDir)*0.5);
    const float diffuse = dot(normal, lightDir);

    const float df = 0.5;
    const float sf = 0.5;
    const float shinness = 6.0;

    const float diffuseSpecular = df*dot(normal, lightDir) + sf*(pow(dot(normal, halfDir), shinness));

    const float3 ambient = float3(1, 1, 1)*0.25;
    const float3 lightColor = float3(0, 0.5, 1)*light*intensity;

    return float4(saturate(lightColor + ambient + srcColor.rgb*diffuseSpecular), srcColor.a);
}

反省

・解像度が2の累乗でないテクスチャだとミップマップの解像度が倍々に増えていく構造が崩れるので扱いづらい。
・疑似乱数を使ってある程度の精度が確保できればいいかと思ったが全然精度が上がらなかったので、何か勘違いしてるかバグがあるかも。
・あと遅い。numOfSumplesとBUFFER_SIZEを下げれば一応速くはなるけど精度がさらに犠牲になる。


明日は@prince_0203さんの記事です。よろしくお願いします。