11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ポケポケのホログラム表現に挑戦③

Last updated at Posted at 2026-01-12

この記事は前回の記事
「ポケポケのホログラム表現に挑戦②」の続編です
初めての方は①~②を読んでからこの記事を読んでください

前回はセルラーノイズを応用し、マーブル模様を表示するところまでやっていました。
今回は以下映像のような表現ができるところまでやりたいと思います。

しかしポケポケをインストールして何度もガチャってますが、マーブル模様の「マッシブーン」が全然出なくて、動き具合が分からず、マッシブーン持ってる人からすると「全然違うじゃん!」ってなるかもしれないので、そういう方は細かくどこがどう違うのかご指摘ください。
あと、この記事を読んで自分なりにブラッシュアップしたものを見せていただけると嬉しいです。

マーブル模様を動かしてみる

マーブル模様自体は前回の記事でやったので、マーブル模様のためのコードはさらっと書いておきます(内容の解説はしません)

/// セル内のランダムな位置を取得
/// @param {float2} cell セル座標(サーフェスを格子状に分割した時のそれぞれのセル座標)
float3 GetCellPosition(float2 cell)
{
    float2 randomOffset = GenerateRandomFloat22(cell);
    float sgn = GenerateRandomFloat(cell);
    float affectValue = sign(sgn*2-1.0);
    return float3(cell + randomOffset * 0.5, affectValue);
}

これで、各セルにおけるランダムな場所(UV値)を得ます。これを利用してマーブル模様のための値を計算します

///マーブル模様値取得(UVのu値が返る)
///@param {float2} uv テクスチャ座標(サーフェス上のUV座標)
///@param {float} scale マーブルのスケール(大きいほど細かい)
float GetMarbleValue(float2 uv,float scale)
{
    uv *= scale;
    float2 p_i = floor(uv);
    float2 p_f = frac(uv);
    float totalAffect = 0.0;
    float totalWeight = 0.0;
    for (int y = 0; y <= (int)scale; y++)
    {
        for (int x = 0; x <= (int)scale; x++)
        {
            float2 offset = float2(x, y);
            float2 cell = offset;
            float3 pointData = GetCellPosition(cell);
            float affect = pointData.z; // +1 or -1
            float2 pointPos = pointData.xy;
            pointPos += offset;
            float dist = distance(uv, pointPos);
            float weight = 1.0 / (dist * dist + 0.01);//0除算にならないように

            totalAffect += affect * weight;
            totalWeight += weight;
        }
    }
    float value = totalAffect / (totalWeight + 0.001);//0除算にならないように
    value = value * 0.5 + 0.5; // 0~1に正規化(必要に応じ調整)
    return value;
}

これで、ランダムに配置した点に対する距離で値を取得できます。これを用いてマーブル模様テクスチャを適用するんでしたね

float3 marbleCol = marble.Sample(smp, float2(mv,0)).rgb;

さて、今回はこのランダムに配置した点に対して「PD」と呼ばれるコントロールを行うという事を考えます

「PD制御」を実装してみる

PD制御って何よ!?

image.png
最初、Position Dynamicsかと思ったけど、CGでよく聞くアレはPDB(Position-Based-Dynamics)だった。
仕方ないので検索するとPID制御(Proportional-Integral-Derivative Controller)というのが出てきた。

もうわけわかんない

そしてバネと粘性!?

所謂ショックアブソーバー的な機構なのかな。MTB自転車やバイクについてるサスペンションの衝撃を和らげるアレですね。
バネ単品だとぴょんぴょんし続けて収束しない(収束が遅い)のですが、ここにダンパー(粘性)というオイルやガスで動きを緩やかにする機構を加えます。
ダンパーは速く移動しようとする力にはより強く抵抗し、移動がゆっくりの場合には弱く抵抗するものです。これにより大雑把にグラフにすると
image.png
こういう感じで収束していきます。式はテキトーです。

