インテリアマッピング(interior mapping)~1m³距離編~
昨日
本記事は Unity #2 Advent Calendar 2020 の 24日目 の記事です。
前日の記事は @madoramu_f さん の「【Unity】3Dオブジェクトの残像処理」です!
qiita.com
残像と聞くと、学生時代にトランザムシステムをゲームに組み込むのが流行ってたのを思い出されます。
これがあればいつでも トランザムは使うなよ! 演出が使えるようになりそうですね(?)
はじめに
本記事では、インテリアマッピングの実装について簡単にまとめてみました。
前回(2018年のアドカレ)で内積の性質を使った壁との距離計算でインテリアマッピングを実装しましたが、
今回は1m³立方の壁との距離計算を行う実装の解説になります。例のごとく備忘録のようなものになります。
Cubemapでテクスチャをマッピングすることを前提とした方法となっております。
前回の記事
coposuke.hateblo.jp
目次
インテリアマッピング(Interior Mapping)
インテリアマッピングとは、その名の通りビル等の建築物の室内装飾表現に用いられる描画表現です。
前回の実装もシンプルなものでしたが、今回の実装もとてもシンプルです。
今回の完成図
完成品もあるので興味のある方はご覧ください。
github.com
実装
前回と共通の考え方
インテリアマッピングでは「視点/視線」によって変化します。
視線の先は必ず壁、床、天井のいずれかの面に向けられますが、
どの面が手前に描画されるのかは、それぞれの 距離 を求めることにより分かります。
つまり視線と各面の交点を求め、手前にある面の色を描画することで奥行きが表現されます。
天井/床/壁までの距離計算
前回はベクトルの内積の「直行するベクトルの内積=0」という性質を使って、
特定の位置/向きの平面との距離を算出しました。
今回はそれよりもシンプルに、1m³(立方メートル)の空間の中で、
視線ベクトルの3軸をそれぞれ壁に届くように引き延ばした場合、どの3軸の壁と距離が近いかを算出します。
1m先の壁までの 視線の長さ を求める
3軸ではイメージしづらいので、ひとまず2軸(XY)で考えてみます。
斜めに進む 1mの視線ベクトル が XY軸の壁に向いている状態を考えます。
右の図のように、視線がXY軸の壁に衝突するポイントは2つあります(赤い丸)。
衝突までの距離は レイの開始地点から壁までの最短距離 / 視線ベクトル
で視線ベクトル何個分で届くのか?という計算になります。
視線ベクトルは正規化されて1mとなっており、実質 壁までの距離となります
(視線ベクトル何個分?=1mものさし何個分?=距離何m?)。
つまり、レイの開始地点を(0, 0)
とした場合、下記のような形で距離を求めることが出来ます。
X軸の壁:1.0 / rayDir.x
Y軸の壁:1.0 / rayDir.y
シェーダーでは1.0 / rayDir
で各要素で個別に除算してくれるのでまとめちゃいます。
これで、XYZ軸それぞれの壁までの距離が計算されるわけですが(図の2つの赤い丸)、
一番近い壁の距離が取れればOKなので、min
で1軸に絞ります(図のX軸の壁に当たってる赤い丸)。
float3 dist3 = 1.0 / rayDir;
float dist = min(min(dist.x, dist.y), dist.z);
早速シェーダを用意していきましょう。
Project に 右クリックで Create > Shader でシェーダファイルを作成して、
中身を全部消して、下記を張り付けていきます。
Shader "Custom/InteriorMapping(Cubemap)" { Properties { } CGINCLUDE #include "UnityCG.cginc" ENDCG SubShader { Tags { "RenderType"="Opaque" } Pass { CGPROGRAM #pragma target 2.0 #pragma vertex vert #pragma fragment frag struct v2f { float4 pos : SV_POSITION; float3 rayDir : TEXCOORD0; }; v2f vert(appdata_tan i) { float3 worldPos = mul(unity_ObjectToWorld, i.vertex).xyz; v2f o; o.pos = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); o.rayDir = worldPos - _WorldSpaceCameraPos.xyz; return o; } half4 frag(v2f i) : SV_TARGET { float3 rayDir = normalize(i.rayDir); float3 dist3 = 1.0 / rayDir; float dist = min(min(dist3.x, dist3.y), dist3.z); return float4(float3(dist, dist, dist) * 0.5, 1); } ENDCG } } }
o.rayDir = worldPos - _WorldSpaceCameraPos.xyz;
はnormalize()
しないようにご注意ください。
ベクトルの大きさを消してしまうと、ラスタ化時に正しく線形補間されません。
とりあえず、マテリアルを作成してシェーダを割り当て、Quadを置いてマテリアルを割り当ててみましょう。
しかしまだ正しく表示されていない状態です!
残りmの壁までの 視線の長さ を求める
表示が崩れている理由は、レイの開始地点が考慮されておらず、それぞれ1m先の壁に向かって計算しているからです。
壁までの最短距離がレイの開始地点によって異なるので、事前に最短距離を計算しておきます。
この図を見ると、UV.x
の場所から見ると、X軸の壁までUV.x
メートル近づいている状態にあります。
つまり、X軸の壁までの最短距離は1.0 - UV.x
になります。
求めるべきは 視線べクトル が壁に衝突する 長さ(距離) なので、
壁に衝突するまでの距離は(1.0 - UV) / rayDir.xy
ということになります。
struct v2f { float4 pos : SV_POSITION; float3 rayDir : TEXCOORD0; float2 uv : TEXCOORD1; // 追加 }; v2f vert(appdata_tan i) { float3 worldPos = mul(unity_ObjectToWorld, i.vertex).xyz; v2f o; o.uv = i.texcoord; // 追加 o.pos = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); o.rayDir = worldPos - _WorldSpaceCameraPos.xyz; return o; } half4 frag(v2f i) : SV_TARGET { float3 uvw = float3(i.uv, 0.0); // 追加 float3 rayDir = normalize(i.rayDir); float3 dist3 = (1.0 - uvw) / rayDir; // 修正 float dist = min(min(dist3.x, dist3.y), dist3.z); return float4(float3(dist, dist, dist) * 0.5, 1); }
すると!ちょっとだけそれっぽい描画がされます。
ここでは 視線ベクトルが壁にぶつかる距離 を色の明暗で描画しています。
暗い場所は近く、明るい場所は遠いことを示しています。
負数での計算
どうして右上だけしか描画されていないの?と思われるかと思いますが、
これは 正の向き の 視線ベクトル でしか考慮できていないからです。
では 負の向き ではどのような計算になるか考えてみます。
視線ベクトルのX軸成分だけ 負の向き にしてみました。
正の向きでは 1.0 の壁に向かう1.0 - UV.x
がX軸の最短距離でしたが、
負の向きでは 0.0 の壁に向かうUV.x
がX軸の最短距離となることが分かるかと思います。
ただし、UV.x / rayDir.x
だと正数 / 負数
で答えが負数になってしまうので、
-UV.x / rayDir.x
となるように計算してあげます。なので…
正の向き: (1.0 - UV) / rayDir
負の向き: (0.0 - UV) / rayDir
となればバッチリ距離が計算できそうです。
正の向きでは1.0
、負の向きでは0.0
になるものといえば…!
GLSL/HLSLではstep(v, x)
関数の出番です!
step(v, x) = (v < x) ? 1.0 : 0.0
こんな感じで値が返ってきます(雑)。
これらを踏まえて、どちらの向きでも対応できるようにするには、
(step(0.0, rayDir) - UV) / rayDir
となります。
half4 frag(v2f i) : SV_TARGET { ... float3 dist3 = (step(0.0, rayDir) - uvw) / rayDir; // 修正 float dist = min(min(dist3.x, dist3.y), dist3.z); return float4(float3(dist, dist, dist) * 0.5, 1); }
今度はそれっぽい見た目に仕上がりました!
角が際立っているので、平面っぽいものが見えますね!
テクスチャを貼る
この平面に2Dのテクスチャを貼っていきます。
まず先に2Dのテクスチャを準備しておきます。
Properties { _Interior2DTex("Interior 2D Texture", 2D) = "white" // 追加 } CGINCLUDE #include "UnityCG.cginc" #include "Noise.cginc" sampler2D _Interior2DTex; // 追加 ENDCG
先程の計算で、視線ベクトルの壁に衝突する距離が分かったので
視線開始座標 + 視線ベクトル × 距離
で壁に衝突した座標を算出します!
float3 rayHit = uvw + rayDir * dist;
視線ベクトルが壁に衝突した座標ということは、XYZ軸のいずれかが1.0
か0.0
に限りなく近い数字になったということです。
このままだと扱いづらいので、衝突した座標を|rayHit - 0.5|× 2.0
と計算して1.0
に的をしぼります。
float3 rayHitNormal = abs(rayHit - 0.5) * 2.0;
そして、先程登場したstep(v,x)
関数で1.0
未満の部分は0.0
にすることで 面の法線 がゲットできます。
下記にある1e-3
は0.001
のことで、衝突した座標が必ずしも1.0
とならない為、若干しきい値を広げています。
float3 rayHitNormal = step(1.0 - 1e-3, abs(rayHit - 0.5) * 2.0);
法線は極端にXYZ軸のいずれかが1.0
となっているので、それに合わせてマッピングするUVを作成します
half4 frag(v2f i) : SV_TARGET { ... //return float4(float3(dist, dist, dist) * 0.5, 1); float3 rayHit = uvw + rayDir * dist; float3 rayHitNormal = step(1.0 - 1e-3, abs(rayHit - 0.5) * 2.0); float2 texture2DUV = {0,0}; texture2DUV += rayHitNormal.x * rayHit.zy; // X軸壁に衝突 texture2DUV += rayHitNormal.y * rayHit.xz; // Y軸壁に衝突 texture2DUV += rayHitNormal.z * rayHit.xy; // Z軸壁に衝突 return tex2D(_Interior2DTex, texture2DUV); }
かなりそれっぽい絵になりました!もはや完成といってよいでしょう!
いや…角が汚いですね。
テクスチャによりけりですが、気になるという人向けにいい感じになる処理を考えました。
// 角のジャギー対策 float3 c = { 0,0,0 }; if (1.0 - 1e-3 <= rayHitNormal.x) c = tex2D(_Interior2DTex, rayHit.zy); else if (1.0 - 1e-3 <= rayHitNormal.y) c = tex2D(_Interior2DTex, rayHit.xz); else if (1.0 - 1e-3 <= rayHitNormal.z) c = tex2D(_Interior2DTex, rayHit.xy); return float4(c, 1.0);
( ´・ω・`).。oO(何故かrayHitの値をifで分岐してもダメだった…苦し紛れの tex2D の if分岐)
テクスチャを貼る(余談)
折角なので、前回(~その1~)で使用したテクスチャをマッピングしてみます。
床/天井/壁の区分に沿ったテクスチャとなっております。
絵素材は「建築パース.com様」のものを使用しております。
本記事にて再配布しておりますが、停止する可能性もございます。
ご利用の際はご注意ください。
kenchiku-pers.com
テクスチャの区分に合わせてUVを加工していくだけなので、ソースを貼り付けます。
const float Tex2DTileSize = 1.0 / 4.0; // 4x4 の 16枚 float2 texture2DUV = {0,0}; texture2DUV += rayHitNormal.x * Tex2DTileSize * (rayHit.zy + float2(2, 0)) ; // X軸壁に衝突 texture2DUV += rayHitNormal.y * Tex2DTileSize * (rayHit.xz + float2(0, 2 * step(0.5, rayHit.y))); // Y軸壁に衝突 texture2DUV += rayHitNormal.z * Tex2DTileSize * (rayHit.xy + float2(2, 0)); // Z軸壁に衝突 return tex2D(_Interior2DTex, texture2DUV);
階層
インテリアマップの一番の魅力は、大量の部屋の内装を描画できるところにあります!
ということで、階層を付けていきますが、前回(~その1~)と比べると格段に簡単です。
今回は壁がどこにあるか?というのをUVを基準にして計算しています。
なので、階層を作る=UVタイリングするということです。
half4 frag(v2f i) : SV_TARGET { float3 uvw = float3(frac(i.uv * 4.0), 0.0); // 修正 ...
frac(v)
関数は小数部を返す関数です。i.uv * 4.0
とすることで、4つタイリングしています。
floor(v)
関数を使えば整数部が取れるので、「ランダムで回転や奥行きを変えたい!」といったことがやりやすいですね!
接空間で計算する
現在Scene上にQuadを配置して実装しているところですが、インテリアマッピングの醍醐味は Cube 等の立方体でやることにあります。
しかし、QuadからCubeに差し替えても正しく表示されません!
その理由は、視線ベクトルはワールド空間で計算されているのに、視線開始座標は接空間で計算されているからです。
Quadで問題なく表示されていたのは実は偶然で、おおよそワールド空間と接空間が似ている Mesh だったからなのです。
接空間とは
接空間とは、ポリゴンの表面を中心に、UV.x(U)の向き=+X軸、UV.y(V)の向き=+Y軸、法線の向き=+Z軸とした空間のことです。
一番大事なポイントは UV軸=XY軸 という特徴です。
UV軸=XY軸とすることで、ポリゴンの表面の向きが真正面になるので、法線の向きに依存する描画は基本的に情報が扱いやすくなります。今回のインテリアマッピングの場合は、X軸1m/Y軸1m/Z軸1mといったUV軸および法線の向きに依存した描画なので、接空間で計算を行うことが必要となっております。
ポリゴン表面の法線の向きが正面(+Z軸)なので、左手系のUnityからすると左右が反転している(鏡映)のですが、
むしろ前後が反転している空間と思えばスムーズに理解が進むかと思います。
自分はその空間の見え方を考えてしまうタイプなのですが、同じタイプの方向けに言葉を変えると、
ポリゴンの表面上に 仰向けに寝ている人の視点を中心とした空間です。ただし、前述にもあった通り左右反転していることに注意です。
接空間の向きに関して、数学的な定義と厳密には違いますが、CGで用いられる接空間はこのような認識で大丈夫だと思います。
ちなみに手持ちの本では、頂点座標空間 や サーフェス座標空間 といった呼び方もしていました。
接空間へ変換する回転行列の作成
接空間に関しては長文となってしまいましたので、こちらにて詳しい解説をしておりますので、よろしければご覧ください。
coposuke.hateblo.jp
ワールド空間 ⇔ 接空間 の変換行列の作り方は次の通りです。
// ワールド空間にしておく float3 normal = UnityObjectToWorldNormal(i.normal); float3 tangent = mul(unity_ObjectToWorld, i.tangent.xyz); float3 binormal = cross(normal, tangent) * i.tangent.w * unity_WorldTransformParams.w; // ワールド空間 → 接空間 の変換行列 float3x3 worldToTangentMatrix = float3x3(tangent.xyz, binormal, normal); tangentVector = mul(worldToTangentMatrix, worldVector); // 接空間 → ワールド空間 の変換行列 float3x3 tangentToWorldMatrix = float3x3(tangent.x, binormal.x, normal.x, tangent.y, binormal.y, normal.y, tangent.z, binormal.z, normal.z); float3x3 tangentToWorldMatrix = transpose(worldToTangentMatrix); worldVector = mul(tangentToWorldMatrix, tangnetVector);
早速このプログラムを組み込んでいきます。
v2f vert(appdata_tan i) { float3 worldPos = mul(unity_ObjectToWorld, i.vertex).xyz; v2f o; o.pos = mul(UNITY_MATRIX_VP, float4(worldPos, 1.0)); o.uv = i.texcoord; // 接空間の姿勢(ローカル空間上の向き)→ ワールド空間にしておく float3 normal = UnityObjectToWorldNormal(i.normal); float3 tangent = mul(unity_ObjectToWorld, i.tangent.xyz); float3 binormal = cross(normal, tangent) * i.tangent.w * unity_WorldTransformParams.w; // 視線算出(ワールド空間) o.rayDir = worldPos - _WorldSpaceCameraPos.xyz; // 視線を ワールド空間 → 接空間 に変換 float3x3 toTangentMatrix = float3x3(tangent.xyz, binormal, normal); o.rayDir = mul(toTangentMatrix, o.rayDir); o.rayDir.z *= -1; // Z軸の法線の方を正としちゃうので反転させる // 視線を ワールド空間 → 接空間 に変換(こちらの方が若干スマート) //o.rayDir = float3(dot(tangent.xyz, o.rayDir), dot(binormal, o.rayDir), dot(normal, o.rayDir)); //o.rayDir.z *= -1; return o; }
視線ベクトルを接空間に変換した際、Z軸+の方向が法線の方向なので反転させています。
これでいい感じに見えるようになりました!
Unityには
TANGENT_SPACE_ROTATION
というマクロが存在します。
ただ扱いづらいし可読性低いので使用はおすすめしませんが、一応紹介まで。。
ワールド空間で計算する
先程【視線ベクトルはワールド空間で計算されているのに、視線開始座標は接空間で計算されている】とお伝えいたしましたが、
視線開始座標をワールド空間(ローカル空間)にすることで見栄えを調整することも可能です。
接空間では、UVとXY軸が一致していたため視線開始座標のXYを直接調整していたのですが、
シンプルにピクセル位置のワールド座標(あるいはローカル座標)を視線開始地点とすれば、うまく描画されます。
struct v2f { float4 pos : SV_POSITION; float3 rayDir : TEXCOORD0; float2 uv : TEXCOORD1; float3 rayPos : TEXCOORD2; // 追加 }; v2f vert(appdata_tan i) { ... // 接空間に変換する箇所は全てコメントアウト ... o.rayPos = worldPos; // どちらか追加 o.rayPos = i.vertex; // どちらか追加 return o; } half4 frag(v2f i) : SV_TARGET { float3 uvw = frac(i.rayPos * 4.0 + i.rayDir * 1e-3); // 修正 float3 rayDir = normalize(i.rayDir); ... }
frac(i.rayPos * 4.0)
にて、ワールド空間(ローカル空間)上をレイの開始地点となりました。
frac()
を用いて1m立方空間をタイリングしています。さりげなく加算されているi.rayDir * 1e-3)
は Cube の表面が壁との瀬戸際でチラツキが発生するので、若干レイを前方に進めています。
これで1m(1Unit)中に4つタイリングされたものが描画されるようになりました。
接空間で計算したものとは違い、真上や真下から覗くと天井および床が見えるようになりました。
おまけ(Cubemapを貼る)
先程は Texture2D を結構無理やり貼りつけましたが…
この計算方法の一番優れているところは、Cubemapを貼りやすいところにあります!(ただし接空間計算のみ)
視線開始座標 + 視線ベクトル × 距離
で壁に衝突した座標を算出するところは先程と同じです。
float3 rayHit = uvw + rayDir * dist;
この rayHit を色として表示すると、次のようになります。
左下前が(0.0, 0.0, 0.0)
右上奥が(1.0 ,1.0 ,1.0)
となっているのが分かるかと思います。これをそれぞれ…
左下前が(-1.0, -1.0, -1.0)
右上奥が(1.0 ,1.0 ,1.0)
となればCubemapのUVとなるので、次のように加工します。
float3 cubemapUV = (rayDir * dist + uvw - 0.5) * 2.0;
これで準備が整いました!早速Cubemapを貼りつけましょう。
絵素材は「Pixexid様(cmetric様)」のものを使用しております。
本記事にて再配布しておりますが、停止する可能性もございます。
ご利用の際はご注意ください。
pixexid.com
Properties { _Interior2DTex("Interior 2D Texture", 2D) = "white" _InteriorCubemapTex("Interior Cubemap Texture", CUBE) = "white" // 追加 } CGINCLUDE #include "UnityCG.cginc" sampler2D _Interior2DTex; UNITY_DECLARE_TEXCUBE(_InteriorCubemapTex); // 追加 ENDCG SubShader { half4 frag(v2f i) : SV_TARGET { ... // 衝突場所の算出 float3 rayHit = uvw + rayDir * dist; // テクスチャ(2D)の箇所は全てコメントアウト ... // テクスチャ(Cubemap) float3 cubemapUV = (rayHit - 0.5) * 2.0; // 追加 return UNITY_SAMPLE_TEXCUBE(_InteriorCubemapTex, cubemapUV); // 追加 } }
いいかんじですね!
最後にもうちょっとテクスチャに合わせて見栄えを調整します。
Properties { _Interior2DTex("Interior 2D Texture", 2D) = "white" _InteriorCubemapTex("Interior Cubemap Texture", CUBE) = "white" _InteriorCubemapTex_Z("Interior Cubemap Texture Z Tile&Offset", Vector) = (1,1,0,0) // 追加 } CGINCLUDE #include "UnityCG.cginc" sampler2D _Interior2DTex; UNITY_DECLARE_TEXCUBE(_InteriorCubemapTex); float4 _InteriorCubemapTex_ST; // 追加 float3 _InteriorCubemapTex_Z; // 追加 ENDCG SubShader { half4 frag(v2f i) : SV_TARGET { ... // テクスチャ(Cubemap) float3 cubemapUV = (rayHit - 0.5) * 2.0; cubemapUV.xy *= _InteriorCubemapTex_ST.xy; // 追加 cubemapUV.xy += _InteriorCubemapTex_ST.zw; // 追加 cubemapUV.z *= _InteriorCubemapTex_Z.x; // 追加 cubemapUV.z += _InteriorCubemapTex_Z.z; // 追加 return UNITY_SAMPLE_TEXCUBE(_InteriorCubemapTex, cubemapUV); } }
ただの自己満足です!
おまけ(オブジェクトを置く)
前回(~その1~)で人や物を配置していたので、今回もやってみました。
接空間のみですのでご注意ください。
half4 frag(v2f i) : SV_TARGET { ... // テクスチャ(2D) const float Tex2DTileSize = 1.0 / 4.0; // 4x4 の 16枚 float2 texture2DUV = {0,0}; ... // 人物/物(接空間のみ) float distPlane = 0.5 / rayDir.z; // 追加 float3 rayHitPlane = uvw + rayDir * distPlane; // 追加 texture2DUV += Tex2DTileSize * (rayHitPlane.xy + float2(2, 2)); // 追加 float4 planeColor = tex2D(_Interior2DTex, texture2DUV); // 追加 if (0.5 < planeColor.a && distPlane < dist) // 追加 return planeColor; // 追加 // テクスチャ(Cubemap) ... }
Z軸の0.5
との距離を計算し、テクスチャの色を取得しています。
テクスチャの色が透明じゃなかったら、すぐに色を返すような処理にしています。
疲れてきたのでif文を使っていますが、
lerp(cubemapColor, planeColor, step(0.5, planeColor.x) * step(distplane, dist))
の方が処理速度が速いかもです。
そんなに注視する場所に使うテクニックじゃないので、コスパ悪いと思われます。
おまけ(部屋のサイズと奥行き)
最後に部屋のサイズと奥行きの調整を実装して終わりにしたいと思います。(接空間のみ)
Properties { _Interior2DTex("Interior 2D Texture", 2D) = "white" _InteriorCubemapTex("Interior Cubemap Texture", CUBE) = "white" _InteriorCubemapTex_Z("Interior Cubemap Texture Z Tile&Offset", Vector) = (1,1,0,0) _InteriorRoomSize("Interior Room Size", Vector) = (1,1,1,0) // 追加 } CGINCLUDE #include "UnityCG.cginc" sampler2D _Interior2DTex; UNITY_DECLARE_TEXCUBE(_InteriorCubemapTex); float4 _InteriorCubemapTex_ST; float3 _InteriorCubemapTex_Z; float3 _InteriorRoomSize; // 追加 ENDCG SubShader { half4 frag(v2f i) : SV_TARGET { float3 uvw = float3(frac(i.uv * _InteriorRoomSize.xy), -_InteriorRoomSize.z + 1.0); float3 rayDir = normalize(i.rayDir); ... // 衝突場所の算出 float3 rayHit = uvw + rayDir * dist; rayHit.z = (rayHit.z + _InteriorRoomSize.z - 1.0) / _InteriorRoomSize.z; ... } }
XY軸のサイズはタイリングしていたところを調節します。
奥行きは視線開始位置を手前(-Z)にしてあげて、rayHit.z
の長さを0.0~1.0の範囲になるように調整しています。
エネルギーを使い果たしたので、本記事はここまでになります!
最後まで読んでいただいてありがとうございます!!
まとめ
いかがでしたでしょうか?
前回は オブジェクト空間 で計算したマッピング方法でしたが、
今回は 接空間 で計算することを前提としたマッピング方法でした。
ん~今回のやり方の方が可読性も高いし内容もスマートですねぇ。
UVでタイリングしているので、部屋毎に奥行きを変えられるのは大きいと感じました。
ただ、Texture2Dを貼り付ける場合は部屋の隅に気を遣う必要があるので、
Cubemapの絵の上から、絵画/ポスターやドア等の壁の装飾を描くという使い方が良いかもしれないです。(隅はα=0となりやすいので)
参考資料
明日
明日は @taptappun さん の「何か書く」です!
何が書かれるのかとても楽しみです!
早くも明日で最終日なんですね…!1か月経つのが本当に早い…