カイワレスタイル

ゲーム、アニメ、プログラム、興味のあることをツラツラと。

三日坊主を止めるな2のVJシステム(Boze Jockey System)についての仕組み解説

はじめに
 
去る2020年 12月28日、VRChat内で「三日坊主を止めるな2」という謎のイベントが開催されました。
イベントの様子は、おきゅたんbotさんに配信して頂きました。
アーカイブはこちらから↓
 
自分は、このイベントの音声映像配信とVJ演出周りを担当しました。
今回は、これらの仕組みの解説をしていきます。
一旦、上のアーカイブでライブの映像を見てからのほうが、理解しやすいと思います。
続きを読む

Boze入門

この記事はBoze Advent Calendar 2020の2日目の記事です(遅くなって申し訳ありません)

https://qiita.com/advent-calendar/2020/boze2020

前日は、オリジナルの三日坊主さんの「Bozeって何?」でした。
とても哲学的で良い記事でしたね。
前日の記事で"Boze"が何かわかったと思いますので、今回はより実践的な"Boze"について書いてみたいと思います。

Boze入門

私の"Boze"は、レイマーチングを用いた"Boze"です。
早速ですが、最低限のレイマーチングによる"Boze"のサンプルです。 neort.io

最低限ですが、結構な長さがあるので、要点部分だけ抜粋して解説します。

レイマーチングとは

レイマーチングとは、その名の通り、レイ(光)をマーチング(行進)させてレンダリングする手法です。

f:id:kaiware007:20201203231434p:plain
↑親の顔より見たレイマーチングの図

レイは、各図形の距離関数からレイとの距離を計算し、その距離の長さだけレイを進めて、再度距離を求めて…というのを繰り返していく感じです。
距離がものすごく小さくなったら図形に接触していると判断します。
ソース上では、traceRayがそれにあたります。

// 距離関数をマッピングする処理(空間の中心にBozeがあるだけ)
surface map(vec3 p)
{
    surface result = SURF_NOHIT(1e5);
    
    float ms = sin(iTime) * 0.5 + 0.5;

    // boze
    surface boze = sdBoze(p, vec3(1), ms);
    
    result = opU(result, boze);
    
    return result;
}

// レイを進ませる処理
surface traceRay(in vec3 origin, in vec3 direction, float dist, out vec3 pos) {
    float t = 0.0;
    
    pos = origin;

    int count = 0;
    surface hit;
    float d;
    
    for (int i = 0; i < MAX_MARCH; i++) {
        hit = map(pos);
        d = hit.dist;
        
        if (d <= EPS || d >= MAX_DIST) {
            break;
        }

        t += d;
        pos = origin + direction * t;
        count++;        
    }

    hit.dist = t;
    hit.count = count;

    pos = origin + direction * t;
        
    if(hit.isHit)
    {
        // Lighting
        vec3 normal = norm(pos);

        vec3 lightDir = normalize(vec3(cos(iTime), 1, sin(iTime)));
        vec3 lightColor = vec3(1.5);
        
        float NoL = saturate(dot(normal, lightDir));
        
        vec3 ambientColor = vec3(0.1);
        
        hit.albedo.rgb *= NoL * lightColor +  ambientColor;
    }
    
    if(d <= EPS){
        hit.isHit = true;
        return hit;
    }else{
        
        hit.isHit = false;
        return hit;
    }
}

// レイを進ませて、図形と衝突してなかったら背景色にする処理
vec3 render(vec3 p, vec3 ray, vec2 uv)
{
    vec3 pos;
    surface mat = traceRay(p, ray, 0., pos);
    
    vec3 col = vec3(0,0,0);
    vec3 sky = vec3(0.3);
    
    col = mat.isHit ? mat.albedo.rgb : sky;
    
    return col;
    
}

レイが図形に接触したら、図形の表面の色を取得する必要がありますが、"Boze"の場合、肌、目、口とそれぞれ色が違います。
そのため、各図形から距離を取得するときに、一緒に色も返すように戻り値を構造体にしています。

// 距離関数からの情報を格納する構造体
struct surface {
    float dist; // 距離
    vec4 albedo;    // 色 
    int count;  // 進んだ回数
    bool isHit; // 衝突フラグ
};

Bozeの構成要素

"Boze"は、基本的な距離関数の組み合わせでできています。
下記に"Boze"で使っている基本的な距離関数一覧を紹介します。

sdCappedTorus:欠けたトーラス(リング)

f:id:kaiware007:20201201212032p:plain

float sdCappedTorus(in vec3 p, in vec2 sc, in float ra, in float rb)
{
  p.x = abs(p.x);
  float k = (sc.y*p.x>sc.x*p.y) ? dot(p.xy,sc) : length(p.xy);
  return sqrt( dot(p,p) + ra*ra - 2.0*ra*k ) - rb;
}

sdEllipsoid:楕円球

f:id:kaiware007:20201201212036p:plain

float sdEllipsoid( vec3 p, vec3 r )
{
    float k0 = length(p/r);
    float k1 = length(p/(r*r));
    return k0*(k0-1.0)/k1;
}

sdCapsule:カプセル

f:id:kaiware007:20201201212419p:plain

float sdCapsule(vec3 p, vec3 a, vec3 b, float r)
{
    vec3 pa = p - a, ba = b - a;
    float h = clamp(dot(pa, ba) / dot(ba, ba), 0.0, 1.0);
    return length(pa - ba*h) - r;
}

sdRoundBox:角丸立方体

f:id:kaiware007:20201201212025p:plain

float sdRoundBox(vec3 p, vec3 size, float r)
{
    return length(max(abs(p) - size * 0.5, 0.0)) - r;
}

sdRoundedCylinder:角丸円柱

f:id:kaiware007:20201201212028p:plain