ただ、sinカーブで書いてはいますが、あまり「戻り」が目立つとぴょんぴょんして気持ち悪いので、戻りはほぼなしとして考えましょう。
んーまぁなんか、完璧を求めないなら普通にエルミート補間によるイーズインイーズアウトでいい気もしますが、まぁ実装しながら考えましょう。

普通にこういう動きをするだけでいいならスピードを毎フレーム*0.1やれば、ギュっという感じで0に収束するため、それを利用しようかと思います。具体的には1.0→0.9→0.81→0.729…0.001みたいな感じでほぼ0に収束するとします。

シンプルイズベスト

C++側のキーボードでオイラー角eularを変更して、それに追従するlastEularをHLSL側に渡そうと思います。

GetHitKeyStateAll(keystate);
if(keystate[KEY_INPUT_LEFT]){
	eular.y -= 0.05f;
}
if(keystate[KEY_INPUT_RIGHT]){
	eular.y += 0.05f;
}
if(keystate[KEY_INPUT_UP]){
	eular.x += 0.05f;
}
if(keystate[KEY_INPUT_DOWN]){
	eular.x -= 0.05f;
}
eular.x = std::clamp(eular.x, -DX_PI_F / 6.0f, DX_PI_F / 6.0f);
eular.y = std::clamp(eular.y, -DX_PI_F / 6.0f, DX_PI_F / 6.0f);
		
float distance = (eular - lastEular).Length();
if (distance > 0.01f) {
	Vector2 dir = (eular - lastEular).Normalized();
	velocity = dir * (distance * 0.1f);
	lastEular += velocity;
}else{
	velocity = { 0.0f,0.0f };
	lastEular = eular;
}
pStateCBuffer->offset = lastEular;

このoffsetをHLSLに渡しますが、その前にGetMarbleValueを改造します。引数にこの移動した角度量を渡せるようにしておきます。

///マーブル模様値取得(UVのu値が返る)
///@param {float2} uv テクスチャ座標(サーフェス上のUV座標)
///@param {float} scale マーブルのスケール(大きいほど細かい)
///@param {float2} tiltDir 傾き方向ベクトル(正規化済み)
///@param {float2} pdOffset PositionDynamics的なオフセット
float GetMarbleValue(float2 uv,float scale,float2 tiltDir,float2 pdOffset)
{
    uv *= scale;
    uv += tiltDir * uv.yx * 0.25;
    float2 p_i = floor(uv);
    float2 p_f = frac(uv);
    float totalAffect = 0.0;
    float totalWeight = 0.0;
    for (int y = 0; y <= (int)scale; y++)
    {
        for (int x = 0; x <= (int)scale; x++)
        {
            float2 offset = float2(x, y);
            float2 cell = offset;

            float3 pointData = GetCellPosition(cell);
            float affect = pointData.z; // +1 or -1
            float2 pointPos = pointData.xy;
            if (affect > 0.0)
            {
                offset += pdOffset;
            }
            pointPos += offset;
            float dist = distance(uv, pointPos);
            float weight = 1.0 / (dist * dist + 0.01); // 逆二乗で減衰(滑らかに)

            totalAffect += affect * weight;
            totalWeight += weight;
        }
    }
    float value = totalAffect / (totalWeight + 0.001);
    value = value * 0.5 + 0.5; // 0~1に正規化(必要に応じ調整)

    return value;
}

あと、C++側からのベクトル変化量を渡される準備しておきます。

cbuffer CardState : register(b1)
{
    float time;//C++側から渡された経過時間
    float2 tiltDiff; // C++側から渡された傾き差分
    float padding;//使わない
};

あとは呼び出し部分を変更

//マーブル模様値取得    
float mv = GetMarbleValue(uv, 3.0, input.norm.xy,tiltDiff);

これでやってあげれば

こんな感じでぐんにょり動きます。

ミラーボール

次にミラーボール感を実装したいと思います。
image.png

