LoginSignup
16
7

More than 3 years have passed since last update.

シーケンス(シーンの制御) -- GLSL --

Last updated at Posted at 2019-12-03

シーケンスとは

ウィキペディアをみると
シーケンス制御(シーケンスせいぎょ、Sequential Control)とは「あらかじめ定められた順序または手続きに従って制御の各段階を逐次進めていく制御」である。日本工業規格(JIS)の旧規格 C0401 に定義されている。
と書いてある。

まあ、シーンの時間管理です。shaderだけで、こんな風にシーンを切り替えたり、形を時間でコントロールします。

output.gif

shaderでシーンの管理

基本、shaderは、
uniform float time;
こいつで、時間を貰い動いている。
ここで、world time, local timeと言う言葉を使わせて貰います。
world timeとは、uniformで定義されてるtimeと、まったく同じとします。
実際には、シーケンスをすると同じ時間は戻ってこないので、mod(time,60.0)とかで使わないと実用的じゃありません。
local timeとは、あるシーンの0.0秒から始まる時間とします。
以下、world time, local timeの言葉を使って説明していきます。

local timeの作り方

world timeで5秒の時にlocal timeが0秒からスタート。

float localTime=max(0.0,wolrdTime-5.0);

そんなに難しい事じゃありません。これからシーンの数が増えてくると、このままでは使えないので、わかりづらくなりますが、これが基本です。

シーンにIDを振る

シーンにIDを振らないと管理しようがないので、IDの取得をします。ちょっと、ややこしくなってきます。
world time 0.0秒の時からIDが0,
world time 3.0秒の時からIDが1,
world time 6.0秒の時からIDが2,となる

int ID=-1;
ID+=int(step(0.0,worldTime));
ID+=int(step(3.0,worldTime));
ID+=int(step(6.0,worldTime));

このようにstep()を使ってIDを増やしていきます。
IDの初期値は、マクロの書き方の問題で、-1にしないと上手くいきませんでした。なのでIDの初期値は-1にしてください。

IDを作る事の利点

最大の利点は、配列が使える事です。
if文switch文など、持ち出さなくても、配列で一発処理だし、管理も楽です。
例えば

float fog=float[](
    0.03,
    0.05,
    0.03
)[ID];

こんな感じに出来ます。ここでシーン別のフラグを一括管理。ここに数字を入れて調整できます。
フラグの調整が複数あるなら、マクロで持ち出し、並べておけば、割と便利でいいです。設定用の別ファイルを作るようなモノです。マクロでするなら、バックスラッシュを使い

#define E float[](\
0.03,\
0.05,\
0.03\
)[ID]

こんな感じで可読性を上げて

float fog=E;

なるべく直感的にわかるように組み立てる

local timeをつくる事と、IDを振ることが基本です。このまま書いていくと、最初の内は把握できるけど、後から、このシーンは2秒増やしたいとか、調整していくと、全部の数字を書き直したり、わけわからなくなってきます。そこでマクロを使い組み立てていきます。
私の使い勝手としては、ID 0に3秒使い、ID 1に5秒使い、ID 2に8秒としたかったので,そういう風に組み立てました。それについて書いていきます。この辺りは、個人の好みもあると思うので、その場合は自分好みに改造してください。

S(3.0)S(5.0)S(8.0)

マクロを、こう書いて管理します。S()を使ってますが、これは好みの問題です。なんでもいいです。並び順には、意味があります。IDが0でスタートして、3秒後にIDが1になり、ここでlocal timeがリセットされ5秒後にIDが2になり、またlocal timeがリセットされ8秒後にはIDが3になります。IDが変わる度にlocal timeがリセットされます。使い勝手が優先なので、#define S(a)が面倒なのは、あきらめましょう。

float worldTime=mod(time,60.0);
float TIME=0.0,SAM=0.0;
int ID=-1;
#define S(a) TIME=max(TIME,step(worldTime,SAM+a-0.01)*(worldTime-SAM));ID+=int(step(SAM,worldTime));SAM+=a;