float sdRoundedCylinder( vec3 p, float ra, float rb, float h )
{
  vec2 d = vec2( length(p.xz)-2.0*ra+rb, abs(p.y) - h );
  return min(max(d.x,d.y),0.0) + length(max(d,0.0)) - rb;
}

画像出典: distance functions by iq www.iquilezles.org

"Boze"の各パーツ

頭部

なめらかな凸型の頭部は、カプセルと角丸円柱を融合させて作っています。 上部がカプセルで、下部が角丸円柱です。

 // head
    float d = sdCapsule(p, vec3(0,0.05,0), vec3(0, 0.11, 0), 0.125);
    float d1 = sdRoundedCylinder(p + vec3(0,0.025,0), 0.095, 0.05, 0.0);

    d = smin(d, d1, 0.1);

2つの図形をSmooth minというなめらかに補完する関数を使って融合させています。

float smin( float d1, float d2, float k ) {
    float h = clamp( 0.5 + 0.5*(d2-d1)/k, 0.0, 1.0 );
    return mix( d2, d1, h ) - k*h*(1.0-h); 
}

www.iquilezles.org

耳は、欠けたトーラスを90度ほど傾けて作っています。

float sdEar(vec3 p)
{
    p = rotate(p, RAD90+0.25, vec3(0,0,1));    
    return sdCappedTorus(p + vec3(0.05, 0.175, 0), vec2(sin(0.7),cos(0.7)), 0.03, 0.01);
}

また、顔のパーツは口以外は左右対称なので、レイの座標をX座標を絶対値にすることで、空間が左右対称になって距離関数を削減しています。

    vec3 mxp = vec3(-abs(p.x), p.yz);
    
    // ear
    float d2 = sdEar(mxp);
    d = opU(d, d2);

口は楕円球を使っていますが、口角を若干上げるために楕円球の中心からX方向に離れるほど回転させて歪めています。

float sdMouse(vec3 p, float ms)
{
    vec3 q = opBendXY(p, 2.0);
    ms += 0.00001;
    return sdEllipsoid(q - vec3(0,0,0.2), vec3(0.035, 0.01 * ms,0.05 * ms));
}

vec3 opBendXY(vec3 p, float k)
{
    float c = cos(k*p.x);
    float s = sin(k*p.x);
    mat2  m = mat2(c,-s,s,c);
    return vec3(m*p.xy,p.z);
}

目と眉毛

目は見たまんまですがカプセル、眉毛は角丸をすごく細くして作っています。

float sdEyeBrow(vec3 p)
{
    const float x = 0.05;
    p = opBendXZ(p + vec3(0.02,0,-0.02), -6.5);
    return sdRoundBox(p + vec3(0.005, -0.14,-0.11), vec3(0.003,0.0025,0.05), 0.001);
}
    // eye
    float d4 = sdCapsule(mxp, vec3(-EYE_SPACE, 0.06, 0.13), vec3(-EYE_SPACE, 0.08, 0.125), 0.0175);
    surface eye = SURF_BLACK(d4);

    // eyebrows
    float d9 = sdEyeBrow(mxp);
    eye.dist = opU(eye.dist, d9);

パーツ単位で一番複雑なのが、頬のうねうねの線です。
元のデザインでは手書きでうねうねさせていた線ですが、レイマーチングでは3本のカプセルを歪めながら連結して再現しています。

float sdCheep(vec3 p)
{    
    const float x = 0.05;
    const float z = -0.175;
    const float r = 0.0045;
    const float rb1 = 100.;
    
    p = rotate(p, M_PI * -0.6 * (p.x - x), vec3(-0.2,0.8,0));
    
    float d = sdCapsule(opBendXY(p + vec3(x, -0.01, z), rb1), vec3(-0.005,0.0,0.0), vec3(0.005, 0., 0.001), r);
    float d1 = sdCapsule(opBendXY(p + vec3(x+0.01, -0.01, z), 200.0), vec3(-0.0026,0.0,0), vec3(0.0026, 0., 0), r);
    float d2 = sdCapsule(opBendXY(p + vec3(x+0.019, -0.015, z), -rb1), vec3(-0.01,0.0,-0.01), vec3(0.0045, 0., 0.0), r);
    
    return opU(opU(d, d1), d2);
}

パーツの合成

前述の顔のパーツを合成します。
距離関数での図形の合成は、以下のとおりです。

// 和
float opUnion( float d1, float d2 ) {  min(d1,d2); }

// 除
float opSubtraction( float d1, float d2 ) { return max(-d1,d2); }

// 交差(論理積)
float opIntersection( float d1, float d2 ) { return max(d1,d2); }

まず、顔と耳と頬をopUnionで合成します。 目、口、眉毛は、凹んでいるのでopSubtractionで削除します。
これで完成!

f:id:kaiware007:20201201233447p:plain

君だけの"Boze"を作ろう!

2020年上半期で作ったRaymarchingまとめ

正月

Cyber Fuji 2020

f:id:kaiware007:20200702191416p:plain

neort.io

VRChatの一部界隈でお正月の振り袖の模様をシェーダーで作るムーブメントがあったので便乗して作ってみた作品です。
みんなだいたい和風で作ってたので、ハマってたvaporwaveというかsynthwave的な雰囲気のものを作ってみました。 f:id:kaiware007:20200702215114p:plain

VRChatに持っていったバージョンは、日が登ったり沈んだりする動きも追加しています。
バーチャルとはいえ、衣服の模様がアニメーションするのはインパクトがあって反応を見るのが楽しいです。

Boze ノ ハジマリ

ある日、天啓を得て、VRChat界のNo1ゆるキャラ(?)、三日坊主さんを距離関数してみました。
これが後の"Boze"シリーズとしてそこそこ続くとは、このときは全然考えていませんでした…。

Distance Function : Mikka Bouzu

f:id:kaiware007:20200702191819p:plain

neort.io

基本の三日坊主さんの顔の距離関数です。 通称、Boze。
基本的な距離関数を組み合わせて作っています。