ミラーボールのつぶつぶを作る

実際のポケポケはこのつぶつぶが、同心円タイル状にならんでるっぽいのですが、ひとまずはただ並べてみましょう
せっかくだから2DのSDF(Signed Distance Function)を使って作りましょう。
2D-SDFに関しての細かい所は
https://iquilezles.org/articles/distfunctions2d/
を見ておいて欲しいのですが、この中の一番簡単なやつCircleを並べる感じのを作ります
たとえば円はこういう関数を作っておいて

float sdfCircle( vec2 p, float r )
{
    return length(p) - r;
}
(中略)
float val = SDFCircle((input.uv - float2(0.5, 0.5)) * float2(aspect,1.0), 0.3);
return float4(val.xxx, 1.0);

とすると
image.png

単純に境界付近で0になっているのが分かりますね。SDFは距離関数と言って、特定の点からの距離を測る事で形を作るものです。

これを並べる部分に関してですが
https://iquilezles.org/articles/sdfrepetition/
も読んでおいて欲しいのですが、もうちょっと分かりやすくこういうのを作ります。

/// 繰り返し円のSDF
float SDFRepeatCircle(float2 p, float repeat)
{
    return SDFCircle(fmod(p * repeat, 1) - float2(0.5, 0.5), 0.25);
}
(中略)
float val = SDFRepeatCircle(input.uv * float2(aspect,1.0), 4.0);

image.png
はい、fmodによって繰り返しになっているのがわかりますね。これを利用して円を作ります。
やる事は簡単でマイナスになっている部分を塗りつぶせばいいのです。step関数を使いましょう

float val = SDFRepeatCircle(input.uv * float2(aspect,1.0), 7.0);
val = step(val, 0.0);

image.png
エッジが若干汚いので、smmoothstepにしましょうか

float val = SDFRepeatCircle(input.uv * float2(aspect,1.0), 7.0);
val = 1.0-smoothstep(0, 0.1, val);
return float4(val.xxx, 1.0);

image.png
もうちょっと粒粒を小さくして、もとのカードに加算合成すると
image.png
ただしこれはただ敷き詰めただけなので、ミラーボールではないですね。
ここからがちょっと難しいですが頑張りましょう

傾き方向でインデックステクスチャを取得

傾き方向に関しては言ってもカードだから一様で、普通に法線情報を持ってくればいいです。
問題は「傾き反射マップ」とかいうものですが…なんだこれは…?
恐らくは、傾きから得られたUV座標によって、渦巻き状の特定の場所を決定(これが現在のインデックスになる)
次に「ミラーボールテクスチャ」をランダムに生成し、このインデックスと一致している部分を光らせる…的なところでしょうか?

「傾き反射マップ」の生成

まずは「傾き反射マップ」の生成だけどこれはなんてことはないですね。ただの螺旋ですし。
image.png
まずは角度をインデックスとして取り出してみましょう

const float pi = 3.1415926535;
float len = length(normXY);
float angle = atan2(normXY.x, normXY.y);
float index = ((angle + pi) / (2.0 * pi)); //0~1に正規化
return index;

こうなりますね
image.png
では次に、この渦を4つにしてみましょう。やり方はのこぎり波と同じでfmodを使えばいいですね。相手が角度というだけです。

index = fmod(index, 1.0 / 4.0) * 4.0;

image.png
あとは半径をこれに加算してあげればいいわけです。加算すると1.0を超えてしまいますのでfracで小数部分だけ受け取りましょう

///ミラーボールシェーダを作る
///カードの向きで反射インデックスを変化させる
float GetReflectionIndex(float2 normXY){
    const float pi = 3.1415926535;
    float len = length(normXY);
    float angle = atan2(normXY.x, normXY.y);
    float index = ((angle + pi) / (2.0 * pi)); //0~1に正規化
    index = fmod(index, 1.0 / 4.0) * 4.0;
    index += len*4.0 ; //中心から外れるほどインデックスを変化させる
    return frac(index);
}