IDはID,TIMEがlocal time、worldTimeはworld time、SAMは累積時間です。基本SAMは使う事はありません。
local timeは使用時間が過ぎると0にリセットされるようにすることで、後ろに書いたマクロ時間とのmax()で、狙い通りのlocal timeを取得できます。-0.01が書いてありますが、繋がりが変だったので、調整の為にいれました。
追記:
TIME=max(TIME,step(worldTime,SAM+a-0.01)*(worldTime-SAM));
これの-0.01の調整は不等号の問題と気付いたので
TIME=max(TIME,(1.0-step(SAM+a,worldTime))*(worldTime-SAM));
こちらに変更してください。参考の為に本文は、書き直しませんが、サンプルは直しておきます。
TIME=max(TIME,sign(SAM+a-worldTime)*(worldTime-SAM));
これも使えます。

使い方

IDをswitch文で使っていきます。shaderだとif文は重いとされてます。ましてやswitch文なんて、もってのほかだ。と思われてるでしょうが、時間管理だと、全スレッドが同じIDのところしか動かないので、問題無いと思われます。確かめるすべを知らないのですが、GPUの負荷グラフと、にらめっこしていたら、軽い処理のターンだと負荷が落ちていました。
if文は重いの理由は。
GPUで、if~elseの文を書くと、あるスレッドは、ifがtureの処理をした後,ifがfalseの処理をするスレッドの処理終了まで待機。ifがfalseのスレッドは、その逆の挙動。つまりture、falseの処理の時間を足した時間が必要になる。だから重いって事。でも、このおかげでdFdxとかの関数が、いつでも使える。
そういう仕組みを理解すれば、この使い方は、それほど気にしなくていいかなと思えます。
前置きが長くなりましたが、

switch(ID)
    {
        case 0:
            // scene 0 の処理
            break;
        case 1:
            // scene 1 の処理
            break;
        case 2:
            // scene 2 の処理
            break;
    }

これで、単純にシーンが書いていけます。この中身は、どういうふうにも使えます。距離関数の振り分けに使ってもいいし、何でもありですね。ただ、配列が使える処理なら、パフォーマンス的に、もったいないので配列を使った方が良いと思います。

途中でシーンを入れ替えたくなったり、あるシーンを一回置きに入れたい時の対処法は、
シーンのIDの順番の配列を作って処理します。

#define SceneID int[](0,1,2,1,3,2)
int id=SceneID [ID];

別のパターンの管理法。

今までの奴はシーンの時間管理なので、シーンが変われば、local timeをリセットして、IDを増やせば済んだのですが、同じシーン内で、別々のモノに対しての制御になると、使い勝手が変わってきます。ID管理では、逆に面倒になります。なので、IDを排除してしまいます。
別々のモノが混在していると、別々の時間が必要になってきます。時間で処理すると扱いが煩雑になってきます。そこで時間の正規化をします。GLSLでは、お得意のジャンルです。
どういうことかというと、
例えば時間の数値を2秒後に3秒かけて0.0から1.0にする。2秒より前は0.0で、5秒から後は1.0にする。

float t = clamp((time - 2.0) / 3.0, 0.0, 1.0);

こうしてt値(0.0<=t<=1.0)を取得します。
この数値は、パラメトリック関数のt値として使います。これで位置、回転、色とかを動かすのか簡単になりました。
一旦、これを関数にしてみます。関数名の付け方にセンスはないですが・・・

float normalizeTime(float time, float startTime, float useTime)
{
    return clamp((time-startTime)/useTime, 0.0, 1.0);
}

こうして正規化した時間を使い、色々なモノを動かしていきます。
パラメトリック関数とは、言っていますが、だいたいは、mix()を使っておしまいでしょうね。
あと、ある数値に対する加算かな。
例を出します。カメラの位置(ro)を3秒後から、5秒使ってA地点からb地点に移す。

float t = normalizeTime(time, 3.0, 5.0);
vec3 ro = mix(A, B, t);

もう1例。円の半径(r)を3秒後から、5秒使って2.0増やす。

float r=1.0;
float t = normalizeTime(time, 3.0, 5.0);
r+=t*2.0;

こんな感じです。やっている事は、呆れるくらい簡単です。
これを幾つか組み合わせて、コントロールしていき複雑な動きにみせていきます。
これで、単純なsin()の動きから解放されます。

イージング

上のやり方ですが、もう一段、奥があります。イージングって奴です。
モーショングラフィックスのジャンルで使われています。
実装も簡単にできます。ただ、かっこよく見えるかは別です。そこは、腕でしょ。私にはないので、組込み方だけ説明します。
glslで用意されている関数smoothstep()。これもイージングの一種です。これを使ってみます。