00 Meta Boze

f:id:kaiware007:20200702222936p:plain

neort.io

距離関数にすると何が便利かと言うと、モーフィングさせたり、ねじれさせたり、融合させたりと普段ポリゴンベースのリアルタイムレンダリングだと難しい処理もできるようになることです。
とはいえ、そもそもレイマーチング自体が重いので万能ではないですが…。
この頃は、まだ最適化が甘くてめちゃくちゃ重いです。
あまりに重すぎたので、Neortには動画にして投稿しています。
3月にネット上で開催されたShader1weekCompoというイベントに出展しました。
1週間という限られた時間の中でシェーダーを使った作品を作るというニッチなイベントでしたが、割と作りたいものがはっきりしていたのですんなりと完成できました。
まぁテーマは完全無視してましたが…。

www.shader1weekcompo.org

進撃のBoze

01 Meta Boze Again

f:id:kaiware007:20200702192013p:plain

neort.io

5月に開催された第2回のShader1weekCompo用の作品です。
テーマは「続く」ということで、前回のMetaBozeさんを続投させるということで無理矢理合わせました。
無限にBozeがうねうね伸びていくように見せれて満足です。

Everyday 1 Boze

第2回のShader1weekCompoが終わってから、もっとレンダリングの勉強やレイマーチングのテクニックを身につけたいなと思い立ち、色々Bozeを使って模索をはじめました。
ネタがポンポン思いついたので、毎日投稿できるかもしれないと思って #everyday1boze というタグをつけて投稿し始めました。
結局毎日は無理でしたが…!

02 Party Boze

f:id:kaiware007:20200702192345p:plain

neort.io

グリッドごとに違う動きや色をだせるか試してみた作品その1。
空間をmod関数などでグリッドで区切ってリピートさせてオブジェクトを増やす手法は、レイマーチングではよくやるのですが、グリッドごとに乱数を使ったりすると、形が崩れる問題が度々おきます。
原因は、グリッド内での距離の分だけレイを進ませた時に、グリッドの境界を突き抜けて次のグリッドに大幅に食い込んだりするためです。
このときは、単純にレイが進む距離を半分にしてはみ出しづらくしてごまかしています。

03 Darkmatter Boze

f:id:kaiware007:20200702192432p:plain

neort.io

グリッドごとに違う動きや色をだせるか試してみた作品その2。
モチーフは、子供の頃に図鑑か何かで見た「宇宙誕生以前の図」、磁性流体、Max CooperのOriginsのMVです。

www.youtube.com

04 Endless Boze Tunnel

f:id:kaiware007:20200702192513p:plain

neort.io

自分としては、ずっと見ていられるようなものを作りたいモチベーションがあります。
しかし、何故か無限にループするようなトンネル系のものを作ったことがなかったので作ってみました。
これのためだけに、Boze関数の口の中を深くしました。
実際は拡大してるだけなんですが、中に入っていくような感じに見えて面白かったです。

05 Drop Boze feat. Daruma-Otoshi

f:id:kaiware007:20200702192551p:plain

neort.io

空間のグリッド化をしつつ、破綻しないように頑張ろうとしたけどうまく行かなかった作品。
1つの顔をグリッドで分割して、グリッドごとの顔の位置をずらしたら、達磨落としみたいなものができそうと思って作りました。
結局このときは、グリッドではできなくて、5つの顔を段々になっているBoxとの論理積をとって残った部分をずらしている感じで表現しています。 1つのBoze関数でもそこそこ重いのを、5個も描画してるのでかなり重いです。閲覧注意!

06 Ghost Boze

f:id:kaiware007:20200702192703p:plain

neort.io

空間のグリッド化は、一旦諦めて、ボリュームレンダリングに挑戦してみた作品。
凹みさんのボリュームレンダリング記事がめちゃくちゃ参考になりました。
この世のUnityについての面白そうな技術は、だいたいkeijiro神か凹み神が既にやってたりします。 我々人類は、ありがたく神の恵みを享受しましょう。
通常のレイマーチングで床と天井を作ったあとに、Depthを保持して、ボリュームレンダリングをしているので、うまく床や天井にめり込むようになってます。

tips.hecomi.com

07 Cloud Boze

f:id:kaiware007:20200702192737p:plain

neort.io

ボリュームレンダリング作品その2。
FBMの値に距離関数の距離を引くことで、雲が形作られてます。
図形との距離が遠いと濃度が0になり、中にはいると距離が負になるので濃度が濃くなる寸法です。
シンプルだけど面白いですね~。

08 Grid Dissolve Boze

f:id:kaiware007:20200702192812p:plain

neort.io

やっと空間のグリッド分割がうまくいった作品。
レイを進ませる時に、必ずグリッドの境界で一旦止まるようにしています。
距離関数のグリッドの立方体の表面との距離と、距離関数の戻り値の距離を比較して、短い方を採用しています。
グリッド境界で必ず止まるので、普通にレイマーチングするよりは試行回数が増えてしまいます。

09 Round Slice Boze

f:id:kaiware007:20200702192904p:plain

neort.io

空間分割作品その2。
グリッド自体は立方体ですが、ずらし方を段ごとに同じにしてるので、輪切りされてるように見せてます。
断面にノイズによるサイケな模様をつけたり、背景をピンクにしたりして怪しい雰囲気を出してみました。

10 BRDF Boze

f:id:kaiware007:20200702193003p:plain

neort.io

本格的にBRDFについて勉強しはじめた作品。
リアルタイム鏡面反射たのし~!
まだ反射処理を1回分しか行っていないので、床に写り込んでいるBozeの表面が反射していないです。

11 U.F.B(Unidentified Flying Boze)

f:id:kaiware007:20200702193054p:plain

neort.io