image.png
多少ジャギってますがこれが表示されるわけでもないので、あまり(゚ε゚)キニシナイ!!

反射インデックスマップ

さぁ最後です。反射インデックスマップを作りましょう。
image.png
これは同心円状にならんでますが、ちょっとこれに対応するのは次の機会にして今回はグリッド状に作ればいいとします。
で、どういうマップかというと前の項目で0.0~1.0のインデックスを返すものを作りましたよね?このインデックスと値が一致したら明るくなるようなマップを作りたいわけです。
これは、それほど難しくなくて、普通にグリッドごとに0.0~1.0の乱数値を設定すればいいだけです。グリッドごとなので、グリッドの左上あたりを乱数シードにすればよく

/// グリッドセルごとのランダム値取得
/// @param {float2} uv テクスチャ座標(0.0 ~ 1.0)
/// @param {float} gridDiv グリッドの分割数(大きいほど細かい)
/// @param {float2} aspect アスペクト比補正値
/// @return {float} ランダムな float 値
/// @note ブロックノイズとほぼ同じですね
float GetGridCellRandomValue(float2 uv, float gridDiv,float2 aspect)
{
    float2 gridId = floor(uv * gridDiv*aspect);
    return GenerateRandomFloat(gridId);
}
(中略)
float rnd = GetGridCellRandomValue(input.uv, 21.0, float2(aspect, 1.0));
return float4(rnd.xxx, 1.0);

image.png

あとはここまでやってきた事をまとめればいいわけです

やってきたことをまとめる

まず、傾きからインデックスを取得し、それをもとに特定の場所を明るくしてみましょう。

float rnd = GetGridCellRandomValue(input.uv, 21.0, float2(aspect, 1.0));
float rindex = GetReflectionIndex(normalize(input.norm.xy)*2.0-1.0);
float v = distance(rnd, rindex)<0.025 ? 1.0 : 0.0;//完全一致することはないため近い値でOKとする
return float4(v, v, v, 1.0);

これでやるとこんな感じになります

丸くないのが気に入らないですね。これも簡単で前に作った円の繰り返しのSDFでマスクしてやればいいだけです。

float rnd = GetGridCellRandomValue(input.uv, 21.0, float2(aspect, 1.0));
float rindex = GetReflectionIndex(normalize(input.norm.xy)*2.0-1.0);
float v = distance(rnd, rindex)<0.025 ? 1.0 : 0.0;
float val = SDFRepeatCircle(input.uv * float2(aspect, 1.0), 21.0);
val = (1.0 - smoothstep(0, 0.1, val)) * v;
return float4(val.xxx, 1.0);

あとはこれをちょっと薄めて完成品に加算してやればいいだけです

調整後のシェーダを書くと

const float div = 28.0;//分割数
float rnd = GetGridCellRandomValue(input.uv, div, float2(aspect, 1.0));
float rindex = GetReflectionIndex(normalize(input.norm.xy)*2.0-1.0);
float v = step(distance(rnd, rindex),0.25);
float val = SDFRepeatCircle(input.uv * float2(aspect, 1.0), div);
val = (1.0 - smoothstep(0, 0.1, val)) * v;
val *= 0.45;//明るくなり過ぎないように乗算
val += 0.1;//暗くなり過ぎないように下駄はかす
(中略)
//ほかで計算済みのカードのカラーに加算
return cardCol+val;

結果

ここまで作った段階で、やっとポケポケでミラーボールのカードが出現したんだけど、疲れたからここまでです。

あとは、つぶつぶの並べ方をどうにかしたいなー(同心円状)とか、つぶつぶを淡色じゃなくて傾きによって色変えたいなーとか、その他色々あるけど、疲れたし時間もないしでここまでです。

こんな事で連休使い果たしていいのだろうか…

11
0
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
11
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?