float t = normalizeTime(time, 3.0, 5.0);
t=smoothstep(0.0,1.0,t); // easing
ro = mix(A, B, t);

まあ、こんだけです。正規化した時間をイージングの関数に入れて、出力をパラメトリック関数に入れる。
イージング関数は、さやちゃんぐbotさんの
速度警察に摘発されないためのイージング関数集
ここを見れば、いっぱいあります。色々試してください。

累積時間を使う場合

上のやり方をlocal timeが並列に動くことなく順序立てて動くなら、累積時間が使える。その場合をマクロを使って書いてみます。色々なモノが絡むので、書く順番でコントロールしていきます。

float T=0.0,SAM=0.0;
#define Q(a) T=clamp((worldTime-SAM)/a, 0.0, 1.0);SAM+=a;

Q(2.0) 
float a=mix(2.0,10.0,T);
Q(3.0)
    float b=mix(5.0,-3.0,T);
Q(7.0) float c=mix(5.0,-3.0,smoothstep(0.0,1.0,T));
Q(5.0)b+=3.0*T;

こんな風に改行を入れたり、インデントを入れたり、逆に一行で書いたり、と見やすく書いていきます。マクロを上手く使えば可読性が上がるので、感覚でシーケンスが出来たりします。
複数のオブジェクトをコントロールする場合は、SAM=0.0;で一度リセットできます。
サンプルに書いた奴は、同じ時間のタイミングで動いているので、わざわざリセットをかける必要はないのですが、参考の為に書きました。最初に紹介した方法より累積時間を使った方が、コントロールしやすいかもしれません。そうは言っても、ケースバイケースでしょうね。

音楽でのシーケンス

music shaderというジャンルがあります。float beatとも呼ばれているようです。music shaderのシーケンスについての以前、記事を書きましたが、そのなかのドラムのシーケンスをグラフィックに持ち込んで、グラフィックにビート感をだしても面白そうなので、その説明をします。これは時間を正規化するより音のエンベロープを持ち込んで処理の方が良さそうです。音量の大小で制御しようという発想です。
このアイデアは、この記事を書いている時に思いつきました。
GLSLで音楽(まずは、ドラムだ)
ここより、ドラムのシーケンス部分を抜粋。

#define BPM 120.0
#define A (15.0/BPM)
float sequence(int s,float time)
{
  float n =mod(time,A);
  for(int i=0;i<16;i++){
    if((s>>(int(time/A)-i)%16&1)==1)break;
    n+=A;
  }
  return n;
}

そして0x0581がリズムデータです。intをbit演算で楽譜代わりに使ってます。

float localTime=sequence(0x0581,worldTime);
float value=exp(-localTime*2.0); //envelope

このvalue(音量)をt値として使ってみます。ちなみにenvelopeの関数は、時間が0.0の時に出力が1.0で時間と共に減少するが負数には成らない関数です。t値としては、おあつらえ向きです。
サンプルの方は、sequence()に手を加えてあります。
この部分の追記
0x0581では、イメージ出来ないので、マクロを使って書き換えました。

#define Rhythm2Int(v,a)v=0;for(int i=0;i<16;i++)v+=a[i]<<i;

int rhytm;
Rhythm2Int(rhytm, int[]( 1,0,0,0, 0,0,0,1, 1,0,1,0, 0,0,0,0 ))
float localTime=rhythmSequence(rhytm,worldTime,beat);
float value=exp(-localTime*3.0); //envelope

これで感覚でリズムを書けます。サンプルの方も直しておきました。
1小節を16等分(16分音符)してnote onするタイミングが 1 になります。
music shaderのシーケンスに興味ある方は、こちらもどうぞ。音が出ます。
https://www.shadertoy.com/view/WsX3R4
https://www.shadertoy.com/view/wtc3R7

最後に

シーケンスっていうのも、面白いものです。これに手を出してからマクロを書く妙味を覚えました。bit演算とか、色々なモノが利用できます。ここは、余り数学の出番のない世界な気がします。他にもシーケンス方法が、ありそうな気もするし、状況によりけり、ここの奴では、物足りない時も出てきます。実際に私も状況によりけり書き直してたりします。これをたたき台にして色々試してください。
時間シーケンスとは関係はありませんが、2Dで色々な形とかを描写する時にもマクロは重宝します。マクロ使いってのも面白いので、こちら色々試してください。