寝る前に、「カプセルの距離関数は、内部的に直線上の点の位置を計算できてるから、その点上に別の距離関数を置いて距離を返したら、引き伸ばされた図形ができないか?」と思いついて作ってみた作品。 割と思ったとおりの感じの絵になってよかったです。

12 Color Blend Boze

f:id:kaiware007:20200702193147p:plain

neort.io

色や質感をなめらかに合成してみたくて作った作品。
今まで、質感の設定は、衝突した距離関数に応じてインデックスを決めて、最後にまとめて色を割り振るみたいなことをしていましたが、それだとsmoothminなどで複数の物体がブレンドされていても境界付近がぱっきりわかれてしまっていました。
今回は、距離関数に色などの質感設定も返すようにしてsmoothminでもなめらかにブレンドされるようにしてみました。
結果、switch~case文が減ってシンプルになった面もありますが、距離関数を書くのが若干めんどくさくなりました…。
質感や色味は割と気に入っています。

13 Boze Factory

f:id:kaiware007:20200702193250p:plain

neort.io

複数回の鏡面反射とエミッション(発光物)をやってみた作品。
エミッションは単純に足せばいいだけなので簡単ですね。
ただ、レイマーチングでブルーム処理は難しいのでまだできていません(2パス使えれば簡単にできそう)
複数回の鏡面反射は、反射物に写った自身の表面にも、更に反射した絵が写ってないと不自然だったのでやってみたって感じです。
反射の計算が1回だけだと、反射物の表面に写った物体の反射計算が行われません。
単純に試行回数増やすだけなので重いけど、その分情報量もマシマシになるので楽しかったです。

14 Crystal Boze

f:id:kaiware007:20200702193402p:plain

neort.io

レイマーチングでReflect(反射)をやったので、Reflact(屈折)もやってみたいな~と思って作った作品。
球体は透過してるように見えるけど、実際はほぼ全反射する物体です。背景のせいでわかりにくいですね。

レイマーチングの屈折表現はがむさんのシェーダーを参考に実装してみました。

www.shadertoy.com

物体内部にレイを進ませるために距離を絶対値にしたり、法線と進行方向の内積を取ることで内外判定したり、と色々勉強になりました。
はじめ、屈折の法則を知らなかったので、なんで屈折率の逆数を使うのかわからなかったんですが、高校物理のサイトとか見て外に出る時は屈折率が入れ替わるということを知って理解できました。

レイは形状に応じて屈折するので、違和感が無くなるようにBozeの眉毛の深さなど細かいところを調整しました。

15 Non-uniform Scaling Boze

f:id:kaiware007:20200702235545p:plain

neort.io

大きさの指定がXYZ軸ごとに指定できるようにしたBozeのテスト。
座標を大きさで割ったら、図形の大きさも変えれるのは知ってましたが、アーティファクトが出たりしちゃってうまく行かなかったんですが、最後にXYZ軸で最も小さいサイズをかけるとうまくいくことを知りました。
床の模様のせいもあるが「カー○ィボール」と言われました。

まとめ

正月のCyberFuji2020以外、全部Bozeやんけ!
無機質になりがちなレイマーチング作品ですが、愛嬌のある三日坊主さんの顔を使うことで、キャラクター性が出て楽しく見えてモチベーションが上がってます(いつも広い心で許していただいてありがとうございます)
作品作りを通して、自前のレイマーチング用のレンダリングエンジン(?)が出来つつあるので、TDFやRevisionなど、シェーダー系のイベントに作品を投稿してみたいな~という気持ちになりつつあります。
今年ももう半分過ぎてしまいましたが、これからもBozeを作り続けたいです。

また、現在、趣味でjavascript(というかWeb開発全般)とWebGLの勉強がてら、タイムライン操作できるShaderToyもどきを作っています。

f:id:kaiware007:20200702211919p:plain

自分が欲しいからという理由で作ってるので公開するかは未定です(完成するかも未定)
CSSわからん…なんも…

さいごに

twitterにもboze作品の動画を投稿してますが、ほぼ全て避雷さんのShaderRecというサイトでキャプチャしています(いつもお世話になっております) shader-rec.herokuapp.com

【VRChat】GPUパーティクル同士が線で結ばれるワールド「Simple Plexus」の解説

f:id:kaiware007:20191130135303p:plain

前置き

本記事は、VRChatアドベントカレンダー2019の2日目の記事です。
前日はほたてねこまじんさんの「自分の部屋をデジタル空間に持っていった話」でした。

adventar.org

今回は、VRChatで自作したワールドの簡単な紹介と、そのワールドで全体的に使用している自作GPUパーティクルの実装の解説を書きたいと思います。

ワールド概要

記事のトップの画像が今回作成した「Simple Plexus」ワールドのスクリーンショットです。
空間全体にGPUパーティクルを敷き詰め、パーティクル同士が近づくと線で結ばれる仕組みになってます。
いわゆるAfterEffectの「Plexus」プラグインをシェーダーで一部再現してみた感じです。

ワールドのリンクは下記にあるのでVRChatユーザーはぜひ来てください。 vrchat.com

ワールドの中央には、ローポリでツルピカな大仏が鎮座して周囲のパーティクルの光を反射しています。 f:id:kaiware007:20191130140306p:plain モデル自体は、Yahoo! JapanCreative Commonsライセンスで配布している大仏の3DデータをBlenderで頂点数を削減してカクカクにしたものを使用しています。

www.thingiverse.com

モデルのシェーダー自体はUnity標準のStandardShaderですが、ReflectionProbeで毎フレームCubeMapを生成してパーティクルの明滅を反映させてます。

大仏の右の方には、パーティクルの制御用のコントロールパネルがあります。

f:id:kaiware007:20191130142452p:plain

左から、

  • Wave
    パーティクルは、一定周期で大きさが変わったり線が繋がらなくなったりですが、その変化の度合いの調整をするパラメータです。0にすると明るさがほぼ変わらなくなって線もつながる条件ならつながり続けます。
  • Time Speed
    パーティクルの動く速さのスライダーです。0にすると止まります。
  • Connect Distance
    パーティクル同士を線で結ぶ距離のしきい値のスライダーです。パーティクル同士の距離がしきい値以下になると線で結ばれます。簡単に言うとスライダーを上げると線が結ばれやすくなって、下げると結ばれにくくなります。
  • Reset Parameter
    上記3つのパラメータを初期値にリセットするボタンです。
  • Mirror
    鏡を出したり消したりするボタンです(撮影用)

これらのパラメータを変更することでワールドの雰囲気を多少変化させることができます。
Connect Distanceを最大まで上げた状態↓ f:id:kaiware007:20191130144739p:plain

実装解説

ワールドの紹介はここまでにして、実装の解説に移ります。
VRChatは、ユーザーが自作したアバターやワールドをアップロードすることができますが、ユーザーが自作したC#スクリプトコンポーネントは使用できないという制約があります。
ただし、自作シェーダーは使えるので、工夫次第でいろんな事ができます。
「Simple Plexus」ワールドも、パーティクル部分の仕組みはすべてシェーダーで実現しています。
今回サンプルとしてGPUパーティクルの部分だけを取り出したプロジェクトをgithubで公開します。

github.com

簡略化のために、スライダーによるシェーダーパラメータの変更を抜いているので、実際にVRChatで公開してるワールドとは若干仕様が違います。

CustomRenderTextureについて

今回、UnityのCustomRenderTextureという機能を使ってパーティクルの座標更新を行いました。 CustomRenderTextureの詳しい仕様についてはUnityのマニュアルか、凹みさんのブログを参照してください(いつも大変お世話になっております)

カスタムレンダーテクスチャ - Unity マニュアル

tips.hecomi.com

ものすごく簡単に説明すると、シェーダーつきのテクスチャです。毎フレームシェーダーを走らせて絵を変えることができます。
今回の自分の使い方としては、パーティクルの更新した座標を格納するのに使っています。
まず、CustomRenderTextureの設定ですが、32x32x32頂点の立方体メッシュの一頂点をCustomRenderTextureの1ピクセルとして扱いたかったので、32x32x32の解像度の3次元テクスチャにしています。

f:id:kaiware007:20191130153436p:plain

パーティクルの素になるメッシュの作成

VRChatはC#スクリプトが使えないので、Graphics.DrawProceduralなどの直接的に描画処理を呼ぶAPIが使えません。
そのため、通常のMeshRenderにMeshを登録して描画させなければなりません。
しかし、32x32x32頂点の立方体をBlenderで作るのも(操作に慣れてないのもあり)面倒なので、一辺をN頂点で分割した立方体状の点群メッシュを生成するEditor拡張を作りました。

gist.github.com

32x32x32回分Vertexシェーダーが走ればいいだけなので、必要分の頂点座標の配列と、ただ順番に番号を詰め込んだIndex配列だけセットしています。
パーティクルや線のポリゴンはGeometryシェーダー内で生成しているので、UV座標もセットしていません。
注目ポイントは、Mesh.SetIndicesの2番めの引数でMeshTopology.Pointsを指定して点群メッシュとして生成しているところです。

パーティクルの座標更新

前項でMeshを作りましたが、実際の描画にはMeshの頂点座標の値は使いません。 頂点座標は、CustomRenderTextureの中で計算して書き込み、描画時にそれを取り出して使います。

実際にCustomRenderTextureの更新に使っているシェーダー(PlexusUpdate.shader)のメインの部分は下記になります。

 float4 fragUpdatePosition(v2f_customrendertexture i) : SV_Target
    {
        // テクスチャのUV座標(3次元)
        float3 pos = i.globalTexcoord;
        
        // 1ドット1メートルとする
        pos *= float3(_CustomRenderTextureWidth, _CustomRenderTextureHeight, _CustomRenderTextureDepth);

        // スライダーから時間の速さを取得
        float3 sliderPos = readDataPos(1);
        float range = _MaxY - _MinY;
        float limitY = clamp(sliderPos.y, _MinY, _MaxY);
        float normalizeY = saturate((limitY - _MinY) / range);
        float timeSpeed = lerp(_TimeSpeedMin, _TimeSpeedMax, normalizeY);

        // RTX2080系だとノイズ関数で浮動小数点演算の誤差かなんかで結果が偏りまくるのであまり大きな値にならないようにしている(ループするときに微妙だけど…)
        float time = fmod(_Time.y * timeSpeed, 256) + 138.21;

        // シンプレックスノイズで座標をゆらゆらさせてる
        float noiseSpeed = snoise(float2(pos.x + pos.y * _CustomRenderTextureWidth + pos.z * _CustomRenderTextureWidth * _CustomRenderTextureHeight / 34.2148, time * _PositionSpeed1 + 32.153));
        float yz = time * noiseSpeed * _PositionSpeed2;
        pos.xyz += snoise3D(pos.xyz  * _PositionNoiseScale + float3(yz * 0.1, yz * 0.34, yz * 0.75)) * _PositionRange;
        pos /= float3(_CustomRenderTextureWidth, _CustomRenderTextureHeight, _CustomRenderTextureDepth); // 正規化する

        // テクスチャに座標を書き込む
        return float4(pos, 1);
    }

3次元テクスチャのピクセル数分fragUpdatePositionが実行されます。
3次元テクスチャの1ピクセルを1頂点として、その頂点の座標と時間をもとにノイズでゆらゆらさせた座標を最後にRGBチャンネルにXYZ座標を書き込んでます。 明らかに1を超えたり負の値になったりしますが、テクスチャのColor Formatの設定をRGBA Floatにしているので問題ありません。

パーティクルの描画

描画は、PlexusDraw.shaderで行っています。
シェーダーの中身は、主にVertexシェーダーとGeometryシェーダーとFragmentシェーダーに分かれています。
Vertexシェーダーから見ていきましょう。