サンプル

上の要素を全ていれたサンプルを書きました。説明も付けておきました。中に書いてあるcase 0 については、中でシーケンスは使ってません。少し寂しいので、普通のshaderを、賑やかしに混ぜてあります。

#version 300 es
precision highp float;
uniform vec2  resolution; 
uniform float time; 
out vec4 fragColor;

// ++++++++++++++++++++++++++++
// シーケンス関係のマクロ
//

// world timeはmod()でループさせて、制御範囲を決める。
#define worldTime mod(time,30.0)

float TIME=0.0,SAM=0.0;
int ID=-1;

#define S(a)\
    TIME=max(TIME,(1.0-step(SAM+a,worldTime))*(worldTime-SAM));\
    ID+=int(step(SAM,worldTime));\
    SAM+=a;

// シーンの順番
#define SceneID int[](3,0,1,2,1,3,2,0)[ID]

// +++++++++++++++++++++++++++++
// 良く使いまわすマクロ
//
#define PI acos(-1.0)
#define TAU PI*2.0
#define rot(a) mat2(cos(a), sin(a), -sin(a), cos(a))
// https://twitter.com/phi16_/status/1143451702219042816
#define hue(t) (cos((vec3(0,2,-2)/3.0+t)*TAU)*0.5+0.5)
// https://twitter.com/phi16_/status/1151731126580338688
#define hsv(h,s,v) mix(vec3(1),hue(h),s)*v
// https://qiita.com/7CIT/items/4126d23ffb1b28b80f27
#define lpNorm(p,n) pow(dot(pow(abs(p),vec2(n)),vec2(1)),1.0/n)
// 超テキトーな乱数取得関数。
#define hash(n) fract(sin(n)*5555.0)
// https://twitter.com/iquilezles/status/1177461747625553921
#define opRepLim(p,s,lim) p-s*clamp(round(p/s),0.0,lim)

// +++++++++++++++++++++++++++++
// scene 0 で、使ってる関数
//
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);
}

// pmod()のスムース版
// https://www.shadertoy.com/view/WlBSRR
vec2 polarSmoothFold(vec2 p,float n)
{
    float h=floor(log2(n));
    float a =TAU*exp2(h)/n;
    for(int i=0; i<int(h)+2; i++)
    {
        vec2 v = vec2(-cos(a),sin(a));  
        p-=2.0*smin(0.0,dot(p,v),0.05)*v;
        a*=0.5;
    }
    return p;
}

// +++++++++++++++++++++++++++++
// scene 1 で、使ってる関数
//
float normalizeTime(float time, float startTime, float useTime)
{
    return clamp((time-startTime)/useTime, 0.0, 1.0);
}


// +++++++++++++++++++++++++++++
// scene 2 で、使ってる関数
//
// https://neort.io/art/bj2225k3p9f9psc9ovf0
// イージング関数を一部抜粋。
float ease_in_quad(float x) {
    float t=x; float b=0.; float c=1.; float d=1.;
    return c*(t/=d)*t + b;
}

float ease_in_sine(float x) {
    float t=x; float b=0.; float c=1.; float d=1.;
    return -c * cos(t/d * (3.14159265359/2.)) + c + b;
}

// +++++++++++++++++++++++++++++
// scene 3 で、使ってる関数
// https://qiita.com/gaziya5/items/e58f8c1fce3f3f227ca7
// リズムシーケンスを少し改造
//

#define Rhythm2Int(v,a)v=0;for(int i=0;i<16;i++)v+=a[i]<<i;

float rhythmSequence(int s,float time, float a)
{
  float n =mod(time,a);
  for(int i=0;i<16;i++){
    if((s>>(int(time/a)-i)%16&1)==1)break;
    n+=a;
  }
  return n;
}