struct appdata
{
    float4 vertex : POSITION;
    uint vid : SV_VertexID;
};

struct v2g
{
    float4 vertex : SV_POSITION;
    uint vid : TEXCOORD0;
    float scale : TEXCOORD1;
    float particleIntensity : TEXCOORD2;
    float lineIntensity : TEXCOORD3;
    float4 positions[13] : TEXCOORD4;
};

// 省略~~~~

sampler2D _MainTex;
float4 _MainTex_ST;
UNITY_DECLARE_TEX3D(_PosTex3D);
float3 _PosTexTexelSize;

float _Size;
float _SizeNoiseScale;
float _SizeNoiseSpeed;
float _LineWidth;
float _Intensity;
float _FogPower;

float4 _Color;
float3 _ColorNoiseScale;
float _ColorNoiseSpeed;
float _ColorSat;

float _ConectDist;


v2g vert(appdata v)
{
    v2g o;

    float3 vpos = v.vertex.xyz + _PosTexTexelSize.xyz * 0.5; // 自身のUV座標

    // 3次元テクスチャから頂点座標を取り出す
    float4 pos = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos, 0);

    float time = fmod(_Time.y, 256) + 132.5347;

    // 自身の頂点座標をワールド座標に変換
    o.vertex = mul(unity_ObjectToWorld, pos);

    o.vid = v.vid;
    o.scale = clamp((_Size + (snoise(float2((float)v.vid / 234.2148, time * _SizeNoiseSpeed + 32.153)) * 0.5 + 0.5) *_SizeNoiseScale), 0.01, 1);

    // パーティクルや線のの明るさ計算
    o.particleIntensity = saturate(exp(-distance(o.vertex.xyz, _WorldSpaceCameraPos.xyz) * _FogPower));
    o.lineIntensity = saturate(o.particleIntensity);

    // 隣接する頂点の座標を取得
    float4 pos0  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(0, 0, _PosTexTexelSize.z), 0);
    float4 pos1  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, 0, _PosTexTexelSize.z), 0);
    float4 pos2  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, 0, 0), 0);
    float4 pos3  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(0, _PosTexTexelSize.y, 0), 0);
    float4 pos4  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(0, _PosTexTexelSize.y, _PosTexTexelSize.z), 0);
    float4 pos5  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, _PosTexTexelSize.y, _PosTexTexelSize.z), 0);
    float4 pos6  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, _PosTexTexelSize.y, 0), 0);
    float4 pos7  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, -_PosTexTexelSize.y, _PosTexTexelSize.z), 0);
    float4 pos8  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, -_PosTexTexelSize.y, 0), 0);
    float4 pos9  = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, -_PosTexTexelSize.y, _PosTexTexelSize.z), 0);
    float4 pos10 = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.x, 0, -_PosTexTexelSize.z), 0);
    float4 pos11 = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(0, _PosTexTexelSize.y, -_PosTexTexelSize.z), 0);
    float4 pos12 = UNITY_SAMPLE_TEX3D_LOD(_PosTex3D, vpos + float3(_PosTexTexelSize.xy, -_PosTexTexelSize.z), 0);

    // ワールド座標に変換
    o.positions[0] = mul(unity_ObjectToWorld, pos0);
    o.positions[1] = mul(unity_ObjectToWorld, pos1);
    o.positions[2] = mul(unity_ObjectToWorld, pos2);
    o.positions[3] = mul(unity_ObjectToWorld, pos3);
    o.positions[4] = mul(unity_ObjectToWorld, pos4);
    o.positions[5] = mul(unity_ObjectToWorld, pos5);
    o.positions[6] = mul(unity_ObjectToWorld, pos6);
    o.positions[7] = mul(unity_ObjectToWorld, pos7);
    o.positions[8] = mul(unity_ObjectToWorld, pos8);
    o.positions[9] = mul(unity_ObjectToWorld, pos9);
    o.positions[10] = mul(unity_ObjectToWorld, pos10);
    o.positions[11] = mul(unity_ObjectToWorld, pos11);
    o.positions[12] = mul(unity_ObjectToWorld, pos12);

    return o;
}

元のメッシュの頂点座標を3次元のUV座標に変換して、3次元テクスチャからUNITY_SAMPLE_TEX3D_LODで頂点座標の情報を取り出しています。
また、次のGeometryシェーダーで隣接する頂点と線を結ぶために、隣接する頂点の座標も配列にセットしています。
3次元空間をグリッド状に区切り、1グリッドの中に1つの頂点が入ってると仮定します。
その場合、隣接するグリッドは全部で26個1あります。流石にすべてのグリッドが26個ずつ調べるのは負荷が大きそうです。
しかし、線は点と点を結ぶものなので、線がつながるかどうかは片方の点(グリッド)が判定するだけでいいのです。
つまり、すべての点(グリッド)が自分の右下方向だけを見るようにすれば、各グリッドが判定する隣接グリッド数は7個になります。
f:id:kaiware007:20191130231725p:plain

1. (0,0,1) 2. (1,0,1) 3. (1,0,0) 4. (0,1,0) 5. (0,1,1) 6. (1,1,1) 7. (1,1,0)

自分の右下方向の頂点7つを次のGeometryシェーダーに渡しています。

※(2019/12/8修正)隣接するグリッド数は7個では無く、13個でした。
自分でも勘違いしてたので、Unityで模型を作って確認してみました。
下図では、赤い球体は自身のグリッド、周りの青い球体は隣接するグリッド、水色の球体は今までの自信から参照する隣接するグリッドを表しています。 f:id:kaiware007:20191208152150j:plain

これに隣接するグリッドからの参照も表示したものが下図です。

f:id:kaiware007:20191208152431j:plain

線が多すぎてわかりにくいので、正面(Z軸方向を奥にして)から見てみます。

f:id:kaiware007:20191208152953j:plain

あれ?斜め上方向に線が繋がってないぞ…?ということがわかりました。
下図の赤い線です。

f:id:kaiware007:20191208153305p:plain

これはZ軸だけを見たものですが、他の軸から見ても同様なので、斜め方向にも参照を追加します。
下図の緑の球体と線が追加した参照です。

f:id:kaiware007:20191208153558j:plain

既存のグリッド数も合わせると全部で13個になります。

  1. (0,0,1)
  2. (1,0,1)
  3. (1,0,0)
  4. (0,1,0)
  5. (0,1,1)
  6. (1,1,1)
  7. (1,1,0)
  8. (1,-1,1)
  9. (1,-1,0)
  10. (1,-1,-1)
  11. (1,0,-1)
  12. (0,1,-1)
  13. (1,1,-1)

f:id:kaiware007:20191208155945j:plainf:id:kaiware007:20191208155954j:plain

追加した参照の線も周辺グリッドに配置すると、隣接するグリッドすべてがちゃんと結ばれるようになりました(わかりにくいですが…)
おこうさん、ご指摘ありがとうございました!

で、話をもどしまして…

Geometryシェーダーは大きく2つの構成に分かれています。
前半は、自身のパーティクルの板ポリ(ビルボード)を作成するパート。
後半は、隣接する頂点を線で結ぶかの判定と実際の線の板ポリを作成するパートです。

struct v2g
{
    float4 vertex : SV_POSITION;
    uint vid : TEXCOORD0;
    float scale : TEXCOORD1;
    float particleIntensity : TEXCOORD2;
    float lineIntensity : TEXCOORD3;
    float4 positions[7] : TEXCOORD4;
};

struct g2f
{
    float4 pos : SV_POSITION;
    float2 uv : TEXCOORD0;
    float intensity : TEXCOORD1;
    float3 color : TEXCOORD2;
};

// 省略~~~~~

// ジオメトリシェーダ
[maxvertexcount(82)]
void geom(point v2g input[1], inout TriangleStream<g2f> outStream)
{
    g2f output;
    float4 pos = input[0].vertex;
    float particleIntensity = input[0].particleIntensity;
    float lineIntensity = input[0].lineIntensity;
    float scale = input[0].scale;
    float3 color = hsv2rgb(float3(snoise(float4(pos.xyz * _ColorNoiseScale, _Time.y * _ColorNoiseSpeed)) * 0.5 + 0.5, _ColorSat, 1));

    // ビルボード用の行列
    float4x4 billboardMatrix = UNITY_MATRIX_V;
    billboardMatrix._m03 = billboardMatrix._m13 = billboardMatrix._m23 = billboardMatrix._m33 = 0;

    // パーティクル(点)の板ポリ作成
    // 四角形になるように頂点を生産
    for (int x = 0; x < 2; x++)
    {
        for (int y = 0; y < 2; y++)
        {
            // UV
            float2 uv = float2(x * 0.5, y);
            output.uv = uv;

            // 頂点位置を計算
            output.pos = pos + mul(float4((float2(x, y) * 2 - float2(1, 1)) * scale, 0, 1), billboardMatrix);
            output.pos = mul(UNITY_MATRIX_VP, output.pos);
            output.intensity = particleIntensity;
            output.color = color;

            // ストリームに頂点を追加
            outStream.Append(output);
        }
    }

    // トライアングルストリップを終了
    outStream.RestartStrip();

    // 線の処理
    if (lineIntensity > 0.0) {
        // 近い点同士を線で結ぶ
        float3 cameraDiff = pos.xyz - _WorldSpaceCameraPos;
        float3 normal = normalize(cameraDiff);

        for (int i = 0; i < 13; i++)
        {
            float4 targetPos = input[0].positions[i];

            // 点同士の距離を判定
            float len = distance(targetPos, pos);
            if (len <= _ConectDist)
            {
                // 点と点を結ぶベクトル(線の方向)
                float3 dir = normalize(targetPos.xyz - pos.xyz);
                // 線の幅方向のベクトル
                float3 right = normalize(cross(dir, normal)) * _LineWidth * 0.5;

                // 線のポリゴンを構成する4頂点の座標
                float4 v0 = mul(UNITY_MATRIX_VP, float4(pos.xyz - right, 1));
                float4 v1 = mul(UNITY_MATRIX_VP, float4(pos.xyz + right, 1));
                float4 v2 = mul(UNITY_MATRIX_VP, float4(targetPos.xyz - right, 1));
                float4 v3 = mul(UNITY_MATRIX_VP, float4(targetPos.xyz + right, 1));

                float3 targetColor = hsv2rgb(float3(snoise(float4(targetPos.xyz * _ColorNoiseScale, _Time.y * _ColorNoiseSpeed)) * 0.5 + 0.5, _ColorSat, 1));

                // 点同士の距離に応じて線の明るさを変える(近いほど明るい)
                float distIntensity = 1 - smoothstep(0.0, 0.5, saturate(len / _ConectDist));
                output.intensity = lineIntensity * distIntensity;

                // 線が見える時にだけ線を引く
                if (output.intensity > 0.0)
                {
                    // triangle line
                    output.pos = v0;
                    output.uv = float2(0.5, 0);
                    output.color = color;
                    outStream.Append(output);

                    output.pos = v2;
                    output.uv = float2(0.5, 1);
                    output.color = targetColor;
                    outStream.Append(output);

                    output.pos = v1;
                    output.uv = float2(1, 0);
                    output.color = color;
                    outStream.Append(output);

                    outStream.RestartStrip();

                    output.pos = v2;
                    output.uv = float2(0.5, 1);
                    output.color = targetColor;
                    outStream.Append(output);

                    output.pos = v3;
                    output.uv = float2(1, 1);
                    output.color = targetColor;
                    outStream.Append(output);

                    output.pos = v1;
                    output.uv = float2(1, 0);
                    output.color = color;
                    outStream.Append(output);

                    outStream.RestartStrip();
                }
            }
        }
    }
}