// +++++++++++++++++++++++++++++
// int A[26]は、アルファベットのデータだが、わかりずらいので、マクロを使用。
// int N[10]は、数字のデータ。インデックスが、そのまま数字なので、マクロを省略。
#define _A 0
#define _B 1
#define _C 2
#define _D 3
#define _E 4
#define _F 5
#define _G 6
#define _H 7
#define _I 8
#define _J 9
#define _K 10
#define _L 11
#define _M 12
#define _N 13
#define _O 14
#define _P 15
#define _Q 16
#define _R 17
#define _S 18
#define _T 19
#define _U 20
#define _V 21
#define _W 22
#define _X 23
#define _Y 24
#define _Z 25

int A[26]=int[](7325,53709,35217,53705,36241,3217,39317,7196,49601,39176,11282,35088,6202,14392,39321,3229,47513,11421,37285,16577,39192,2578,14872,8738,16418,33667);
int N[10]=int[](39835,4106,36237,38285,5148,38293,40341,643,40349,38301);

// 16セグメントの距離関数。
// intをデータとして使用。bit演算でデータを取り出す。
float de16Seg(vec2 p,int n)
{
    float e=2.;
    mat2 m=mat2(0,-1,1,0);
    for(int i=0;i<16;i++){
        int j=i&3;
        if(j==0)m*=mat2(0,1,-1,0);
        if((n>>i&1)==1){
            vec2 a=m*vec2(26>>j&1,19>>j&1)*vec2(0.6,1);
            vec2 b=m*vec2(13>>j&1,9>>j&1)*vec2(0.6,1);
            a.x+=0.2*a.y;
            b.x+=0.2*b.y;
            vec2 c=p-a,d=b-a;
            e=min(e,length(c-d* clamp(dot(c,d)/dot(d,d), 0.0, 1.0)));
        }
    }
    return e;
}

// debug用マクロのフラグ。 -1で通常。0~3でシーンを指定。
#define TEST_ID -1