最後にFragmentシェーダーです。
特に複雑なことはしておらず、Geometryシェーダーで生成したポリゴンにUV座標に応じてテクスチャの色とパーティクルの明るさを乗算しているだけです。

struct g2f
{
    float4 pos : SV_POSITION;
    float2 uv : TEXCOORD0;
    float intensity : TEXCOORD1;
    float3 color : TEXCOORD2;
};

// 省略~~~~

fixed4 frag(g2f i) : SV_Target
{
    // sample the texture
    fixed4 col = tex2D(_MainTex, i.uv);
    col.rgb *= _Intensity * i.intensity * i.color;

    return col;
}

スライダーによるパラメータの変更

リポジトリには入れてないんですが、実際のVRChatのワールドではパーティクルのパラメータをスライダーで調整できるようになっています。

f:id:kaiware007:20191130184228p:plain

スライダーのつまみの座標位置をシェーダーに渡すため、@yagiri_pgさんの「座標、姿勢をRenderTextureに書込、読込機構」を使わせていただきました。圧倒的感謝! yagiri.booth.pm

前述したとおり、VRChatはC#スクリプトが使えないので、オブジェクトの座標をシェーダーに正攻法では渡せません。
正直自分ではどうすればいいかわからなかったところに、@yagiri_pgさんが機構を公開してくれたので非常に助かりました。
実際に中身を見て驚愕したんですが、概要を書くと下記の通りです。

  1. Quadにシェーダーを割り当てる
  2. 特定のカメラにだけ映るようにレイヤー設定する
  3. 特定のカメラにRenderTextureを割り当てる
  4. 特定のカメラに該当Quadが映るときに、頂点シェーダーでカメラの指定した位置にQuadを移動させて座標を色にしてレンダリングさせる

こうすることで、RenderTextureの特定の位置に値を書き込めるという寸法らしいですC#スクリプトなら1行くらいで済むことをやるのにこれだけの手順を踏まないといけないと知った時は辛い気持ちになった)

また、スライダーのパラメータの表示に@butadiene121さんの「数字表示シェーダー」を使わせていただきました。圧倒的感謝!

まとめ

長々とした説明になってしまいましたが、まとめると以下のとおりです。

  • 必要な頂点数分の点群メッシュを作成
  • CustomRenderTextureでパーティクルの座標を更新
  • VetexシェーダーでCustomRenderTextureから頂点座標を取り出して頂点の座標を移動
  • Geometryシェーダーでパーティクルと線のポリゴンを作成
  • VRChatのシェーダー芸人達は凄い

最後に、「Simple Plexus」ワールド作成に助力頂いた方々に圧倒的感謝!

f:id:kaiware007:20191130143016p:plain

※画像に載ってないけど初公開時にワールドに来てくださった方々に圧倒的感謝!


  1. 27個と書いてたけど、自身を抜いたら26個でした。

Thetaのストリーミング映像をリアルタイムにリフレクションさせてみた

本記事は Unity Advent Calendar 2018 の 18日目 の記事です。

qiita.com

はじめに

9/15のInfiniteRaveというイベントで、ThetaVを使って360度のストリーミング映像をSkyBoxにしたり、オブジェクトにリフレクションさせたりするVJシステムを作って使っていました。
当時はReflectionProbeを使って毎フレームSkyboxだけをターゲットにしてCubeMapを生成していたのですが、そのCubeMapのレンダリングがそこそこ重かったです。
60FPSは維持してましたが、他のオブジェクトなどの負荷も合わせると結構ギリギリなラインでした(そんなにつよつよPCではなかったせいもある)

www.youtube.com

上のアーカイブ動画の冒頭からスクリーンに映っている映像がソレです。
今回ReflectionProbeを使わずに、リアルタイムにリフレクションさせる仕組みを作ってみたのでそれの解説をします。
下が完成形の動画です。

youtu.be

続きを読む

「Unityを使ったVJでよく使うイメージエフェクト集」という内容でLTしてきた

f:id:kaiware007:20181109014825p:plain

11月9日、ドリコムで開催された「Unity Shader 勉強会」で「Unityを使ったVJでよく使うイメージエフェクト集」という内容でLTしてきました。

connpass.com

シェーダーに関する内容での発表を頼まれていたんですが、他の登壇者とかぶらないネタを考えてVJネタに落ち着きました。
通常のスライドと見せかけて、スライドにイメージエフェクトをかけて、見てる人をびっくりさせるためだけにUnityでスライドを作りました(背景色とかレイアウトもちょっとそれっぽくした)
uGUIを使ったスライド切り替えシステム自体はすぐ出来たんですが、作図が地味にめんどくさかったです。
VJネタはこういう技術系勉強会では珍しかったのか、Twitterでもなかなか好評だったと思います。
UnityエンジニアでもVJに興味を持つ人が増えるといいなと思います。

実際に見れるスライド

unityroomさんのほうで実際に動くスライドを公開しています。
音楽を聞きながら適当にイメージエフェクトをかけるとだんだん気持ちよくなるかもしれません。  

Unityを使ったVJでよく使うイメージエフェクト集スライド | 無料ゲーム投稿サイト unityroom - Unityのゲームをアップロードして公開しよう

スライドのソースコード

スライドのUnityプロジェクトもgithubの方で公開しています。
シェーダーのソースコードも入ってるので、詳しく見たい方はこちらを見てください。

github.com

VIVEトラッカーの接続でハマった話

前回、VIVE proのHMDのセッティングでハマった話を書いた。

今回は、VIVEトラッカーの接続で3点ハマった箇所があったので、メモしておく。
ちなみにSteamVRのバージョンは1533664367
自分は後述する手順で接続できたが、正しい手順なのかはちょっとわからない。
間違っていたら教えて欲しい。

続きを読む