void main( )
{
    vec2 p = (gl_FragCoord.xy*2.0-resolution)/resolution.y;
    vec3 col = vec3(0.15);

    // これでシーンの切り替え時間を設定する。
    S(3.0)S(5.0)S(3.0)S(4.0)S(4.0)S(5.0)S(3.0)S(3.0)

    // switchのcaseの後にブロックは、無くても良いにですが、変数名の衝突が、面倒なのでブロックを使ってます。
#if TEST_ID < 0
    switch(SceneID)
#else
    switch(TEST_ID)
#endif
    {
        case 0:
        {
            // シーケンスを使ってない奴。
            p*=8.0;
            for(int i=0; i<3; i++)
            {
                p=polarSmoothFold(p,8.0);
                p-=0.7+sin(TIME)*0.1;
                p*=rot(hash(2.3+float(i)+floor(TIME*1.5)));
                p=polarSmoothFold(p,5.0);
                p.x-=0.3+sin(TIME)*0.1;
                p*=0.6;  
            }
            float de=abs(lpNorm(p,5.0)-0.3);
            de = min(de,abs(dot(p,rot(sin(TIME)*0.5+1.7)*vec2(0,1))));
            col=mix(vec3(0),col,smoothstep(0.03,0.031,de));
            col=mix(hsv(atan(p.x,p.y)*0.5+TIME,0.7,0.8),col,smoothstep(0.02,0.021,de));
            break;
        }
        case 1:
        {
            // 個別のシーケンス
            float de;
            vec3 q;

            float itr=35.0;
            float num=15.0;

            for(float i=0.0;i<num;i+=num/itr)
            {
                vec2 coord=vec2(hash(i+133.125),hash(i+320.123))*2.0-1.0;
                coord.x*=resolution.x/resolution.y;
                float t=normalizeTime(TIME, hash(i+456.123)*5.0, hash(i+789.123));
                t=smoothstep(0.0,1.0,smoothstep(0.0, 1.0, t)); // easing
                float r=mix(0.05,0.5,t);
                de=abs(lpNorm(p-coord*0.7,8.0)-r);
                col=mix(vec3(0),col,smoothstep(0.02,0.021,de));\
                col=mix(hsv(hash(i+74221.123),0.7,0.8),col,smoothstep(0.01,0.011,de));
            }
            break;
        }
        case 2:
        {
            // 累積時間をつかったシーケンス
            // ここで使うならworld timeは、TIMEになる。注意。
            float T,SAM1;
            #define Q(a) T=clamp((TIME-SAM1)/a, 0.0, 1.0);SAM1+=a;
            #define DSP2(c)\
                col=mix(vec3(0),col,smoothstep(0.03,0.031,de));\
                col=mix(c,col,smoothstep(0.02,0.021,de));
            float de,r;
            vec2 coord;

            // shape 1
            SAM1=0.0; // init
            coord=vec2(0);
            r=0.0;
            Q(0.5)
                T=ease_in_quad(T); // easing
                r+=0.5*T;
            Q(0.5)
            Q(0.5)
            Q(0.5)
                T=ease_in_sine(T); // easing
                r-=0.3*T;
            de=abs(length(p-coord)-r);
            DSP2(hsv(0.0,0.8,0.7))

            // shape 2
            SAM1=0.0; // init
            coord=vec2(0);
            r=0.0;
            Q(0.5)
            Q(0.5)
                T=ease_in_quad(T); // easing
                r+=0.15*T; 
                coord+=vec2(0.7,0)*T;
            Q(0.5)
                T=ease_in_sine(T); // easing
                coord *=rot(T*TAU);
            de=abs(length(p-coord)-r);
            DSP2(hsv(0.5,0.8,0.7))
            de=abs(length(p+coord)-r);
            DSP2(hsv(0.5,0.8,0.7))        
            break;
        }
        case 3:
        {
            // music shader シーケンスからの応用
            float bpm=120.0;
            float beat=15.0/bpm;
            int rhytm;
            Rhythm2Int(rhytm, int[]( 1,0,0,0, 0,0,0,1, 1,0,1,0, 0,0,0,0 ))
            float localTime=rhythmSequence(rhytm,worldTime,beat);
            float value=exp(-localTime*3.0); //envelope

            #define DSP3(c)\
                col=mix(vec3(0),col,smoothstep(0.03,0.031,de));\
                col=mix(c,col,smoothstep(0.02,0.021,de));

            float de;
            vec2 q=vec2(atan(p.x,p.y), length(p)-0.3);
            q.y=opRepLim(q.y,0.2,floor(5.0*value));
            float s=TAU/4.0;
            q.x=mod(q.x,s)-0.5*s;
            q.x-=clamp(q.x,-0.2,0.2);
            de=length(q);
            DSP3(mix(vec3(1),vec3(1,0,0),0.8))
            float h=length(p)*0.5;
            for(float i=0.0;i<3.0;i++) p=abs(p)-0.2/exp2(i);
            if(p.x<p.y) p=p.yx;
            p.x-=clamp(p.x,0.0,value);
            de=length(p);
            DSP3(hsv(h,0.7,0.8))
            break;
        }
    }

    // デバック用の文字列
    // わざわざブロックを作ってる理由は、ブロック内だと変数名が重複しても、エラーが出ず、ブロック内の変数名が優先されるからです。
    // デバック用なので、気楽に書きたいからですね。
    {
        vec2 p=gl_FragCoord.xy/resolution.y;
        p*=50.0;

        #define ALF(a) idx=A[a];de=min(de,de16Seg(p,idx));p.x-=2.0;
        #define NUM(a) idx=N[a];de=min(de,de16Seg(p,idx));p.x-=2.0;
        #define DSP(c)\
            col=mix(vec3(0),col,smoothstep(0.4,0.41,de));\
            col=mix(c,col,smoothstep(0.15,0.16,de));

        int idx;
        float de=2.0;
        p-=vec2(35,10);
        ALF(_S)ALF(_C)ALF(_E)ALF(_N)ALF(_E)
        DSP(vec3(1))
        p.x-=1.0;
        de=2.0;
        NUM(SceneID)
        DSP(vec3(1,0,0))
        p-=vec2(-13,-4);
        de=2.0;
        ALF(_L)ALF(_O)ALF(_C)ALF(_A)ALF(_L)
        p.x-=1.0;
        ALF(_T)ALF(_I)ALF(_M)ALF(_E)
        DSP(vec3(1))
        p.x-=1.0;
        de=2.0;
        NUM(int(TIME)/10)
        NUM(int(TIME)%10)
        DSP(vec3(1,0,0))
        de=2.0;
        ALF(_S)
        DSP(vec3(1))
    }

    fragColor=vec4(col,1);
}

shadertoyに置いたサンプル
neortに置いたサンプル

16
7
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
16
7