✂大きく叩きつけるNEWSTAMPDOWN
字が大きめの姿で現れ、勢いよく元の大きさへ縮んで、紙に押す判のように決まります。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/24 追加自作メソッド(GLSL・JavaScriptの画像メソッド)と、歌詞の文字の動き(コードモーション・かんたん設定)のサンプルです。毎日少しずつ増えます(いまは 228個、最後に増えたのは 10/24)。カードにマウスを載せる(スマホはタップ)と動きます。
崩れ方・グラフィック(GLSL):「1枚モード」の「ぐちゃぐちゃにする」か、シーケンスの崩れ方の最後にある「+自作メソッド」を開き、「GLSLで作る」を選んで、「プリセット…」から選ぶとコードが入ります。
画像・立体・物理(JavaScript)と文字モーション(コード):メソッド工房のサンプルブロックから選びます。コードを書き換えて、自分のメソッドとして追加できます。
文字の動き(かんたん設定):シーケンスの「05 歌詞・曲」で歌詞の行を開き、「動き」から選びます。
字が大きめの姿で現れ、勢いよく元の大きさへ縮んで、紙に押す判のように決まります。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/24 追加
ぼやけて少し大きかった字が、一文字ずつ時間差でくっきりとピントが合います。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/24 追加
行の字が少しずつ遅れて上下に揺れ、波に乗っているように読めます。
STUDIOのおまかせ「✂ カット割り」の部品(STAY・保持)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/24 追加
退場する字が左から順に縦へ押しつぶされ、一本の線になって消えます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/24 追加
画面全体がカットの頭で少し傾き、ばねのように揺れながら水平へ戻ります。
STUDIOのおまかせ「✂ カット割り」の部品(CAM・カメラ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/24 追加
カットの頭で絵全体が大きくぼけ、すぐにくっきりとピントが戻ります。
STUDIOのおまかせ「✂ カット割り」の部品(FX・画面効果)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/24 追加
斜めに傾いた細い帯が順に開き、前の絵から次の絵へ入れ替わります。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/24 追加
文字を囲む四隅に細いかぎ括弧が外から寄ってきて、ぴたっと止まります。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/24 追加
一文字ずつ元の枠に切り抜かれ、小さな点から字の形へ広がって現れます。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/23 追加
退場する字が一文字ずつ小さな角片に変わり、向きを変えながら外へ散ります。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/23 追加
文字の外側に細い帯が伸びて二つの輪をつくり、中央で小さく結ばれます。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/23 追加
文字を囲む細い糸を小さな縫い目が順に進み、最後に一周つなぎます。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/23 追加
画面全体が三度に分かれて少しずつ近づき、それぞれの間で短く止まります。
STUDIOのおまかせ「✂ カット割り」の部品(CAM・カメラ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/23 追加
一つの角から斜めの折り目が広がり、折り返すように次の絵へ切り替わります。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/23 追加
文字が少しずつ違う間をあけながら順に現れます。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
行の中央の一字だけがアクセント色に染まり、静かに明滅します。
STUDIOのおまかせ「✂ カット割り」の部品(STAY・保持)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
行の中央から字が左右へほどけて、端へ向かって薄く消えます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
字の外側に細い二重の輪と目盛りが回り、計器のような気配を添えます。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
画面全体が縦に小さくゆれ、ゆっくり元の高さへ戻ります。
STUDIOのおまかせ「✂ カット割り」の部品(CAM・カメラ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
言葉を上段と下段へ分け、細い枠で順番に読み進められるようにします。
STUDIOのおまかせ「✂ カット割り」の部品(STAGE・文字を載せる台)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
カットの頭で赤と青緑の像が左右にずれ、すぐに重なって元の色へ戻ります。
STUDIOのおまかせ「✂ カット割り」の部品(FX・画面効果)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
カットの頭で絵が横長の帯に切れ、帯ごとに左右へずれてから元の位置へ戻ります。
STUDIOのおまかせ「✂ カット割り」の部品(FX・画面効果)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
カットの頭で絵の拡大した残像が重なって広がり、すっと元の大きさへ収まります。
STUDIOのおまかせ「✂ カット割り」の部品(FX・画面効果)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
カットの頭で、細い光の帯が画面を上から下へ走り、通ったあとに淡い明るさが残ります。
STUDIOのおまかせ「✂ カット割り」の部品(FX・画面効果)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
欠けた三つの不揃いな輪が、文字のまわりを別々の速さで巡ります。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
小さな星が文字の外周を巡り、近づくたびに大きく瞬きます。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
輪状の細い線が傾いて回り、脈動しながら手前を太く奥を細く見せます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
輪のらせんが奥から手前へ進み、遠近に合わせて細さと大きさを変えます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/22 追加
縦横の細線が交互にくぐり、織物のような格子をゆっくり組み替えます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/21 追加
四角い段枠が中心から順に広がり、線の間隔を保って流れていきます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/21 追加
山形の輪郭が重なって奥へ進み、遠近のリズムをゆっくり描きます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/21 追加
斜めの手描き線が数コマごとに描き替わり、細かな揺れをつくります。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/21 追加
透ける光の柱の間を、小さな泡がゆっくり昇っていきます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/21 追加
文字の外側に小さな四角い点が並び、端から順に静かに現れます。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/21 追加
文字の層が浅い弧を描いて左右へ往復し、中央で向きを切り替えます。
STUDIOのおまかせ「✂ カット割り」の部品(CAM・カメラ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/21 追加
市松模様の小窓が順に開き、次の絵へすき間なく切り替わります。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/21 追加
字が近くの字と場所を入れ替えるように動き、元の並びへ戻ります。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
字が行の中心へ渦を描いて寄り、小さくなりながら消えていきます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
文字の下に手描きの矢印が現れ、数コマごとに線を描き替えて震えます。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
細い稲妻が上から字の近くへ落ち、短く光って消えます。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
半透明の黒い球を細い光の軌道が巡り、下の絵を残したまま回ります。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
星形かハート形の窓が広がり、その輪の中だけ画面の色を反転します。
STUDIOのおまかせ「✂ カット割り」の部品(FX・画面効果)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
六角形の線が蜂の巣のように並び、波が渡るように濃淡がゆれます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
三角形のタイルが敷き詰められ、ところどころが順に淡く光ります。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
二組の同心円が逆向きにゆっくり動いて、重なりに縞模様が生まれます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
半円のうろこを重ねた和柄が、波のきらめきのように淡く明るさを変えます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
小さな点が黄金角でうずを巻いて並び、ゆっくり回りながら呼吸します。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
斜めの細い線が交差してひし形の格子をつくり、ゆるやかに流れていきます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/20 追加
字が上の細い線から、ブラインドを下ろすように下へ向かって開いていきます。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
字が看板の電球のように、順にずれながら明るく太くなったり戻ったりします。
STUDIOのおまかせ「✂ カット割り」の部品(STAY・保持)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
一字だけがその場に残り、ほかの字は左右へ離れて消え、最後にその字も消えます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
字が縦に細くつぶれて一本の線になり、横へ流れながら消えていきます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
文字の下に細い弦が張られ、弾かれて波になって震え、やがて静まります。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
細い縦のバーが、音の波形のように決まった揺れで高さを変えながら並びます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
文字全体が画面の下から勢いよく駆け上がり、ふっと止まります。
STUDIOのおまかせ「✂ カット割り」の部品(CAM・カメラ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
前の絵の上にハート形の窓が広がり、窓の向こうから次の絵へ切り替わります。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
文字の上に小さな星が順に現れ、細い線で結ばれて静かに消えます。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
やわらかな光の輪が画面を横切り、絵の色を残したまま通り過ぎます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
4行3列の小さな区画が順に淡く光り、次の区画へ移ります。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/19 追加
字ごとに細い輪郭が現れ、中がアクセント色に閃いて元の色へ戻ります。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/18 追加
ほとんど静かな行の中で、選ばれた一字だけが小さく明滅します。
STUDIOのおまかせ「✂ カット割り」の部品(STAY・保持)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/18 追加
字の下が伸びて雨だれのように垂れ、ぼけながら消えていきます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/18 追加
字が小さな跳ねを二度重ねながら、少し横へ進んで消えていきます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/18 追加
細い輪と十字が文字の外から近づき、行の中心で静かに止まります。
STUDIOのおまかせ「✂ カット割り」の部品(DECO・飾り)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/18 追加
細い縦線が細かく震え、中央のすき間がゆっくり広がって戻ります。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/18 追加
前の絵の上に星形の窓が広がり、窓の向こうから次の絵へ切り替わります。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/18 追加
全部の字が行の真ん中に重なって現れ、元の位置へ広がります。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
駅の案内板のように、字が1つずつ上から下へめくれて現れます。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
字が行の真ん中へ寄りながら小さくなって、吸い込まれるように消えます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
字が少しずつ時間をずらして上へ昇り、ゆれながら薄くなって消えます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
語ごとに順番に、心臓の鼓動のように小さく大きくなります。
STUDIOのおまかせ「✂ カット割り」の部品(STAY・保持)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
色の小さな紙が、ひらひらと回りながら上から降ってきます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
文字全体が、小さな円を描くように画面の中をゆっくり回ります。
STUDIOのおまかせ「✂ カット割り」の部品(CAM・カメラ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
前の絵が手前へ大きく抜けていき、その奥から次の絵が近づいてきます。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
小さな星が画面のあちこちで、ゆっくりまたたきます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
輪だけの泡が、ゆれながら下から上へゆっくり昇っていきます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
入れ子になった四角の線が、少しずつ角度を変えながら回ります。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
古いフィルムのような小さな粒が、こまかく場所を変えてちらつきます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
何本かの細い波線が、ゆるやかに形を変えながら横へ流れます。
STUDIOのおまかせ「✂ カット割り」の部品(BG・背景)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
前の絵が真ん中で上下に割れて開き、その間から次の絵が見えてきます。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
前の絵が四つに割れて四隅へ飛んでいき、奥から次の絵が現れます。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加前の絵が粗いピクセルに崩れ、粗いままの次の絵が細かく戻ってきます。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
横に細く切れた帯が、ずれながら少しずつ次の絵に入れ替わります。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
太い帯が左右から交互にすべり込んで、次の絵に入れ替わります。
STUDIOのおまかせ「✂ カット割り」の部品(TRANS・つなぎ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
字が1つずつ少し遅れて、振り子のように左右に小さく揺れます。
STUDIOのおまかせ「✂ カット割り」の部品(STAY・保持)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
となり合う語が、シーソーのように交互に上がったり下がったりします。
STUDIOのおまかせ「✂ カット割り」の部品(STAY・保持)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
字が左から1つずつ順番に、ぴょんと小さく跳ねていきます。
STUDIOのおまかせ「✂ カット割り」の部品(STAY・保持)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
行が風に吹かれたように、字を少しずつ遅らせて横にゆらぎます。
STUDIOのおまかせ「✂ カット割り」の部品(STAY・保持)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
字が左右から交互に飛び込んできて、元の位置に並びます。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
字が斜めに傾いて現れ、振り子のように揺れながら止まります。
STUDIOのおまかせ「✂ カット割り」の部品(IN・登場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
となり合う字が上と下に分かれて、すっと離れながら消えます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
字がくるくると回りながら小さくなって、順に消えていきます。
STUDIOのおまかせ「✂ カット割り」の部品(OUT・退場)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
文字全体が、心臓の鼓動のように二度ずつ小さく寄っては戻ります。
STUDIOのおまかせ「✂ カット割り」の部品(CAM・カメラ)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
カットの頭で絵全体が横にぐっと伸び、すぐに元の形へ戻ります。
STUDIOのおまかせ「✂ カット割り」の部品(FX・画面効果)です。カット割りで作ると、ほかの部品と一緒に自動で混ざります。
カット割りの部品(STUDIO)10/17 追加
下の中心を軸にワイパーが振れて、通った所の粒が横へ払われていきます。
// 画面の下の真ん中を軸にした、ワイパーの動き。通った所の粒が、振れる向きへ払われる
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
vec2 u = (c - vec2(0., -.5 * uSize.y)) / uSize.y; // 軸(下の真ん中)から見た位置
float turn = atan(u.x, u.y) / 3.1416 + .5; // 左 0 から 右 1 へ、ワイパーが振れる
float delay = turn * .45 + ph(1.) * .05;
float e = smoothstep(delay, delay + .22, amount);
vec2 push = normalize(vec2(u.y, -u.x) + vec2(.0001)) * e * e * (.45 + ph(2.) * .6) * uSize.y;
l = rot((ph(3.) - .5) * 7. * e) * l * (1. - .3 * e);
gKind = 2.; // 粒の見た目: 2 = 丸い点
gAlpha = 1. - .3 * e;
gLocal = 1.;
return vec3(c + l + push, (ph(4.) - .5) * .3 * e);
}
斜めに波が走って、タイルが順にぴょんぴょんと跳ね上がって戻ります。
メソッド工房の「画像・立体・物理」のサンプルから選びます。
// 左下から右上へ斜めに波が走り、タイルが順に跳ねて、少しずつ小さくなる跳ね方で落ち着く。強さ(amount)が戻ると元へ帰る
const {createImageTiles,size,smooth}=api;
const tiles=createImageTiles();
const half=size.width*.5;
return {update(frame){
const t=Math.min(1,frame.amount/.8);
for(const piece of tiles){
const x=piece.home.x,y=piece.home.y,diag=((x/half)+(y/half)+2)/4; // 0 = 左下、1 = 右上
const local=Math.max(0,Math.min(1,t*1.1-diag*.35)); // タイルごとの進み具合
const hop=Math.abs(Math.sin(local*Math.PI*2.5))*(1-.5*local)*half*.22; // 2回半、だんだん低く(強さが最大のときも跳ねている)
const grow=smooth(Math.min(1,local*3))*(1-smooth(Math.max(0,local*2-1)));
piece.mesh.position.set(x,y+hop,hop*.5);
piece.mesh.rotation.set(0,0,Math.sin(local*Math.PI*2.5)*.25*(1-.5*local));
piece.mesh.scale.setScalar(1+.15*grow);
}
}};
水面に映ったように、ゆらゆら揺れながら現れて、静かに止まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 水面に映ったように、上下に揺れて少し傾きながら現れ、だんだん揺れが収まって止まる。退場はまた揺れながら消える
const {createText,text,width,height,duration}=api;
const label=createText(text,{maxWidth:width*.8,textAlign:'center',lineHeight:1.25,outlineWidth:'2%',outlineColor:'#101819'});
const clamp=x=>Math.max(0,Math.min(1,x));
const enter=Math.min(.9,duration*.32),exit=Math.min(.5,duration*.18);
return {update(t){
const a=clamp(t/enter),b=clamp((duration-t)/exit),calm=a*b;
const wobble=(1-calm)*(1-calm); // 揺れの大きさ(止まるほど小さい)
label.position.set(Math.sin(t*5)*width*.015*wobble,Math.sin(t*9)*height*.05*wobble,0);
label.rotation.z=Math.sin(t*7+1)*.14*wobble;
const fade=clamp(t/(enter*.45))*b;
label.fillOpacity=fade;label.outlineOpacity=fade;
}};
中心から輪が広がり、輪が通った所の粒が外へ弾け飛んでいきます。
// 中心から輪が広がって、輪が通った所の粒が外へ弾ける
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
vec2 u = c / uSize.y; // 縦を1とした座標(中心が原点)
float r = length(u); // 中心からの距離
float delay = r * .55 + ph(1.) * .05; // 中心に近い粒から先に弾ける
float e = smoothstep(delay, delay + .22, amount);
vec2 dir = normalize(u + vec2(.0001));
vec2 q = c + l * (1. - .3 * e) + dir * e * e * (.5 + ph(2.) * .7) * uSize.y;
gKind = 2.; // 粒の見た目: 2 = 丸い点
gAlpha = 1. - .3 * e;
gLocal = 1.;
return vec3(q, e * (ph(3.) - .5) * .3);
}
同心円のリングごとに、タイルが交互に逆向きへ回って、また戻ります。
メソッド工房の「画像・立体・物理」のサンプルから選びます。
// 中心からの距離でリングに分けて、リングごとに交互に逆向きへ回る。強さ(amount)が戻ると元の位置へ帰る
const {createImageTiles,size,smooth}=api;
const tiles=createImageTiles();
const half=size.width*.5;
return {update(frame){
const t=Math.min(1,frame.amount/.8);
for(const piece of tiles){
const x=piece.home.x,y=piece.home.y,r=Math.hypot(x,y)/half;
const ring=Math.floor(r*4.5); // 外へ向かって 0, 1, 2 … のリング
const dir=ring%2===0?1:-1;
const k=smooth(Math.max(0,Math.min(1,t*1.5-ring*.12)));
const turn=dir*k*(.45+.08*ring),c=Math.cos(turn),s=Math.sin(turn);
piece.mesh.position.set(x*c-y*s,x*s+y*c,0);
piece.mesh.rotation.set(0,0,turn);
piece.mesh.scale.setScalar(1-.1*k);
}
}};
ストロボのように点いたり消えたりを繰り返して、最後に点いたままになります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 点滅しながら、だんだん点いている時間が長くなって、最後は点きっぱなしになる。退場は点滅して消える
const {createText,text,width,duration}=api;
const label=createText(text,{maxWidth:width*.8,textAlign:'center',lineHeight:1.25,outlineWidth:'2%',outlineColor:'#101819'});
const clamp=x=>Math.max(0,Math.min(1,x));
const enter=Math.min(.9,duration*.3),exit=Math.min(.45,duration*.15);
return {update(t){
const a=clamp(t/enter),b=clamp((duration-t)/exit);
const phase=(t*13)%1,onIn=phase<.25+.75*a,onOut=((duration-t)*13)%1<.25+.75*b; // 点いている割合が 25% から 100% へ
const on=(a>=1||onIn)&&(b>=1||onOut)&&t>0.02&&t<duration-.02; // 行の前と後ろでは必ず消える
const flash=on?1:0;
label.fillOpacity=flash;label.outlineOpacity=flash;
label.scale.setScalar(1+(on&&a<1?.05*(1-a):0));
}};
市松模様のマスが縮みながら、交互に上と下へずれて崩れていきます。
// 絵を12×12のマスに分け、市松模様の片方は上へ、もう片方は下へ、縮みながらずらす
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC();
vec2 id = floor((c / uSize + .5) * 12.); // マスの番号
float odd = mod(id.x + id.y, 2.); // 市松模様の 0 / 1
vec2 mid = ((id + .5) / 12. - .5) * uSize; // マスの真ん中
float delay = hash(id) * .25;
float e = smoothstep(delay, delay + .3, amount);
vec2 q = mid + (p - mid) * (1. - .55 * e); // マスごとに縮む
q.y += (odd * 2. - 1.) * e * uSize.y * .45; // 片方は上、もう片方は下
gKind = 2.; // 粒の見た目: 2 = 丸い点
gAlpha = 1. - .25 * e;
gLocal = 1.;
return vec3(q, 0.);
}
タイルが列ごとに速さを変えて、縦に伸びながら下へ垂れていきます。
メソッド工房の「画像・立体・物理」のサンプルから選びます。
// タイルが列ごとに別の速さで、縦に伸びながら下へ垂れる。強さ(amount)が戻ると元の位置へ帰る
const {createImageTiles,size,random,smooth}=api;
const tiles=createImageTiles();
const half=size.width*.5;
const speed=new Map(); // 列ごとの乱数(作成時に1回だけ決める)
for(const piece of tiles){const key=Math.round(piece.home.x);if(!speed.has(key))speed.set(key,.6+random()*.9)}
return {update(frame){
const t=Math.min(1,frame.amount/.8);
for(const piece of tiles){
const x=piece.home.x,y=piece.home.y,v=speed.get(Math.round(x));
const top=(y+half*.56)/(half*1.12); // 上のタイルほど遅れて垂れる(0 = 下、1 = 上)
const k=smooth(Math.max(0,Math.min(1,t*v*1.6-(1-top)*.35)));
piece.mesh.position.set(x,y-k*half*.5*v,0);
piece.mesh.scale.set(1-.25*k,1+1.1*k*v,1);
}
}};
ぼやけて広がった分身が、中心に寄ってピントが合うように現れます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 周りに散ったうすい分身が中心へ寄って、ピントが合う。退場はもう一度ぼやけて消える
const {createText,text,width,duration}=api;
const opts={maxWidth:width*.8,textAlign:'center',lineHeight:1.25,outlineWidth:'2%',outlineColor:'#101819'};
const main=createText(text,opts);
const ghosts=[];for(let i=0;i<6;i++)ghosts.push(createText(text,{...opts,colorRanges:null}));
const clamp=x=>Math.max(0,Math.min(1,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.3),exit=Math.min(.5,duration*.18);
return {update(t){
const a=smooth(t/enter),b=smooth((duration-t)/exit),blur=(1-a)+(1-b),fade=smooth(t/(enter*.4))*b;
const r=width*.035*blur;
ghosts.forEach((g,i)=>{const ang=i/6*Math.PI*2;g.position.set(Math.cos(ang)*r,Math.sin(ang)*r,-1);g.fillOpacity=fade*.3*Math.min(1,blur*2);g.outlineOpacity=0});
main.fillOpacity=fade*(.35+.65*a*b);main.outlineOpacity=fade*a*b;
}};
文字が1つずつ上から落ちて、縦書きの列が右から左へ積み上がります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 縦書きの列が右から左へ。文字が1つずつ上から落ちて積まれ、長い歌詞は複数列になる。大きい語は大きく、助詞は小さく積む
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,bpm,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.6*x)*Math.cos(6.8*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const W=Math.max(1,width),H=Math.max(1,height),life=Math.max(.05,duration);
const em0=Math.max(8,fontSize||H*.18);
const outro=Math.min(.5,life*.2);
const root=new THREE.Group();scene.add(root);
const fixed=new THREE.Group();scene.add(fixed);
let accCol;try{accCol=new THREE.Color(accent||'#ffd166')}catch(e){accCol=new THREE.Color('#ffd166')}
const NL=String.fromCharCode(10),CR=String.fromCharCode(13);
const cc=c=>c.charCodeAt(0);
const isHira=c=>cc(c)>=0x3041&&cc(c)<=0x309f;
const SMALLSET=['は','が','を','に','の','で','と','も','へ','や','か','よ','ね','な','だ','わ','さ','ぞ','ぜ','です','ます','でした','ました','から','まで','より','けど','ので','のに','だよ','よね','かな','って','たい','ない','だね','たよ'];
const CLOSE='、。,.,.!?!?…」』))~〜—・';
const OPEN='「『((';
const isSmall=s=>{const a=Array.from(s).filter(c=>CLOSE.indexOf(c)<0&&OPEN.indexOf(c)<0);if(!a.length)return true;if(!a.every(isHira))return false;return a.length<=2||SMALLSET.indexOf(a.join(''))>=0};
const cw=c=>{const n=cc(c);if(n===32)return .3;if(n<128)return .64;return 1};
// 語の分割。Intl.Segmenter があればそれを使い、なければ文字種の変わり目で割る
function segmentLine(line){
const out=[];
if(typeof Intl!=='undefined'&&Intl.Segmenter){for(const p of new Intl.Segmenter('ja',{granularity:'word'}).segment(line))out.push({a:p.index,b:p.index+p.segment.length});return out}
const kind=c=>{const n=cc(c);if(n===32)return 9;if(isHira(c))return 0;if(n>=0x30a0&&n<=0x30ff)return 2;if(n<128)return 3;return 1};
let a=0;for(let i=1;i<=line.length;i++){if(i===line.length||kind(line[i])!==kind(line[i-1])){out.push({a,b:i});a=i}}
return out;
}
function runScale(start,len){
let off=0,sum=0,cnt=0;const end=start+len;
for(const r of (runs||[])){const n=String(r.text||'').length,a=Math.max(off,start),b=Math.min(off+n,end);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);cnt+=b-a}
off+=n;if(off>=end)break}
return cnt?sum/cnt:1;
}
function sizeWord(w,k){
w.k=k;const rs=Math.max(1,runScale(w.start,w.s.length));
w.wu=Array.from(w.s).reduce((a,c)=>a+cw(c),0)*k*rs*(rs>1?1.14:1.05);w.hu=k*rs;
}
const wordList=(()=>{
const rows=String(text||'').split(CR).join('').split(NL);
let off=0;const list=[];
rows.forEach((line,row)=>{
const ws=[];let pre=-1;
for(const t of segmentLine(line)){
const s=line.slice(t.a,t.b);if(!s.trim())continue;
const ch=Array.from(s);
if(ws.length&&ch.every(c=>CLOSE.indexOf(c)>=0)){ws[ws.length-1].b=t.b;continue}
if(ch.every(c=>OPEN.indexOf(c)>=0)){if(pre<0)pre=t.a;continue}
ws.push({a:pre>=0?pre:t.a,b:t.b});pre=-1;
}
if(pre>=0)ws.push({a:pre,b:line.length});
for(const w of ws)list.push({s:line.slice(w.a,w.b),start:off+w.a,row});
off+=line.length+1;
});
for(const w of list){w.n=Array.from(w.s).length;w.small=isSmall(w.s)}
if(list.length===1&&list[0].n>=2){
const w=list[0],a=Array.from(w.s),h=Math.ceil(a.length/2),f=a.slice(0,h).join(''),r=a.slice(h).join('');
list.length=0;
list.push({s:f,start:w.start,row:w.row,n:h,small:false},{s:r,start:w.start+f.length,row:w.row,n:a.length-h,small:true});
}
while(list.length>60){const out=[];for(let i=0;i<list.length;i+=2){const x=list[i],y=list[i+1];out.push(y?{s:text.slice(x.start,y.start+y.s.length),start:x.start,row:x.row,n:x.n+y.n,small:false}:x)}list.length=0;out.forEach(o=>list.push(o))}
for(const w of list)sizeWord(w,w.small?.52:(w.n===1?1.22:1));
return list;
})();
// 長すぎる語を半分ずつに割る
function splitLong(list,max){
const out=[];
for(const w of list){
if(w.n<=max){out.push(w);continue}
const a=Array.from(w.s),parts=Math.ceil(a.length/max),per=Math.ceil(a.length/parts);let off=0;
for(let i=0;i<a.length;i+=per){const s=a.slice(i,i+per).join('');const p={s,start:w.start+off,row:w.row,n:Array.from(s).length,small:false};off+=s.length;sizeWord(p,w.k);out.push(p)}
}
return out;
}
function makeMeshes(items,parent){
for(const w of items){
const m=createText(w.s,{textStart:w.start,fontSize:em0*w.k,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
m.fillOpacity=0;m.outlineOpacity=0;m.visible=false;(parent||root).add(m);w.m=m;
}
}
function put(m,x,y,rot,sx,sy,a){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.003}
function rect(parent,col,z){
const m=new THREE.Mesh(new THREE.PlaneGeometry(1,1),new THREE.MeshBasicMaterial({color:col||accCol,transparent:true,opacity:0,depthWrite:false}));
m.position.z=z===undefined?-1:z;m.renderOrder=-1;m.visible=false;(parent||root).add(m);return m;
}
function bar(m,cx,cy,w,h,a,rot){m.position.x=cx;m.position.y=cy;m.scale.set(Math.max(.001,w),Math.max(.001,h),1);m.rotation.z=rot||0;m.material.opacity=Math.max(0,a);m.visible=a>.003&&w>.01&&h>.01}
function breakLines(items,n,gap){
const tot=items.reduce((a,it,i)=>a+it.wu+(i?gap:0),0),cap=tot/n*1.1;
const lines=[];let cur=[],x=0,prev=null;
for(const it of items){
const left=n-lines.length-1;
const rowBreak=prev&&it.row!==prev.row&&cur.length&&x>=cap*.45&&left>0;
if(cur.length&&left>0&&(x+gap+it.wu>cap||rowBreak)){lines.push(cur);cur=[];x=0}
x+=(cur.length?gap:0)+it.wu;cur.push(it);prev=it;
}
lines.push(cur);return lines;
}
// 行の数をいろいろ試して、いちばん大きく収まる折り返しを選ぶ。s は em0 に対する倍率
function bestWrap(items,o){
o=o||{};const gap=.3,lh=1.25;
const boxW=o.boxW||W*.88,boxH=o.boxH||H*.8,cos=Math.abs(Math.cos(o.rot||0)),sin=Math.abs(Math.sin(o.rot||0));
const nmin=Math.min(o.minLines||1,items.length),nmax=Math.max(nmin,Math.min(o.maxLines||6,items.length));
let best=null;
for(let n=nmin;n<=nmax;n++){
const lines=breakLines(items,n,gap);
const lw=lines.map(l=>l.reduce((a,it,i)=>a+it.wu+(i?gap:0),0));
const lhh=lines.map(l=>Math.max(...l.map(it=>it.hu))*lh);
const w=Math.max(...lw.map((x,j)=>x+j*(o.indent||0)))+(o.extraW||0),h=Math.max(lhh.reduce((a,b)=>a+b,0),o.minH||0);
const bw=w*cos+h*sin,bh=w*sin+h*cos;
const s=Math.min(boxW/(bw*em0),boxH/(bh*em0),o.cap||1.8);
if(!best||s>best.s*1.03)best={lines,lw,lhh,w,h,s};
}
return best;
}
// 行ごとに語の中心 (cx,cy) を px で決める(基準線にそろえる)。返り値は行ごとの情報
function placeLines(b,align,xOff){
const gap=.3,meta=[];let top=b.h/2;
b.lines.forEach((l,j)=>{
const mh=Math.max(...l.map(it=>it.hu)),lw=b.lw[j],bottom=top-mh;
const x0=(align==='left'?-b.w/2:align==='right'?b.w/2-lw:-lw/2)+(xOff?(xOff[j]||0):0);
let x=x0;
for(const it of l){it.cx=(x+it.wu/2)*em0;it.cy=(bottom+it.hu/2)*em0;it.line=j;x+=it.wu+gap}
meta.push({x0:x0*em0,x1:(x0+lw)*em0,bottom:bottom*em0,top:top*em0,lw:lw*em0,mh:mh*em0});
top-=b.lhh[j];
});
const dy=-(meta[0].top+meta[meta.length-1].bottom)/2;
for(const l of b.lines)for(const it of l)it.cy+=dy;
for(const m of meta){m.top+=dy;m.bottom+=dy}
return meta;
}
function vInfo(c){
if('ー~〜—…-([「」『』())]'.indexOf(c)>=0)return {rot:-Math.PI/2,ox:0,oy:0};
if('、。,.,.'.indexOf(c)>=0)return {rot:0,ox:.34,oy:.34};
if('ぁぃぅぇぉっゃゅょァィゥェォッャュョ'.indexOf(c)>=0)return {rot:0,ox:.1,oy:.1};
return {rot:0,ox:0,oy:0};
}
let dead=false;
const kill=o=>{[...o.children].forEach(kill);if(typeof o.dispose==='function')o.dispose();else{if(o.geometry)o.geometry.dispose();if(o.material)o.material.dispose()}};
function disposeAll(){if(dead)return;dead=true;kill(root);kill(fixed);scene.remove(root);scene.remove(fixed)}
if(!wordList.length)return {update(){},dispose(){disposeAll()}};
const items=wordList;
let total=0;for(const w of items)total+=w.n;
const PL=total>110?2:1;
for(const w of items){
w.vk=w.small?.66:(w.n===1?1.15:1);w.pc=[];
const a=Array.from(w.s);let off=0;
for(let i=0;i<a.length;i+=PL){
const s=a.slice(i,i+PL).join(''),rs=Math.max(1,runScale(w.start+off,s.length));
w.pc.push({s,start:w.start+off,k:w.vk,wu:Array.from(s).reduce((x,c)=>x+cw(c),0)*w.vk*rs*(rs>1?1.14:1.05),hu:w.vk*rs*(PL>1?.9:1),row:w.row,vi:PL===1?vInfo(s):{rot:0,ox:0,oy:0}});
off+=s.length;
}
}
const sumH=items.reduce((a,w)=>a+w.pc.reduce((b,p)=>b+p.hu,0),0);
const maxH1=Math.max(...items.map(w=>Math.max(...w.pc.map(p=>p.hu))));
function flowCols(capH){
const cols=[{p:[],h:0,w:0}];let prevRow=items[0].row;
const cur=()=>cols[cols.length-1],nl=()=>cols.push({p:[],h:0,w:0});
for(const w of items){
const wh=w.pc.reduce((a,p)=>a+p.hu,0);
if(cur().p.length&&(w.row!==prevRow||cur().h+wh>capH))nl();
prevRow=w.row;
for(const p of w.pc){if(cur().p.length&&cur().h+p.hu>capH+1e-6)nl();cur().p.push(p);cur().h+=p.hu;cur().w=Math.max(cur().w,p.wu)}
}
return cols;
}
const boxW=W*.88,boxH=H*.8,GAP=.5;
let best=null;
for(let n=1;n<=8;n++){
const cols=flowCols(Math.max(maxH1,sumH/n*1.12));
const tw=cols.reduce((a,c,i)=>a+Math.max(c.w,.9)+(i?GAP:0),0)+GAP,mh=Math.max(...cols.map(c=>c.h))+.9;
const s=Math.min(boxW/(tw*em0),boxH/(mh*em0),1.9);
if(!best||s>best.s*1.03)best={cols,tw,mh,s};
}
const cols=best.cols,TW=best.tw,MH=best.mh-.9;
root.scale.setScalar(best.s);
const flat=[];
let xr=TW/2-GAP/2;
cols.forEach((c,ci)=>{
const pitch=Math.max(c.w,.9);c.cx=(xr-pitch/2)*em0;c.pitch=pitch*em0;xr-=pitch+GAP;
let y=MH/2;
for(const p of c.p){p.cx=c.cx;p.cy=(y-p.hu/2)*em0;y-=p.hu;p.col=ci;p.idx=flat.length;flat.push(p)}
});
for(const p of flat){
const m=createText(p.s,{textStart:p.start,fontSize:em0*p.k,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
m.fillOpacity=0;m.outlineOpacity=0;m.visible=false;root.add(m);p.m=m;
}
const N=flat.length,spread=Math.min(life*.3,N*.07);
flat.forEach(p=>{p.t0=N>1?spread*p.idx/(N-1):0});
// 罫線と、列の頭の印
const deco=cols.map(c=>{
const first=c.p[0],last=c.p[c.p.length-1];
return {c,rule:rect(root),dot:rect(root),a:first.t0,b:last.t0+.3};
});
const dotSize=em0*.17;
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life),q=smooth((life-t)/outro);
for(const p of flat){
const lt=t-p.t0,k=clamp(lt/.3),bt=Math.max(0,lt-.3);
const sq=lt>=.3?Math.exp(-10*bt)*Math.cos(28*bt):0;
const sway=Math.sin(t*1.5+p.col*1.3)*em0*.025;
const drop=(1-k*k)*em0*1.9,rise=(1-q)*em0*.9;
put(p.m,p.cx+p.vi.ox*em0*p.k,p.cy+drop+rise+sway+p.vi.oy*em0*p.k,p.vi.rot,1+.14*sq,1-.2*sq,smooth(lt/.08)*q);
}
for(const d of deco){
const g=smooth((t-d.a)/Math.max(.2,d.b-d.a)),h=(MH+.4)*em0,w=Math.max(1.2,em0*.03);
bar(d.rule,d.c.cx-d.c.pitch/2-GAP*em0*.5,MH/2*em0+.2*em0-h*g/2+Math.sin(t*1.5+d.c.p[0].col*1.3)*0,w,h*g,.45*q*(g>0?1:0));
const pulse=1+.2*Math.sin(t*4+d.c.p[0].col);
bar(d.dot,d.c.cx,(MH/2+.5)*em0,dotSize*pulse,dotSize*pulse,smooth((t-d.a+.15)/.2)*q,Math.PI/4);
}
}
update(0);
return {update,dispose(){disposeAll()}};
語が左上から右下へ階段のようにずれて、語ごとに大きさを変えて並びます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語が左上から右下へ階段状に並び、意味のある語は大きく、助詞は小さく置かれる。行ごとに下線と印が付く
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,bpm,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.6*x)*Math.cos(6.8*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const W=Math.max(1,width),H=Math.max(1,height),life=Math.max(.05,duration);
const em0=Math.max(8,fontSize||H*.18);
const outro=Math.min(.5,life*.2);
const root=new THREE.Group();scene.add(root);
const fixed=new THREE.Group();scene.add(fixed);
let accCol;try{accCol=new THREE.Color(accent||'#ffd166')}catch(e){accCol=new THREE.Color('#ffd166')}
const NL=String.fromCharCode(10),CR=String.fromCharCode(13);
const cc=c=>c.charCodeAt(0);
const isHira=c=>cc(c)>=0x3041&&cc(c)<=0x309f;
const SMALLSET=['は','が','を','に','の','で','と','も','へ','や','か','よ','ね','な','だ','わ','さ','ぞ','ぜ','です','ます','でした','ました','から','まで','より','けど','ので','のに','だよ','よね','かな','って','たい','ない','だね','たよ'];
const CLOSE='、。,.,.!?!?…」』))~〜—・';
const OPEN='「『((';
const isSmall=s=>{const a=Array.from(s).filter(c=>CLOSE.indexOf(c)<0&&OPEN.indexOf(c)<0);if(!a.length)return true;if(!a.every(isHira))return false;return a.length<=2||SMALLSET.indexOf(a.join(''))>=0};
const cw=c=>{const n=cc(c);if(n===32)return .3;if(n<128)return .64;return 1};
// 語の分割。Intl.Segmenter があればそれを使い、なければ文字種の変わり目で割る
function segmentLine(line){
const out=[];
if(typeof Intl!=='undefined'&&Intl.Segmenter){for(const p of new Intl.Segmenter('ja',{granularity:'word'}).segment(line))out.push({a:p.index,b:p.index+p.segment.length});return out}
const kind=c=>{const n=cc(c);if(n===32)return 9;if(isHira(c))return 0;if(n>=0x30a0&&n<=0x30ff)return 2;if(n<128)return 3;return 1};
let a=0;for(let i=1;i<=line.length;i++){if(i===line.length||kind(line[i])!==kind(line[i-1])){out.push({a,b:i});a=i}}
return out;
}
function runScale(start,len){
let off=0,sum=0,cnt=0;const end=start+len;
for(const r of (runs||[])){const n=String(r.text||'').length,a=Math.max(off,start),b=Math.min(off+n,end);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);cnt+=b-a}
off+=n;if(off>=end)break}
return cnt?sum/cnt:1;
}
function sizeWord(w,k){
w.k=k;const rs=Math.max(1,runScale(w.start,w.s.length));
w.wu=Array.from(w.s).reduce((a,c)=>a+cw(c),0)*k*rs*(rs>1?1.14:1.05);w.hu=k*rs;
}
const wordList=(()=>{
const rows=String(text||'').split(CR).join('').split(NL);
let off=0;const list=[];
rows.forEach((line,row)=>{
const ws=[];let pre=-1;
for(const t of segmentLine(line)){
const s=line.slice(t.a,t.b);if(!s.trim())continue;
const ch=Array.from(s);
if(ws.length&&ch.every(c=>CLOSE.indexOf(c)>=0)){ws[ws.length-1].b=t.b;continue}
if(ch.every(c=>OPEN.indexOf(c)>=0)){if(pre<0)pre=t.a;continue}
ws.push({a:pre>=0?pre:t.a,b:t.b});pre=-1;
}
if(pre>=0)ws.push({a:pre,b:line.length});
for(const w of ws)list.push({s:line.slice(w.a,w.b),start:off+w.a,row});
off+=line.length+1;
});
for(const w of list){w.n=Array.from(w.s).length;w.small=isSmall(w.s)}
if(list.length===1&&list[0].n>=2){
const w=list[0],a=Array.from(w.s),h=Math.ceil(a.length/2),f=a.slice(0,h).join(''),r=a.slice(h).join('');
list.length=0;
list.push({s:f,start:w.start,row:w.row,n:h,small:false},{s:r,start:w.start+f.length,row:w.row,n:a.length-h,small:true});
}
while(list.length>60){const out=[];for(let i=0;i<list.length;i+=2){const x=list[i],y=list[i+1];out.push(y?{s:text.slice(x.start,y.start+y.s.length),start:x.start,row:x.row,n:x.n+y.n,small:false}:x)}list.length=0;out.forEach(o=>list.push(o))}
for(const w of list)sizeWord(w,w.small?.52:(w.n===1?1.22:1));
return list;
})();
// 長すぎる語を半分ずつに割る
function splitLong(list,max){
const out=[];
for(const w of list){
if(w.n<=max){out.push(w);continue}
const a=Array.from(w.s),parts=Math.ceil(a.length/max),per=Math.ceil(a.length/parts);let off=0;
for(let i=0;i<a.length;i+=per){const s=a.slice(i,i+per).join('');const p={s,start:w.start+off,row:w.row,n:Array.from(s).length,small:false};off+=s.length;sizeWord(p,w.k);out.push(p)}
}
return out;
}
function makeMeshes(items,parent){
for(const w of items){
const m=createText(w.s,{textStart:w.start,fontSize:em0*w.k,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
m.fillOpacity=0;m.outlineOpacity=0;m.visible=false;(parent||root).add(m);w.m=m;
}
}
function put(m,x,y,rot,sx,sy,a){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.003}
function rect(parent,col,z){
const m=new THREE.Mesh(new THREE.PlaneGeometry(1,1),new THREE.MeshBasicMaterial({color:col||accCol,transparent:true,opacity:0,depthWrite:false}));
m.position.z=z===undefined?-1:z;m.renderOrder=-1;m.visible=false;(parent||root).add(m);return m;
}
function bar(m,cx,cy,w,h,a,rot){m.position.x=cx;m.position.y=cy;m.scale.set(Math.max(.001,w),Math.max(.001,h),1);m.rotation.z=rot||0;m.material.opacity=Math.max(0,a);m.visible=a>.003&&w>.01&&h>.01}
function breakLines(items,n,gap){
const tot=items.reduce((a,it,i)=>a+it.wu+(i?gap:0),0),cap=tot/n*1.1;
const lines=[];let cur=[],x=0,prev=null;
for(const it of items){
const left=n-lines.length-1;
const rowBreak=prev&&it.row!==prev.row&&cur.length&&x>=cap*.45&&left>0;
if(cur.length&&left>0&&(x+gap+it.wu>cap||rowBreak)){lines.push(cur);cur=[];x=0}
x+=(cur.length?gap:0)+it.wu;cur.push(it);prev=it;
}
lines.push(cur);return lines;
}
// 行の数をいろいろ試して、いちばん大きく収まる折り返しを選ぶ。s は em0 に対する倍率
function bestWrap(items,o){
o=o||{};const gap=.3,lh=1.25;
const boxW=o.boxW||W*.88,boxH=o.boxH||H*.8,cos=Math.abs(Math.cos(o.rot||0)),sin=Math.abs(Math.sin(o.rot||0));
const nmin=Math.min(o.minLines||1,items.length),nmax=Math.max(nmin,Math.min(o.maxLines||6,items.length));
let best=null;
for(let n=nmin;n<=nmax;n++){
const lines=breakLines(items,n,gap);
const lw=lines.map(l=>l.reduce((a,it,i)=>a+it.wu+(i?gap:0),0));
const lhh=lines.map(l=>Math.max(...l.map(it=>it.hu))*lh);
const w=Math.max(...lw.map((x,j)=>x+j*(o.indent||0)))+(o.extraW||0),h=Math.max(lhh.reduce((a,b)=>a+b,0),o.minH||0);
const bw=w*cos+h*sin,bh=w*sin+h*cos;
const s=Math.min(boxW/(bw*em0),boxH/(bh*em0),o.cap||1.8);
if(!best||s>best.s*1.03)best={lines,lw,lhh,w,h,s};
}
return best;
}
// 行ごとに語の中心 (cx,cy) を px で決める(基準線にそろえる)。返り値は行ごとの情報
function placeLines(b,align,xOff){
const gap=.3,meta=[];let top=b.h/2;
b.lines.forEach((l,j)=>{
const mh=Math.max(...l.map(it=>it.hu)),lw=b.lw[j],bottom=top-mh;
const x0=(align==='left'?-b.w/2:align==='right'?b.w/2-lw:-lw/2)+(xOff?(xOff[j]||0):0);
let x=x0;
for(const it of l){it.cx=(x+it.wu/2)*em0;it.cy=(bottom+it.hu/2)*em0;it.line=j;x+=it.wu+gap}
meta.push({x0:x0*em0,x1:(x0+lw)*em0,bottom:bottom*em0,top:top*em0,lw:lw*em0,mh:mh*em0});
top-=b.lhh[j];
});
const dy=-(meta[0].top+meta[meta.length-1].bottom)/2;
for(const l of b.lines)for(const it of l)it.cy+=dy;
for(const m of meta){m.top+=dy;m.bottom+=dy}
return meta;
}
function vInfo(c){
if('ー~〜—…-([「」『』())]'.indexOf(c)>=0)return {rot:-Math.PI/2,ox:0,oy:0};
if('、。,.,.'.indexOf(c)>=0)return {rot:0,ox:.34,oy:.34};
if('ぁぃぅぇぉっゃゅょァィゥェォッャュョ'.indexOf(c)>=0)return {rot:0,ox:.1,oy:.1};
return {rot:0,ox:0,oy:0};
}
let dead=false;
const kill=o=>{[...o.children].forEach(kill);if(typeof o.dispose==='function')o.dispose();else{if(o.geometry)o.geometry.dispose();if(o.material)o.material.dispose()}};
function disposeAll(){if(dead)return;dead=true;kill(root);kill(fixed);scene.remove(root);scene.remove(fixed)}
if(!wordList.length)return {update(){},dispose(){disposeAll()}};
const items=wordList;
const IND=1.1,PAD=.6;
const best=bestWrap(items,{minLines:2,maxLines:5,indent:IND,extraW:PAD,boxH:H*.78});
const meta=placeLines(best,'left',best.lines.map((_,j)=>j*IND+PAD));
root.scale.setScalar(best.s);
makeMeshes(items);
const N=items.length,spread=Math.min(life*.28,N*.11);
items.forEach((w,i)=>{w.t0=N>1?spread*i/(N-1):0});
const deco=meta.map((m,j)=>({m,line:best.lines[j],ul:rect(root),mk:rect(root)}));
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life),q=smooth((life-t)/outro);
for(const w of items){
const lt=t-w.t0,u=clamp(lt/.5),sp=spring(u),out=1-q;
const sway=Math.sin(t*1.4+w.line*1.1)*em0*.03;
const x=w.cx+(1-sp)*-em0*2.6+out*em0*1.8+sway;
const y=w.cy+(1-sp)*em0*1.4-out*out*em0*.9;
put(w.m,x,y,0,1,1,smooth(lt/.12)*q);
}
for(const d of deco){
const t0=d.line[0].t0,g=smooth((t-t0)/.45),m=d.m;
const th=Math.max(1.5,em0*.05);
bar(d.ul,m.x0+m.lw*g/2,m.bottom-em0*.1,m.lw*g,th,.7*q);
const pop=spring((t-t0)/.35),sz=em0*.2*pop;
bar(d.mk,m.x0-em0*.36,m.bottom+m.mh*.5,sz,sz,q*smooth((t-t0)/.1),Math.PI/4*(1-.0));
}
}
update(0);
return {update,dispose(){disposeAll()}};
語が画面の四隅に寄って大小で置かれ、角印と余白が文字を引き立てます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語が画面の四隅(左上→右上→右下→左下)に寄って大小で置かれ、余白を残す。四隅に角印が付く
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,bpm,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.6*x)*Math.cos(6.8*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const W=Math.max(1,width),H=Math.max(1,height),life=Math.max(.05,duration);
const em0=Math.max(8,fontSize||H*.18);
const outro=Math.min(.5,life*.2);
const root=new THREE.Group();scene.add(root);
const fixed=new THREE.Group();scene.add(fixed);
let accCol;try{accCol=new THREE.Color(accent||'#ffd166')}catch(e){accCol=new THREE.Color('#ffd166')}
const NL=String.fromCharCode(10),CR=String.fromCharCode(13);
const cc=c=>c.charCodeAt(0);
const isHira=c=>cc(c)>=0x3041&&cc(c)<=0x309f;
const SMALLSET=['は','が','を','に','の','で','と','も','へ','や','か','よ','ね','な','だ','わ','さ','ぞ','ぜ','です','ます','でした','ました','から','まで','より','けど','ので','のに','だよ','よね','かな','って','たい','ない','だね','たよ'];
const CLOSE='、。,.,.!?!?…」』))~〜—・';
const OPEN='「『((';
const isSmall=s=>{const a=Array.from(s).filter(c=>CLOSE.indexOf(c)<0&&OPEN.indexOf(c)<0);if(!a.length)return true;if(!a.every(isHira))return false;return a.length<=2||SMALLSET.indexOf(a.join(''))>=0};
const cw=c=>{const n=cc(c);if(n===32)return .3;if(n<128)return .64;return 1};
// 語の分割。Intl.Segmenter があればそれを使い、なければ文字種の変わり目で割る
function segmentLine(line){
const out=[];
if(typeof Intl!=='undefined'&&Intl.Segmenter){for(const p of new Intl.Segmenter('ja',{granularity:'word'}).segment(line))out.push({a:p.index,b:p.index+p.segment.length});return out}
const kind=c=>{const n=cc(c);if(n===32)return 9;if(isHira(c))return 0;if(n>=0x30a0&&n<=0x30ff)return 2;if(n<128)return 3;return 1};
let a=0;for(let i=1;i<=line.length;i++){if(i===line.length||kind(line[i])!==kind(line[i-1])){out.push({a,b:i});a=i}}
return out;
}
function runScale(start,len){
let off=0,sum=0,cnt=0;const end=start+len;
for(const r of (runs||[])){const n=String(r.text||'').length,a=Math.max(off,start),b=Math.min(off+n,end);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);cnt+=b-a}
off+=n;if(off>=end)break}
return cnt?sum/cnt:1;
}
function sizeWord(w,k){
w.k=k;const rs=Math.max(1,runScale(w.start,w.s.length));
w.wu=Array.from(w.s).reduce((a,c)=>a+cw(c),0)*k*rs*(rs>1?1.14:1.05);w.hu=k*rs;
}
const wordList=(()=>{
const rows=String(text||'').split(CR).join('').split(NL);
let off=0;const list=[];
rows.forEach((line,row)=>{
const ws=[];let pre=-1;
for(const t of segmentLine(line)){
const s=line.slice(t.a,t.b);if(!s.trim())continue;
const ch=Array.from(s);
if(ws.length&&ch.every(c=>CLOSE.indexOf(c)>=0)){ws[ws.length-1].b=t.b;continue}
if(ch.every(c=>OPEN.indexOf(c)>=0)){if(pre<0)pre=t.a;continue}
ws.push({a:pre>=0?pre:t.a,b:t.b});pre=-1;
}
if(pre>=0)ws.push({a:pre,b:line.length});
for(const w of ws)list.push({s:line.slice(w.a,w.b),start:off+w.a,row});
off+=line.length+1;
});
for(const w of list){w.n=Array.from(w.s).length;w.small=isSmall(w.s)}
if(list.length===1&&list[0].n>=2){
const w=list[0],a=Array.from(w.s),h=Math.ceil(a.length/2),f=a.slice(0,h).join(''),r=a.slice(h).join('');
list.length=0;
list.push({s:f,start:w.start,row:w.row,n:h,small:false},{s:r,start:w.start+f.length,row:w.row,n:a.length-h,small:true});
}
while(list.length>60){const out=[];for(let i=0;i<list.length;i+=2){const x=list[i],y=list[i+1];out.push(y?{s:text.slice(x.start,y.start+y.s.length),start:x.start,row:x.row,n:x.n+y.n,small:false}:x)}list.length=0;out.forEach(o=>list.push(o))}
for(const w of list)sizeWord(w,w.small?.52:(w.n===1?1.22:1));
return list;
})();
// 長すぎる語を半分ずつに割る
function splitLong(list,max){
const out=[];
for(const w of list){
if(w.n<=max){out.push(w);continue}
const a=Array.from(w.s),parts=Math.ceil(a.length/max),per=Math.ceil(a.length/parts);let off=0;
for(let i=0;i<a.length;i+=per){const s=a.slice(i,i+per).join('');const p={s,start:w.start+off,row:w.row,n:Array.from(s).length,small:false};off+=s.length;sizeWord(p,w.k);out.push(p)}
}
return out;
}
function makeMeshes(items,parent){
for(const w of items){
const m=createText(w.s,{textStart:w.start,fontSize:em0*w.k,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
m.fillOpacity=0;m.outlineOpacity=0;m.visible=false;(parent||root).add(m);w.m=m;
}
}
function put(m,x,y,rot,sx,sy,a){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.003}
function rect(parent,col,z){
const m=new THREE.Mesh(new THREE.PlaneGeometry(1,1),new THREE.MeshBasicMaterial({color:col||accCol,transparent:true,opacity:0,depthWrite:false}));
m.position.z=z===undefined?-1:z;m.renderOrder=-1;m.visible=false;(parent||root).add(m);return m;
}
function bar(m,cx,cy,w,h,a,rot){m.position.x=cx;m.position.y=cy;m.scale.set(Math.max(.001,w),Math.max(.001,h),1);m.rotation.z=rot||0;m.material.opacity=Math.max(0,a);m.visible=a>.003&&w>.01&&h>.01}
function breakLines(items,n,gap){
const tot=items.reduce((a,it,i)=>a+it.wu+(i?gap:0),0),cap=tot/n*1.1;
const lines=[];let cur=[],x=0,prev=null;
for(const it of items){
const left=n-lines.length-1;
const rowBreak=prev&&it.row!==prev.row&&cur.length&&x>=cap*.45&&left>0;
if(cur.length&&left>0&&(x+gap+it.wu>cap||rowBreak)){lines.push(cur);cur=[];x=0}
x+=(cur.length?gap:0)+it.wu;cur.push(it);prev=it;
}
lines.push(cur);return lines;
}
// 行の数をいろいろ試して、いちばん大きく収まる折り返しを選ぶ。s は em0 に対する倍率
function bestWrap(items,o){
o=o||{};const gap=.3,lh=1.25;
const boxW=o.boxW||W*.88,boxH=o.boxH||H*.8,cos=Math.abs(Math.cos(o.rot||0)),sin=Math.abs(Math.sin(o.rot||0));
const nmin=Math.min(o.minLines||1,items.length),nmax=Math.max(nmin,Math.min(o.maxLines||6,items.length));
let best=null;
for(let n=nmin;n<=nmax;n++){
const lines=breakLines(items,n,gap);
const lw=lines.map(l=>l.reduce((a,it,i)=>a+it.wu+(i?gap:0),0));
const lhh=lines.map(l=>Math.max(...l.map(it=>it.hu))*lh);
const w=Math.max(...lw.map((x,j)=>x+j*(o.indent||0)))+(o.extraW||0),h=Math.max(lhh.reduce((a,b)=>a+b,0),o.minH||0);
const bw=w*cos+h*sin,bh=w*sin+h*cos;
const s=Math.min(boxW/(bw*em0),boxH/(bh*em0),o.cap||1.8);
if(!best||s>best.s*1.03)best={lines,lw,lhh,w,h,s};
}
return best;
}
// 行ごとに語の中心 (cx,cy) を px で決める(基準線にそろえる)。返り値は行ごとの情報
function placeLines(b,align,xOff){
const gap=.3,meta=[];let top=b.h/2;
b.lines.forEach((l,j)=>{
const mh=Math.max(...l.map(it=>it.hu)),lw=b.lw[j],bottom=top-mh;
const x0=(align==='left'?-b.w/2:align==='right'?b.w/2-lw:-lw/2)+(xOff?(xOff[j]||0):0);
let x=x0;
for(const it of l){it.cx=(x+it.wu/2)*em0;it.cy=(bottom+it.hu/2)*em0;it.line=j;x+=it.wu+gap}
meta.push({x0:x0*em0,x1:(x0+lw)*em0,bottom:bottom*em0,top:top*em0,lw:lw*em0,mh:mh*em0});
top-=b.lhh[j];
});
const dy=-(meta[0].top+meta[meta.length-1].bottom)/2;
for(const l of b.lines)for(const it of l)it.cy+=dy;
for(const m of meta){m.top+=dy;m.bottom+=dy}
return meta;
}
function vInfo(c){
if('ー~〜—…-([「」『』())]'.indexOf(c)>=0)return {rot:-Math.PI/2,ox:0,oy:0};
if('、。,.,.'.indexOf(c)>=0)return {rot:0,ox:.34,oy:.34};
if('ぁぃぅぇぉっゃゅょァィゥェォッャュョ'.indexOf(c)>=0)return {rot:0,ox:.1,oy:.1};
return {rot:0,ox:0,oy:0};
}
let dead=false;
const kill=o=>{[...o.children].forEach(kill);if(typeof o.dispose==='function')o.dispose();else{if(o.geometry)o.geometry.dispose();if(o.material)o.material.dispose()}};
function disposeAll(){if(dead)return;dead=true;kill(root);kill(fixed);scene.remove(root);scene.remove(fixed)}
if(!wordList.length)return {update(){},dispose(){disposeAll()}};
const items=wordList;
const NG=Math.min(4,items.length);
const CORN=[[0],[0,2],[0,1,2],[0,1,2,3]][NG-1]; // 0 左上 1 右上 2 右下 3 左下(時計まわり)
const tot=items.reduce((a,w)=>a+w.wu,0);
const groups=[];
{let i=0,cum=0;
for(let g=0;g<NG;g++){
const cur=[],target=tot*(g+1)/NG;
while(i<items.length){
const left=items.length-i,need=NG-g-1;
if(g<NG-1&&cur.length&&(left<=need||cum+items[i].wu/2>target))break;
cur.push(items[i]);cum+=items[i].wu;i++;
}
groups.push(cur);
}}
const tgtW=W*.4,tgtH=H*.36;
let S=1.7,blocks=[];
for(let it=0;it<40;it++){
const L=tgtW/(em0*S);
blocks=groups.map(g=>{
const lines=[];let cur=[],x=0;
for(const w of g){if(cur.length&&x+.3+w.wu>L){lines.push(cur);cur=[];x=0}x+=(cur.length?.3:0)+w.wu;cur.push(w)}
lines.push(cur);
const lw=lines.map(l=>l.reduce((a,w,i)=>a+w.wu+(i?.3:0),0));
const lh=lines.map(l=>Math.max(...l.map(w=>w.hu))*1.2);
return {g,lines,lw,lh,w:Math.max(...lw),h:lh.reduce((a,b)=>a+b,0)};
});
if(blocks.every(b=>b.w*em0*S<=tgtW*1.001&&b.h*em0*S<=tgtH))break;
S*=.92;
}
root.scale.setScalar(S);
const ax=(W/2-W*.075)/S,ay=(H/2-H*.115)/S;
blocks.forEach((b,bi)=>{
const c=CORN[bi],right=c===1||c===2,bottom=c===2||c===3;
let top=NG===1?b.h*em0/2:(bottom?(-ay+b.h*em0):ay);
b.c=c;b.right=right;b.bottom=bottom;
b.lines.forEach((l,j)=>{
const mh=Math.max(...l.map(w=>w.hu)),lw=b.lw[j]*em0,bot=top-mh*em0;
let x=NG===1?-lw/2:(right?ax-lw:-ax);
for(const w of l){w.cx=x+w.wu*em0/2;w.cy=bot+w.hu*em0/2;w.blk=bi;x+=(w.wu+.3)*em0}
top-=b.lh[j]*em0;
});
});
makeMeshes(items);
const spreadB=Math.min(life*.25,.7);
blocks.forEach((b,bi)=>{b.t0=NG>1?spreadB*bi/(NG-1):0;b.g.forEach((w,i)=>{w.t0=b.t0+i*.06})});
// 画面の四隅の角印と、中心の小さな十字
const arm=Math.min(W,H)*.075,thick=Math.max(1.5,Math.min(W,H)*.007),ix=W*.045,iy=H*.06;
const marks=[];
for(let c=0;c<4;c++){
const sx=(c===1||c===2)?1:-1,sy=(c===0||c===1)?1:-1;
marks.push({sx,sy,h:rect(fixed),v:rect(fixed),on:CORN.indexOf(c)>=0,bi:CORN.indexOf(c)});
}
const crossH=rect(fixed),crossV=rect(fixed);
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life),q=smooth((life-t)/outro);
for(const w of items){
const b=blocks[w.blk],lt=t-w.t0,sp=spring(lt/.55),out=1-q;
const dx=(b.right?1:-1)*em0*2.2,dy=(b.bottom?-1:1)*em0*1.4;
const fl=Math.sin(t*1.2+w.blk*1.7)*em0*.035;
const k=.7+.3*Math.min(1.15,sp);
put(w.m,w.cx+(1-sp)*dx+out*dx*1.5,w.cy+(1-sp)*dy+out*dy*1.5+fl,0,k,k,smooth(lt/.12)*q);
}
for(const m of marks){
const g=smooth((t-(m.on?blocks[m.bi].t0:0))/.4),a=(m.on?.95:.3)*q*(.4+.6*g);
const x=m.sx*(W/2-ix),y=m.sy*(H/2-iy),len=arm*(.4+.6*g);
bar(m.h,x-m.sx*len/2,y,len,thick,a);
bar(m.v,x,y-m.sy*len/2,thick,len,a);
}
const ca=.35*q*smooth(t/.5),cl=Math.min(W,H)*.03;
bar(crossH,0,0,cl,thick,ca);bar(crossV,0,0,thick,cl,ca);
}
update(0);
return {update,dispose(){disposeAll()}};
ひとつの語は縦書きで端に落ち、残りは横書きで並んで、罫線が交差します。
メソッド工房の「文字モーション」のサンプルから選びます。
// 1語は縦書きで左端か右端に落ち、残りは横書きで並ぶ。縦と横の罫線が交差して向きの対比を作る
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,bpm,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.6*x)*Math.cos(6.8*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const W=Math.max(1,width),H=Math.max(1,height),life=Math.max(.05,duration);
const em0=Math.max(8,fontSize||H*.18);
const outro=Math.min(.5,life*.2);
const root=new THREE.Group();scene.add(root);
const fixed=new THREE.Group();scene.add(fixed);
let accCol;try{accCol=new THREE.Color(accent||'#ffd166')}catch(e){accCol=new THREE.Color('#ffd166')}
const NL=String.fromCharCode(10),CR=String.fromCharCode(13);
const cc=c=>c.charCodeAt(0);
const isHira=c=>cc(c)>=0x3041&&cc(c)<=0x309f;
const SMALLSET=['は','が','を','に','の','で','と','も','へ','や','か','よ','ね','な','だ','わ','さ','ぞ','ぜ','です','ます','でした','ました','から','まで','より','けど','ので','のに','だよ','よね','かな','って','たい','ない','だね','たよ'];
const CLOSE='、。,.,.!?!?…」』))~〜—・';
const OPEN='「『((';
const isSmall=s=>{const a=Array.from(s).filter(c=>CLOSE.indexOf(c)<0&&OPEN.indexOf(c)<0);if(!a.length)return true;if(!a.every(isHira))return false;return a.length<=2||SMALLSET.indexOf(a.join(''))>=0};
const cw=c=>{const n=cc(c);if(n===32)return .3;if(n<128)return .64;return 1};
// 語の分割。Intl.Segmenter があればそれを使い、なければ文字種の変わり目で割る
function segmentLine(line){
const out=[];
if(typeof Intl!=='undefined'&&Intl.Segmenter){for(const p of new Intl.Segmenter('ja',{granularity:'word'}).segment(line))out.push({a:p.index,b:p.index+p.segment.length});return out}
const kind=c=>{const n=cc(c);if(n===32)return 9;if(isHira(c))return 0;if(n>=0x30a0&&n<=0x30ff)return 2;if(n<128)return 3;return 1};
let a=0;for(let i=1;i<=line.length;i++){if(i===line.length||kind(line[i])!==kind(line[i-1])){out.push({a,b:i});a=i}}
return out;
}
function runScale(start,len){
let off=0,sum=0,cnt=0;const end=start+len;
for(const r of (runs||[])){const n=String(r.text||'').length,a=Math.max(off,start),b=Math.min(off+n,end);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);cnt+=b-a}
off+=n;if(off>=end)break}
return cnt?sum/cnt:1;
}
function sizeWord(w,k){
w.k=k;const rs=Math.max(1,runScale(w.start,w.s.length));
w.wu=Array.from(w.s).reduce((a,c)=>a+cw(c),0)*k*rs*(rs>1?1.14:1.05);w.hu=k*rs;
}
const wordList=(()=>{
const rows=String(text||'').split(CR).join('').split(NL);
let off=0;const list=[];
rows.forEach((line,row)=>{
const ws=[];let pre=-1;
for(const t of segmentLine(line)){
const s=line.slice(t.a,t.b);if(!s.trim())continue;
const ch=Array.from(s);
if(ws.length&&ch.every(c=>CLOSE.indexOf(c)>=0)){ws[ws.length-1].b=t.b;continue}
if(ch.every(c=>OPEN.indexOf(c)>=0)){if(pre<0)pre=t.a;continue}
ws.push({a:pre>=0?pre:t.a,b:t.b});pre=-1;
}
if(pre>=0)ws.push({a:pre,b:line.length});
for(const w of ws)list.push({s:line.slice(w.a,w.b),start:off+w.a,row});
off+=line.length+1;
});
for(const w of list){w.n=Array.from(w.s).length;w.small=isSmall(w.s)}
if(list.length===1&&list[0].n>=2){
const w=list[0],a=Array.from(w.s),h=Math.ceil(a.length/2),f=a.slice(0,h).join(''),r=a.slice(h).join('');
list.length=0;
list.push({s:f,start:w.start,row:w.row,n:h,small:false},{s:r,start:w.start+f.length,row:w.row,n:a.length-h,small:true});
}
while(list.length>60){const out=[];for(let i=0;i<list.length;i+=2){const x=list[i],y=list[i+1];out.push(y?{s:text.slice(x.start,y.start+y.s.length),start:x.start,row:x.row,n:x.n+y.n,small:false}:x)}list.length=0;out.forEach(o=>list.push(o))}
for(const w of list)sizeWord(w,w.small?.52:(w.n===1?1.22:1));
return list;
})();
// 長すぎる語を半分ずつに割る
function splitLong(list,max){
const out=[];
for(const w of list){
if(w.n<=max){out.push(w);continue}
const a=Array.from(w.s),parts=Math.ceil(a.length/max),per=Math.ceil(a.length/parts);let off=0;
for(let i=0;i<a.length;i+=per){const s=a.slice(i,i+per).join('');const p={s,start:w.start+off,row:w.row,n:Array.from(s).length,small:false};off+=s.length;sizeWord(p,w.k);out.push(p)}
}
return out;
}
function makeMeshes(items,parent){
for(const w of items){
const m=createText(w.s,{textStart:w.start,fontSize:em0*w.k,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
m.fillOpacity=0;m.outlineOpacity=0;m.visible=false;(parent||root).add(m);w.m=m;
}
}
function put(m,x,y,rot,sx,sy,a){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.003}
function rect(parent,col,z){
const m=new THREE.Mesh(new THREE.PlaneGeometry(1,1),new THREE.MeshBasicMaterial({color:col||accCol,transparent:true,opacity:0,depthWrite:false}));
m.position.z=z===undefined?-1:z;m.renderOrder=-1;m.visible=false;(parent||root).add(m);return m;
}
function bar(m,cx,cy,w,h,a,rot){m.position.x=cx;m.position.y=cy;m.scale.set(Math.max(.001,w),Math.max(.001,h),1);m.rotation.z=rot||0;m.material.opacity=Math.max(0,a);m.visible=a>.003&&w>.01&&h>.01}
function breakLines(items,n,gap){
const tot=items.reduce((a,it,i)=>a+it.wu+(i?gap:0),0),cap=tot/n*1.1;
const lines=[];let cur=[],x=0,prev=null;
for(const it of items){
const left=n-lines.length-1;
const rowBreak=prev&&it.row!==prev.row&&cur.length&&x>=cap*.45&&left>0;
if(cur.length&&left>0&&(x+gap+it.wu>cap||rowBreak)){lines.push(cur);cur=[];x=0}
x+=(cur.length?gap:0)+it.wu;cur.push(it);prev=it;
}
lines.push(cur);return lines;
}
// 行の数をいろいろ試して、いちばん大きく収まる折り返しを選ぶ。s は em0 に対する倍率
function bestWrap(items,o){
o=o||{};const gap=.3,lh=1.25;
const boxW=o.boxW||W*.88,boxH=o.boxH||H*.8,cos=Math.abs(Math.cos(o.rot||0)),sin=Math.abs(Math.sin(o.rot||0));
const nmin=Math.min(o.minLines||1,items.length),nmax=Math.max(nmin,Math.min(o.maxLines||6,items.length));
let best=null;
for(let n=nmin;n<=nmax;n++){
const lines=breakLines(items,n,gap);
const lw=lines.map(l=>l.reduce((a,it,i)=>a+it.wu+(i?gap:0),0));
const lhh=lines.map(l=>Math.max(...l.map(it=>it.hu))*lh);
const w=Math.max(...lw.map((x,j)=>x+j*(o.indent||0)))+(o.extraW||0),h=Math.max(lhh.reduce((a,b)=>a+b,0),o.minH||0);
const bw=w*cos+h*sin,bh=w*sin+h*cos;
const s=Math.min(boxW/(bw*em0),boxH/(bh*em0),o.cap||1.8);
if(!best||s>best.s*1.03)best={lines,lw,lhh,w,h,s};
}
return best;
}
// 行ごとに語の中心 (cx,cy) を px で決める(基準線にそろえる)。返り値は行ごとの情報
function placeLines(b,align,xOff){
const gap=.3,meta=[];let top=b.h/2;
b.lines.forEach((l,j)=>{
const mh=Math.max(...l.map(it=>it.hu)),lw=b.lw[j],bottom=top-mh;
const x0=(align==='left'?-b.w/2:align==='right'?b.w/2-lw:-lw/2)+(xOff?(xOff[j]||0):0);
let x=x0;
for(const it of l){it.cx=(x+it.wu/2)*em0;it.cy=(bottom+it.hu/2)*em0;it.line=j;x+=it.wu+gap}
meta.push({x0:x0*em0,x1:(x0+lw)*em0,bottom:bottom*em0,top:top*em0,lw:lw*em0,mh:mh*em0});
top-=b.lhh[j];
});
const dy=-(meta[0].top+meta[meta.length-1].bottom)/2;
for(const l of b.lines)for(const it of l)it.cy+=dy;
for(const m of meta){m.top+=dy;m.bottom+=dy}
return meta;
}
function vInfo(c){
if('ー~〜—…-([「」『』())]'.indexOf(c)>=0)return {rot:-Math.PI/2,ox:0,oy:0};
if('、。,.,.'.indexOf(c)>=0)return {rot:0,ox:.34,oy:.34};
if('ぁぃぅぇぉっゃゅょァィゥェォッャュョ'.indexOf(c)>=0)return {rot:0,ox:.1,oy:.1};
return {rot:0,ox:0,oy:0};
}
let dead=false;
const kill=o=>{[...o.children].forEach(kill);if(typeof o.dispose==='function')o.dispose();else{if(o.geometry)o.geometry.dispose();if(o.material)o.material.dispose()}};
function disposeAll(){if(dead)return;dead=true;kill(root);kill(fixed);scene.remove(root);scene.remove(fixed)}
if(!wordList.length)return {update(){},dispose(){disposeAll()}};
const items=splitLong(wordList,5);
let vi=-1,vn=0;
items.forEach((w,i)=>{if(!w.small&&w.n>vn&&w.n<=5){vn=w.n;vi=i}});
if(vi<0)items.forEach((w,i)=>{if(w.n>vn){vn=w.n;vi=i}});
const vw=items[vi],rest=items.filter((_,i)=>i!==vi);
const RIGHT=random()<.5;
// 縦書きの語
const VK=1.15,vp=[];
{const a=Array.from(vw.s);let off=0;
for(const c of a){const rs=Math.max(1,runScale(vw.start+off,c.length));vp.push({s:c,start:vw.start+off,k:VK,wu:cw(c)*VK*rs*1.05,hu:VK*rs,vi:vInfo(c)});off+=c.length}}
const vwU=Math.max(...vp.map(p=>p.wu)),vH=vp.reduce((a,p)=>a+p.hu,0);
const GAPU=.9,extra=rest.length?vwU+GAPU+.5:0;
let best,meta=[],S;
if(rest.length){
best=bestWrap(rest,{maxLines:3,extraW:extra,minH:vH+.8,boxH:H*.8});
S=best.s;
const shift=(RIGHT?-1:1)*(vwU+GAPU-.5)/2;
meta=placeLines(best,'center',best.lines.map(()=>shift));
}else{S=Math.min(W*.88/(vwU*em0),H*.8/((vH+.8)*em0),1.8)}
root.scale.setScalar(S);
const totW=rest.length?best.w:vwU;
const vx=rest.length?(RIGHT?(totW/2-vwU/2):(-totW/2+vwU/2))*em0:0;
let yy=vH/2;
vp.forEach((p,i)=>{p.cx=vx;p.cy=(yy-p.hu/2)*em0;yy-=p.hu;p.i=i});
makeMeshes(rest);
for(const p of vp){
const m=createText(p.s,{textStart:p.start,fontSize:em0*p.k,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
m.fillOpacity=0;m.outlineOpacity=0;m.visible=false;root.add(m);p.m=m;
}
const nv=vp.length,nr=rest.length,stepV=Math.min(.1,life*.07);
vp.forEach(p=>{p.t0=p.i*stepV});
rest.forEach((w,i)=>{w.t0=.1+Math.min(life*.18,nr*.1)*(nr>1?i/(nr-1):0)});
// 縦の罫と横の罫が交差する
const edgeX=rest.length?(RIGHT?(totW/2-vwU-GAPU*.5):(-totW/2+vwU+GAPU*.5))*em0:0;
const vRule=rect(root),hRule=rect(root),dot=rect(root);
const hY=rest.length?meta[meta.length-1].bottom-em0*.2:0;
const hFrom=rest.length?Math.min(Math.min(...meta.map(m=>m.x0))-em0*.35,edgeX-em0*.45):0;
const hTo=rest.length?Math.max(Math.max(...meta.map(m=>m.x1))+em0*.35,edgeX+em0*.45):0;
const vLen=(vH+.7)*em0,rth=Math.max(1.5,em0*.045);
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life),q=smooth((life-t)/outro);
for(const p of vp){
const lt=t-p.t0,k=clamp(lt/.3),bt=Math.max(0,lt-.3);
const sq=lt>=.3?Math.exp(-10*bt)*Math.cos(28*bt):0;
const drop=(1-k*k)*em0*2.2,rise=(1-q)*em0*1.1,sway=Math.sin(t*1.5+p.i*.5)*em0*.02;
put(p.m,p.cx+p.vi.ox*em0*p.k,p.cy+drop+rise+sway+p.vi.oy*em0*p.k,p.vi.rot,1+.14*sq,1-.2*sq,smooth(lt/.08)*q);
}
const from=(RIGHT?-1:1);
for(const w of rest){
const lt=t-w.t0,sp=spring(lt/.55),out=1-q,fl=Math.sin(t*1.3+w.line)*em0*.03;
put(w.m,w.cx+(1-sp)*from*em0*2.8+out*from*em0*1.6,w.cy+fl,0,1,1,smooth(lt/.12)*q);
}
if(rest.length){
const g=smooth((t-.15)/.5),g2=smooth((t-.3)/.5);
bar(vRule,edgeX,0,rth,vLen*g,.55*q);
bar(hRule,(hFrom+hTo)/2,hY,(hTo-hFrom)*g2,rth,.55*q);
const pop=spring((t-.55)/.35)*em0*.22;
bar(dot,edgeX,hY,pop,pop,q*smooth((t-.55)/.1),Math.PI/4);
}
}
update(0);
return {update,dispose(){disposeAll()}};
大きい語と小さい語が1本の基準線に並び、拍に合わせて交互に跳ねます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 1本の基準線の上で語が大・小と交互に並び、BPMの拍に合わせて交互に跳ねる。足元に影と基準線
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,bpm,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.6*x)*Math.cos(6.8*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const W=Math.max(1,width),H=Math.max(1,height),life=Math.max(.05,duration);
const em0=Math.max(8,fontSize||H*.18);
const outro=Math.min(.5,life*.2);
const root=new THREE.Group();scene.add(root);
const fixed=new THREE.Group();scene.add(fixed);
let accCol;try{accCol=new THREE.Color(accent||'#ffd166')}catch(e){accCol=new THREE.Color('#ffd166')}
const NL=String.fromCharCode(10),CR=String.fromCharCode(13);
const cc=c=>c.charCodeAt(0);
const isHira=c=>cc(c)>=0x3041&&cc(c)<=0x309f;
const SMALLSET=['は','が','を','に','の','で','と','も','へ','や','か','よ','ね','な','だ','わ','さ','ぞ','ぜ','です','ます','でした','ました','から','まで','より','けど','ので','のに','だよ','よね','かな','って','たい','ない','だね','たよ'];
const CLOSE='、。,.,.!?!?…」』))~〜—・';
const OPEN='「『((';
const isSmall=s=>{const a=Array.from(s).filter(c=>CLOSE.indexOf(c)<0&&OPEN.indexOf(c)<0);if(!a.length)return true;if(!a.every(isHira))return false;return a.length<=2||SMALLSET.indexOf(a.join(''))>=0};
const cw=c=>{const n=cc(c);if(n===32)return .3;if(n<128)return .64;return 1};
// 語の分割。Intl.Segmenter があればそれを使い、なければ文字種の変わり目で割る
function segmentLine(line){
const out=[];
if(typeof Intl!=='undefined'&&Intl.Segmenter){for(const p of new Intl.Segmenter('ja',{granularity:'word'}).segment(line))out.push({a:p.index,b:p.index+p.segment.length});return out}
const kind=c=>{const n=cc(c);if(n===32)return 9;if(isHira(c))return 0;if(n>=0x30a0&&n<=0x30ff)return 2;if(n<128)return 3;return 1};
let a=0;for(let i=1;i<=line.length;i++){if(i===line.length||kind(line[i])!==kind(line[i-1])){out.push({a,b:i});a=i}}
return out;
}
function runScale(start,len){
let off=0,sum=0,cnt=0;const end=start+len;
for(const r of (runs||[])){const n=String(r.text||'').length,a=Math.max(off,start),b=Math.min(off+n,end);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);cnt+=b-a}
off+=n;if(off>=end)break}
return cnt?sum/cnt:1;
}
function sizeWord(w,k){
w.k=k;const rs=Math.max(1,runScale(w.start,w.s.length));
w.wu=Array.from(w.s).reduce((a,c)=>a+cw(c),0)*k*rs*(rs>1?1.14:1.05);w.hu=k*rs;
}
const wordList=(()=>{
const rows=String(text||'').split(CR).join('').split(NL);
let off=0;const list=[];
rows.forEach((line,row)=>{
const ws=[];let pre=-1;
for(const t of segmentLine(line)){
const s=line.slice(t.a,t.b);if(!s.trim())continue;
const ch=Array.from(s);
if(ws.length&&ch.every(c=>CLOSE.indexOf(c)>=0)){ws[ws.length-1].b=t.b;continue}
if(ch.every(c=>OPEN.indexOf(c)>=0)){if(pre<0)pre=t.a;continue}
ws.push({a:pre>=0?pre:t.a,b:t.b});pre=-1;
}
if(pre>=0)ws.push({a:pre,b:line.length});
for(const w of ws)list.push({s:line.slice(w.a,w.b),start:off+w.a,row});
off+=line.length+1;
});
for(const w of list){w.n=Array.from(w.s).length;w.small=isSmall(w.s)}
if(list.length===1&&list[0].n>=2){
const w=list[0],a=Array.from(w.s),h=Math.ceil(a.length/2),f=a.slice(0,h).join(''),r=a.slice(h).join('');
list.length=0;
list.push({s:f,start:w.start,row:w.row,n:h,small:false},{s:r,start:w.start+f.length,row:w.row,n:a.length-h,small:true});
}
while(list.length>60){const out=[];for(let i=0;i<list.length;i+=2){const x=list[i],y=list[i+1];out.push(y?{s:text.slice(x.start,y.start+y.s.length),start:x.start,row:x.row,n:x.n+y.n,small:false}:x)}list.length=0;out.forEach(o=>list.push(o))}
for(const w of list)sizeWord(w,w.small?.52:(w.n===1?1.22:1));
return list;
})();
// 長すぎる語を半分ずつに割る
function splitLong(list,max){
const out=[];
for(const w of list){
if(w.n<=max){out.push(w);continue}
const a=Array.from(w.s),parts=Math.ceil(a.length/max),per=Math.ceil(a.length/parts);let off=0;
for(let i=0;i<a.length;i+=per){const s=a.slice(i,i+per).join('');const p={s,start:w.start+off,row:w.row,n:Array.from(s).length,small:false};off+=s.length;sizeWord(p,w.k);out.push(p)}
}
return out;
}
function makeMeshes(items,parent){
for(const w of items){
const m=createText(w.s,{textStart:w.start,fontSize:em0*w.k,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
m.fillOpacity=0;m.outlineOpacity=0;m.visible=false;(parent||root).add(m);w.m=m;
}
}
function put(m,x,y,rot,sx,sy,a){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.003}
function rect(parent,col,z){
const m=new THREE.Mesh(new THREE.PlaneGeometry(1,1),new THREE.MeshBasicMaterial({color:col||accCol,transparent:true,opacity:0,depthWrite:false}));
m.position.z=z===undefined?-1:z;m.renderOrder=-1;m.visible=false;(parent||root).add(m);return m;
}
function bar(m,cx,cy,w,h,a,rot){m.position.x=cx;m.position.y=cy;m.scale.set(Math.max(.001,w),Math.max(.001,h),1);m.rotation.z=rot||0;m.material.opacity=Math.max(0,a);m.visible=a>.003&&w>.01&&h>.01}
function breakLines(items,n,gap){
const tot=items.reduce((a,it,i)=>a+it.wu+(i?gap:0),0),cap=tot/n*1.1;
const lines=[];let cur=[],x=0,prev=null;
for(const it of items){
const left=n-lines.length-1;
const rowBreak=prev&&it.row!==prev.row&&cur.length&&x>=cap*.45&&left>0;
if(cur.length&&left>0&&(x+gap+it.wu>cap||rowBreak)){lines.push(cur);cur=[];x=0}
x+=(cur.length?gap:0)+it.wu;cur.push(it);prev=it;
}
lines.push(cur);return lines;
}
// 行の数をいろいろ試して、いちばん大きく収まる折り返しを選ぶ。s は em0 に対する倍率
function bestWrap(items,o){
o=o||{};const gap=.3,lh=1.25;
const boxW=o.boxW||W*.88,boxH=o.boxH||H*.8,cos=Math.abs(Math.cos(o.rot||0)),sin=Math.abs(Math.sin(o.rot||0));
const nmin=Math.min(o.minLines||1,items.length),nmax=Math.max(nmin,Math.min(o.maxLines||6,items.length));
let best=null;
for(let n=nmin;n<=nmax;n++){
const lines=breakLines(items,n,gap);
const lw=lines.map(l=>l.reduce((a,it,i)=>a+it.wu+(i?gap:0),0));
const lhh=lines.map(l=>Math.max(...l.map(it=>it.hu))*lh);
const w=Math.max(...lw.map((x,j)=>x+j*(o.indent||0)))+(o.extraW||0),h=Math.max(lhh.reduce((a,b)=>a+b,0),o.minH||0);
const bw=w*cos+h*sin,bh=w*sin+h*cos;
const s=Math.min(boxW/(bw*em0),boxH/(bh*em0),o.cap||1.8);
if(!best||s>best.s*1.03)best={lines,lw,lhh,w,h,s};
}
return best;
}
// 行ごとに語の中心 (cx,cy) を px で決める(基準線にそろえる)。返り値は行ごとの情報
function placeLines(b,align,xOff){
const gap=.3,meta=[];let top=b.h/2;
b.lines.forEach((l,j)=>{
const mh=Math.max(...l.map(it=>it.hu)),lw=b.lw[j],bottom=top-mh;
const x0=(align==='left'?-b.w/2:align==='right'?b.w/2-lw:-lw/2)+(xOff?(xOff[j]||0):0);
let x=x0;
for(const it of l){it.cx=(x+it.wu/2)*em0;it.cy=(bottom+it.hu/2)*em0;it.line=j;x+=it.wu+gap}
meta.push({x0:x0*em0,x1:(x0+lw)*em0,bottom:bottom*em0,top:top*em0,lw:lw*em0,mh:mh*em0});
top-=b.lhh[j];
});
const dy=-(meta[0].top+meta[meta.length-1].bottom)/2;
for(const l of b.lines)for(const it of l)it.cy+=dy;
for(const m of meta){m.top+=dy;m.bottom+=dy}
return meta;
}
function vInfo(c){
if('ー~〜—…-([「」『』())]'.indexOf(c)>=0)return {rot:-Math.PI/2,ox:0,oy:0};
if('、。,.,.'.indexOf(c)>=0)return {rot:0,ox:.34,oy:.34};
if('ぁぃぅぇぉっゃゅょァィゥェォッャュョ'.indexOf(c)>=0)return {rot:0,ox:.1,oy:.1};
return {rot:0,ox:0,oy:0};
}
let dead=false;
const kill=o=>{[...o.children].forEach(kill);if(typeof o.dispose==='function')o.dispose();else{if(o.geometry)o.geometry.dispose();if(o.material)o.material.dispose()}};
function disposeAll(){if(dead)return;dead=true;kill(root);kill(fixed);scene.remove(root);scene.remove(fixed)}
if(!wordList.length)return {update(){},dispose(){disposeAll()}};
const items=wordList;
let bi=0;
for(const w of items){
if(w.small)sizeWord(w,.42);
else{sizeWord(w,bi%2===0?(w.n===1?1.25:1):.74);bi++}
}
const best=bestWrap(items,{maxLines:3,boxH:H*.72,extraW:1});
const meta=placeLines(best,'center');
root.scale.setScalar(best.s);
makeMeshes(items);
const N=items.length,spread=Math.min(life*.25,N*.1);
items.forEach((w,i)=>{w.t0=N>1?spread*i/(N-1):0});
const bl=60/clamp(Number.isFinite(bpm)&&bpm>0?bpm:120,70,170);
const fancy=N<=45;
const base=meta.map(m=>rect(root)),shad=fancy?items.map(()=>rect(root)):[];
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life),q=smooth((life-t)/outro);
for(let i=0;i<N;i++){
const w=items[i],lt=t-w.t0,pop=spring(lt/.4),tt=lt-.4,ramp=smooth(tt/.3);
const u=Math.max(0,tt)/bl+(i%2)*.5,p=u-Math.floor(u);
const A=em0*(w.small?.34:.22),hop=A*ramp*4*p*(1-p);
const d=Math.min(p,1-p),sq=ramp*smooth(1-d/.12);
const sx=(1+.12*sq)*pop,sy=(1-.18*sq)*pop;
const m=meta[w.line];
const y=w.cy+hop-(1-sy)*em0*w.k*.5;
put(w.m,w.cx,y-(1-q)*em0*.5,0,sx,sy,smooth(lt/.1)*q);
if(fancy){
const hh=A>0?hop/A:0,sw=w.wu*em0*.5*(1-.35*hh)*pop;
bar(shad[i],w.cx,m.bottom-em0*.1,sw,Math.max(1.5,em0*.07),.3*q*(1-.5*hh)*smooth(lt/.2));
}
}
meta.forEach((m,j)=>{
const g=smooth((t-.05)/.45);
bar(base[j],(m.x0+m.x1)/2,m.bottom-em0*.2,(m.lw+em0*.8)*g,Math.max(1.5,em0*.045),.65*q);
});
}
update(0);
return {update,dispose(){disposeAll()}};
約12度に傾いた行を語が大小まじりで流れ込み、行ごとに向きを変えます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 約-12度に傾いた帯の上を、語が大小まじりで行ごとに逆向きに流れ込んで並ぶ。帯と罫線が付く
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,bpm,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.6*x)*Math.cos(6.8*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const W=Math.max(1,width),H=Math.max(1,height),life=Math.max(.05,duration);
const em0=Math.max(8,fontSize||H*.18);
const outro=Math.min(.5,life*.2);
const root=new THREE.Group();scene.add(root);
const fixed=new THREE.Group();scene.add(fixed);
let accCol;try{accCol=new THREE.Color(accent||'#ffd166')}catch(e){accCol=new THREE.Color('#ffd166')}
const NL=String.fromCharCode(10),CR=String.fromCharCode(13);
const cc=c=>c.charCodeAt(0);
const isHira=c=>cc(c)>=0x3041&&cc(c)<=0x309f;
const SMALLSET=['は','が','を','に','の','で','と','も','へ','や','か','よ','ね','な','だ','わ','さ','ぞ','ぜ','です','ます','でした','ました','から','まで','より','けど','ので','のに','だよ','よね','かな','って','たい','ない','だね','たよ'];
const CLOSE='、。,.,.!?!?…」』))~〜—・';
const OPEN='「『((';
const isSmall=s=>{const a=Array.from(s).filter(c=>CLOSE.indexOf(c)<0&&OPEN.indexOf(c)<0);if(!a.length)return true;if(!a.every(isHira))return false;return a.length<=2||SMALLSET.indexOf(a.join(''))>=0};
const cw=c=>{const n=cc(c);if(n===32)return .3;if(n<128)return .64;return 1};
// 語の分割。Intl.Segmenter があればそれを使い、なければ文字種の変わり目で割る
function segmentLine(line){
const out=[];
if(typeof Intl!=='undefined'&&Intl.Segmenter){for(const p of new Intl.Segmenter('ja',{granularity:'word'}).segment(line))out.push({a:p.index,b:p.index+p.segment.length});return out}
const kind=c=>{const n=cc(c);if(n===32)return 9;if(isHira(c))return 0;if(n>=0x30a0&&n<=0x30ff)return 2;if(n<128)return 3;return 1};
let a=0;for(let i=1;i<=line.length;i++){if(i===line.length||kind(line[i])!==kind(line[i-1])){out.push({a,b:i});a=i}}
return out;
}
function runScale(start,len){
let off=0,sum=0,cnt=0;const end=start+len;
for(const r of (runs||[])){const n=String(r.text||'').length,a=Math.max(off,start),b=Math.min(off+n,end);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);cnt+=b-a}
off+=n;if(off>=end)break}
return cnt?sum/cnt:1;
}
function sizeWord(w,k){
w.k=k;const rs=Math.max(1,runScale(w.start,w.s.length));
w.wu=Array.from(w.s).reduce((a,c)=>a+cw(c),0)*k*rs*(rs>1?1.14:1.05);w.hu=k*rs;
}
const wordList=(()=>{
const rows=String(text||'').split(CR).join('').split(NL);
let off=0;const list=[];
rows.forEach((line,row)=>{
const ws=[];let pre=-1;
for(const t of segmentLine(line)){
const s=line.slice(t.a,t.b);if(!s.trim())continue;
const ch=Array.from(s);
if(ws.length&&ch.every(c=>CLOSE.indexOf(c)>=0)){ws[ws.length-1].b=t.b;continue}
if(ch.every(c=>OPEN.indexOf(c)>=0)){if(pre<0)pre=t.a;continue}
ws.push({a:pre>=0?pre:t.a,b:t.b});pre=-1;
}
if(pre>=0)ws.push({a:pre,b:line.length});
for(const w of ws)list.push({s:line.slice(w.a,w.b),start:off+w.a,row});
off+=line.length+1;
});
for(const w of list){w.n=Array.from(w.s).length;w.small=isSmall(w.s)}
if(list.length===1&&list[0].n>=2){
const w=list[0],a=Array.from(w.s),h=Math.ceil(a.length/2),f=a.slice(0,h).join(''),r=a.slice(h).join('');
list.length=0;
list.push({s:f,start:w.start,row:w.row,n:h,small:false},{s:r,start:w.start+f.length,row:w.row,n:a.length-h,small:true});
}
while(list.length>60){const out=[];for(let i=0;i<list.length;i+=2){const x=list[i],y=list[i+1];out.push(y?{s:text.slice(x.start,y.start+y.s.length),start:x.start,row:x.row,n:x.n+y.n,small:false}:x)}list.length=0;out.forEach(o=>list.push(o))}
for(const w of list)sizeWord(w,w.small?.52:(w.n===1?1.22:1));
return list;
})();
// 長すぎる語を半分ずつに割る
function splitLong(list,max){
const out=[];
for(const w of list){
if(w.n<=max){out.push(w);continue}
const a=Array.from(w.s),parts=Math.ceil(a.length/max),per=Math.ceil(a.length/parts);let off=0;
for(let i=0;i<a.length;i+=per){const s=a.slice(i,i+per).join('');const p={s,start:w.start+off,row:w.row,n:Array.from(s).length,small:false};off+=s.length;sizeWord(p,w.k);out.push(p)}
}
return out;
}
function makeMeshes(items,parent){
for(const w of items){
const m=createText(w.s,{textStart:w.start,fontSize:em0*w.k,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
m.fillOpacity=0;m.outlineOpacity=0;m.visible=false;(parent||root).add(m);w.m=m;
}
}
function put(m,x,y,rot,sx,sy,a){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.003}
function rect(parent,col,z){
const m=new THREE.Mesh(new THREE.PlaneGeometry(1,1),new THREE.MeshBasicMaterial({color:col||accCol,transparent:true,opacity:0,depthWrite:false}));
m.position.z=z===undefined?-1:z;m.renderOrder=-1;m.visible=false;(parent||root).add(m);return m;
}
function bar(m,cx,cy,w,h,a,rot){m.position.x=cx;m.position.y=cy;m.scale.set(Math.max(.001,w),Math.max(.001,h),1);m.rotation.z=rot||0;m.material.opacity=Math.max(0,a);m.visible=a>.003&&w>.01&&h>.01}
function breakLines(items,n,gap){
const tot=items.reduce((a,it,i)=>a+it.wu+(i?gap:0),0),cap=tot/n*1.1;
const lines=[];let cur=[],x=0,prev=null;
for(const it of items){
const left=n-lines.length-1;
const rowBreak=prev&&it.row!==prev.row&&cur.length&&x>=cap*.45&&left>0;
if(cur.length&&left>0&&(x+gap+it.wu>cap||rowBreak)){lines.push(cur);cur=[];x=0}
x+=(cur.length?gap:0)+it.wu;cur.push(it);prev=it;
}
lines.push(cur);return lines;
}
// 行の数をいろいろ試して、いちばん大きく収まる折り返しを選ぶ。s は em0 に対する倍率
function bestWrap(items,o){
o=o||{};const gap=.3,lh=1.25;
const boxW=o.boxW||W*.88,boxH=o.boxH||H*.8,cos=Math.abs(Math.cos(o.rot||0)),sin=Math.abs(Math.sin(o.rot||0));
const nmin=Math.min(o.minLines||1,items.length),nmax=Math.max(nmin,Math.min(o.maxLines||6,items.length));
let best=null;
for(let n=nmin;n<=nmax;n++){
const lines=breakLines(items,n,gap);
const lw=lines.map(l=>l.reduce((a,it,i)=>a+it.wu+(i?gap:0),0));
const lhh=lines.map(l=>Math.max(...l.map(it=>it.hu))*lh);
const w=Math.max(...lw.map((x,j)=>x+j*(o.indent||0)))+(o.extraW||0),h=Math.max(lhh.reduce((a,b)=>a+b,0),o.minH||0);
const bw=w*cos+h*sin,bh=w*sin+h*cos;
const s=Math.min(boxW/(bw*em0),boxH/(bh*em0),o.cap||1.8);
if(!best||s>best.s*1.03)best={lines,lw,lhh,w,h,s};
}
return best;
}
// 行ごとに語の中心 (cx,cy) を px で決める(基準線にそろえる)。返り値は行ごとの情報
function placeLines(b,align,xOff){
const gap=.3,meta=[];let top=b.h/2;
b.lines.forEach((l,j)=>{
const mh=Math.max(...l.map(it=>it.hu)),lw=b.lw[j],bottom=top-mh;
const x0=(align==='left'?-b.w/2:align==='right'?b.w/2-lw:-lw/2)+(xOff?(xOff[j]||0):0);
let x=x0;
for(const it of l){it.cx=(x+it.wu/2)*em0;it.cy=(bottom+it.hu/2)*em0;it.line=j;x+=it.wu+gap}
meta.push({x0:x0*em0,x1:(x0+lw)*em0,bottom:bottom*em0,top:top*em0,lw:lw*em0,mh:mh*em0});
top-=b.lhh[j];
});
const dy=-(meta[0].top+meta[meta.length-1].bottom)/2;
for(const l of b.lines)for(const it of l)it.cy+=dy;
for(const m of meta){m.top+=dy;m.bottom+=dy}
return meta;
}
function vInfo(c){
if('ー~〜—…-([「」『』())]'.indexOf(c)>=0)return {rot:-Math.PI/2,ox:0,oy:0};
if('、。,.,.'.indexOf(c)>=0)return {rot:0,ox:.34,oy:.34};
if('ぁぃぅぇぉっゃゅょァィゥェォッャュョ'.indexOf(c)>=0)return {rot:0,ox:.1,oy:.1};
return {rot:0,ox:0,oy:0};
}
let dead=false;
const kill=o=>{[...o.children].forEach(kill);if(typeof o.dispose==='function')o.dispose();else{if(o.geometry)o.geometry.dispose();if(o.material)o.material.dispose()}};
function disposeAll(){if(dead)return;dead=true;kill(root);kill(fixed);scene.remove(root);scene.remove(fixed)}
if(!wordList.length)return {update(){},dispose(){disposeAll()}};
const TILT=-0.21;
const items=wordList;
const PADU=2.4;
const best=bestWrap(items,{maxLines:4,rot:TILT,boxW:W*.86,boxH:H*.76,extraW:PADU});
const meta=placeLines(best,'center',best.lines.map((_,j)=>(j%2?1:-1)*.35));
root.scale.setScalar(best.s);
const tilt=new THREE.Group();tilt.rotation.z=TILT;root.add(tilt);
makeMeshes(items,tilt);
const N=items.length,spread=Math.min(life*.25,N*.08);
items.forEach((w,i)=>{w.t0=(w.line*.12)+(N>1?spread*i/(N-1):0)});
const deco=meta.map(m=>({band:rect(tilt),rule:rect(tilt),tick:rect(tilt)}));
const TR=em0*8;
const ease=x=>{x=clamp(x);return 1-Math.pow(1-x,3)};
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life),q=smooth((life-t)/outro);
for(const w of items){
const dir=w.line%2?1:-1,lt=t-w.t0,e=ease(lt/.85);
const drift=dir*Math.sin(t*.9+w.line*1.3)*em0*.12;
put(w.m,w.cx+dir*(1-e)*TR-dir*(1-q)*TR*.6+drift,w.cy,0,1,1,smooth(lt/.2)*q);
}
meta.forEach((m,j)=>{
const dir=j%2?1:-1,d=deco[j],t0=j*.12,e=ease((t-t0)/.85),cx=(m.x0+m.x1)/2;
const drift=dir*Math.sin(t*.9+j*1.3)*em0*.12,sh=dir*(1-e)*TR-dir*(1-q)*TR*.6;
const lw=m.lw+em0*1.0;
bar(d.band,cx+sh+drift,m.bottom+m.mh*.5,lw,m.mh*1.15,.1*q*smooth((t-t0)/.2));
bar(d.rule,cx+sh+drift,m.bottom-em0*.14,lw,Math.max(1.5,em0*.045),.7*q*smooth((t-t0)/.2));
const pop=spring((t-t0-.3)/.4)*em0*.2;
bar(d.tick,m.x1+em0*.55+sh+drift,m.bottom-em0*.14,pop,pop,q*smooth((t-t0-.3)/.1),Math.PI/4);
});
}
update(0);
return {update,dispose(){disposeAll()}};
語ごとに色違いの付箋へ文字を書いて、ぺたっと貼ります。退場は上の糊から剥がれて落ちます。
メソッド工房の「文字モーション」のサンプルから選びます。
// @fonts
// 語ごとに色違いの付箋(黄・桃・水色…)に文字を書いて、少し傾けてぺたっと貼る。退場は上の糊を軸に振れて剥がれ落ちる。
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,accent,color,fonts,fontsFor,fontURL}=api;
const NL=String.fromCharCode(10);
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const hexNum=hex=>{let h=String(hex).trim().replace('#','');if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];return parseInt(h.slice(0,6),16)||0;};
const luma=hex=>{const n=hexNum(hex);return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255};
// シェーダーに渡す色は、色空間の変換をしない生の値で渡す
const rgb=hex=>{const n=hexNum(hex);return new THREE.Vector3((n>>16&255)/255,(n>>8&255)/255,(n&255)/255);};
const PUNCT='、。,.!?!?…「」『』()()・~〜ー-';
const PART='のはがをにへともやでよねかなてたも';
// 語に当たる run の大きさ・強調・色を、歌詞の文字位置 [start,end) から求める
const runInfo=(start,end)=>{
let o=0,sum=0,n=0,emph=false,col=null;
for(const r of runs||[]){
const len=String(r.text||'').length,a=Math.max(start,o),b=Math.min(end,o+len);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);n+=b-a;if(r.emphasis)emph=true;if(r.color&&!col)col=r.color;}
o+=len;
}
return {scale:n?sum/n:1,emph,col};
};
// その語の全部の文字を持っている書体だけから選ぶ(なければ選んだ書体)
const pickFont=(s,names,r)=>{
const cand=[];
for(const k of names){
if(fonts&&fonts[k]&&Array.from(s).every(c=>!c.trim()||fontsFor(c).includes(k)))cand.push(fonts[k]);
}
cand.push(fontURL);
return cand[Math.min(cand.length-1,Math.floor(r*cand.length))];
};
const makeQuad=(mat,w,h)=>{const m=new THREE.Mesh(new THREE.PlaneGeometry(w||1,h||1),mat);m.frustumCulled=false;return m;};
const VERT=['varying vec2 vUv;','void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}'].join(NL);
const GLSL_COMMON=[
'precision highp float;',
'varying vec2 vUv;',
'float hash21(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}',
'float vnoise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);',
' return mix(mix(hash21(i),hash21(i+vec2(1.,0.)),f.x),mix(hash21(i+vec2(0.,1.)),hash21(i+vec2(1.,1.)),f.x),f.y);}',
'float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}'
].join(NL);
const CFG={cap:8,maxChars:5,fonts:['marker','round'],gap:.2,vgap:.3,wob:.12,
pal:['#ffe14d','#ff9ec7','#7fd4ff','#a6ec8e','#ffb25c'],shift:Math.floor(random()*5),
style(l){
// 付箋の色は語ごとに回す。強調語はアクセント色、色指定の語はその色
let paper;
if(l.info.col)paper=l.info.col;
else if(l.info.emph)paper=accent;
else paper=CFG.pal[(l.i+CFG.shift)%CFG.pal.length];
l.paper=paper;l.ink=luma(paper)>.5?'#23263a':'#fffdf5';
},
size(l){const ph=l.fs*1.55;return [Math.max(l.tw0+l.fs*.78,l.fs*1.5),ph];}};
// 歌詞を語に区切る(日本語は Intl.Segmenter。助詞と句読点は前の語につけ、長い語は割り、語が多すぎるときはまとめる)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').split(NL).forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({a:p.index,b:p.index+p.segment.length});}}
else{for(let i=0;i<line.length;i+=2)parts.push({a:i,b:Math.min(line.length,i+2)});}
const m=[];
for(const p of parts){
const s=line.slice(p.a,p.b),last=m[m.length-1];
const isP=Array.from(s).every(c=>PUNCT.includes(c));
const isJ=s.length===1&&PART.includes(s);
if(last&&(isP||(isJ&&last.b-last.a<CFG.maxChars)))last.b=p.b;else m.push({a:p.a,b:p.b});
}
for(const q of m)out.push({a:q.a,b:q.b,line,row,off});
off+=line.length+1;
});
// run の大きさ・強調・色が変わるところでは語を分ける(私 は 元気 です…と語ごとに大きさを変えられるように)
const sigAt=pos=>{let o=0;for(const r of runs||[]){const n=String(r.text||'').length;if(pos<o+n)return (Number.isFinite(r.scale)&&r.scale>0?r.scale:1)+'|'+(r.emphasis?1:0)+'|'+(r.color||'');o+=n;}return '1|0|';};
let list=[];
for(const q of out){
let a=q.a;
for(let i=q.a+1;i<=q.b;i++){
if(i===q.b||sigAt(q.off+i)!==sigAt(q.off+i-1)){
const s=q.line.slice(a,i),sig=sigAt(q.off+a);
if(s.length>CFG.maxChars&&s.charCodeAt(0)>255){
const k=Math.ceil(s.length/CFG.maxChars),sz=Math.ceil(s.length/k);
for(let j=0;j<s.length;j+=sz)list.push({...q,a:a+j,b:a+Math.min(s.length,j+sz),sig});
}else list.push({...q,a,b:i,sig});
a=i;
}
}
}
const squash=(strict,limit)=>{
while(list.length>limit){
let bi=-1,bl=1e9;
for(let i=0;i<list.length-1;i++){
if(list[i].row!==list[i+1].row||(strict&&list[i].sig!==list[i+1].sig))continue;
const l=list[i+1].b-list[i].a;if(l<bl){bl=l;bi=i;}
}
if(bi<0)break;
list.splice(bi,2,{...list[bi],b:list[bi+1].b});
}
};
squash(true,CFG.cap);
squash(false,Math.max(CFG.cap,14));
if(list.length===1&&list[0].b-list[0].a>=4){
const q=list[0],h=Math.ceil((q.b-q.a)/2);
list=[{...q,b:q.a+h},{...q,a:q.a+h}];
}
return list;
})();
const root=new THREE.Group();scene.add(root);
const disposables=[];
const items=[];
const userScaled=(runs||[]).some(r=>Number.isFinite(r.scale)&&r.scale>0&&r.scale!==1);
wordList.forEach((w,i)=>{
const s=w.line.slice(w.a,w.b),start=w.off+w.a,info=runInfo(start,start+s.length);
// 指定がなければ、大・小・大・小…と語ごとに大きさを変える(run の scale があるときはそれを活かす)
const big=userScaled?1+(random()-.5)*.14:(i%2?.8+.18*random():1.04+.28*random());
const l={i,s,start,row:w.row,info,sc:info.scale,big,r:[random(),random(),random(),random()]};
l.fs=em*big;
CFG.style(l);
l.mesh=createText(s,{textStart:start,font:pickFont(s,CFG.fonts,l.r[3]),fontSize:l.fs,anchorX:'center',anchorY:'middle',color:l.ink,colorRanges:null});
l.mesh.fillOpacity=0;l.mesh.visible=false;
l.texts=[l.mesh];
items.push(l);
});
const N=items.length;
const spread=N>1?Math.min(life*.3,2.2):0,ent=Math.min(.5,life*.17),outro=Math.min(.5,life*.17),ostag=Math.min(.12,life*.05);
items.forEach((l,k)=>{l.t0=N>1?spread*k/(N-1):0;l.o=N>1?k/(N-1):.5;});
let fit=1,laid=false,dead=false;
function measure(l){
const b=l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
l.tw0=(b&&b.length===4&&b.every(Number.isFinite))?Math.max(l.fs*.3,b[2]-b[0]):l.fs*Array.from(l.s).length*.95;
const z=CFG.size(l);
l.pw0=z[0];l.ph0=z[1];l.pw=l.pw0*l.sc;l.ph=l.ph0*l.sc;
}
function flow(r,k,gap,limit){
const total=r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0),cap=k>1?total/k*1.15+gap:limit;
const out=[];let cur=[],x=0;
for(const l of r){if(cur.length&&x+gap+l.pw>cap){out.push(cur);cur=[];x=0;}x+=(cur.length?gap:0)+l.pw;cur.push(l);}
out.push(cur);return out;
}
function layout(){
const gap=em*CFG.gap,vgap=em*CFG.vgap,limit=W*.84;
const hard=[];for(const l of items)(hard[l.row]=hard[l.row]||[]).push(l);
const rowsList=hard.filter(Boolean);
let best=null;
for(let k=1;k<=6;k++){
const lines=[];for(const r of rowsList)lines.push(...flow(r,k,gap,1e9));
const lh=lines.map(r=>Math.max(...r.map(l=>l.ph)));
let totalH=0;lines.forEach((r,j)=>{totalH+=lh[j]+(j?vgap:0);});
const maxW=Math.max(1,...lines.map(r=>r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0)));
const f=Math.min(1,W*.84/maxW,H*.7/Math.max(1,totalH));
if(!best||f>best.f+.001)best={f,lines,lh,totalH};
}
let y=best.totalH/2;
best.lines.forEach((r,k)=>{
const w=r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0);
let x=-w/2;y-=best.lh[k]/2;
for(const l of r){l.x=x+l.pw/2;l.y=y+(l.r[2]-.5)*l.ph*CFG.wob;x+=l.pw+gap;}
y-=best.lh[k]/2+vgap;
});
fit=best.f;
root.scale.setScalar(fit);
}
function update(time){
if(dead)return;
const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;
for(const l of items){const inf=l.mesh.textRenderInfo;if(inf&&l.info2!==inf){l.info2=inf;changed=true;}}
if(changed){for(const l of items)measure(l);layout();laid=true;}
pose(t);
}
function dispose(){
if(dead)return;dead=true;
for(const l of items)for(const m of l.texts){root.remove(m);m.dispose();}
for(const d of disposables)d.dispose();
scene.remove(root);
}
const NOTE_FRAG=[GLSL_COMMON,
'uniform vec2 u_half;uniform vec2 u_size;uniform float u_alpha;uniform vec3 u_col;',
'uniform float u_lift;uniform float u_curl;uniform float u_seed;uniform float u_unit;',
'void main(){',
' vec2 p=(vUv-.5)*u_size;',
' float L=u_curl*min(u_half.x,u_half.y)*.5;',
' vec2 c=vec2(u_half.x-p.x,u_half.y+p.y);',
' float dBox=sdRB(p,u_half,u_unit*.5);',
' float dCut=(L-(c.x+c.y))*.7071;',
' float d=L>0.?max(dBox,dCut):dBox;',
' float paperA=1.-smoothstep(-.7,.7,d);',
' vec2 so=vec2(1.5+u_lift*5.,-2.5-u_lift*9.)*u_unit;',
' float blur=(2.+u_lift*7.)*u_unit;',
' float sh=(1.-smoothstep(-blur,blur,sdRB(p-so,u_half,u_unit)))*(.38-.14*u_lift);',
' float gy=p.y/u_half.y*.5+.5;',
' vec3 col=u_col*(.9+.12*gy);',
' col+=.05*smoothstep(.7,.74,gy);', // 糊の帯(上)
' col*=1.-.07*smoothstep(.0,.25,.25-abs(gy-.73));',
' col*=.975+.05*vnoise(p*.7+u_seed*17.);', // 紙の繊維
' col*=1.-.12*smoothstep(.3,0.,gy);', // 下へ向かって少し濃く
' if(L>0.){',
' bool flap=c.x<L&&c.y<L&&(c.x+c.y)>=L;',
' if(flap){float k=(c.x+c.y-L)/L;col=mix(u_col*1.1+.06,u_col*.86,k);}',
' float edge=1.-smoothstep(0.,1.6*u_unit,abs(c.x+c.y-L)*.7071);',
' if(c.x<L+1.&&c.y<L+1.)col*=1.-.18*edge;',
' }',
' float oa=paperA+sh*(1.-paperA);',
' vec3 rgb=oa>.001?col*paperA/oa:vec3(0.);',
' gl_FragColor=vec4(rgb,oa*u_alpha);',
'}'].join(NL);
const MARGIN=Math.round(Math.max(26,em*.8));
function initItem(l){
l.outer=new THREE.Group();l.inner=new THREE.Group();l.outer.add(l.inner);root.add(l.outer);
l.mat=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:NOTE_FRAG,
uniforms:{u_half:{value:new THREE.Vector2(1,1)},u_size:{value:new THREE.Vector2(1,1)},u_alpha:{value:0},u_col:{value:rgb(l.paper)},
u_lift:{value:0},u_curl:{value:l.r[0]>.55?.42:0},u_seed:{value:l.r[1]},u_unit:{value:Math.max(.5,l.fs/42)}}});
disposables.push(l.mat);
l.quad=makeQuad(l.mat,1,1);l.quad.renderOrder=l.i*4;l.inner.add(l.quad);
l.mesh.renderOrder=l.i*4+1;l.mesh.material.depthTest=false;l.mesh.material.depthWrite=false;
l.inner.add(l.mesh);
l.outer.visible=false;
l.rot0=(l.r[1]-.5)*.15;
l.dir=l.i%2?1:-1;
disposables.push(l.quad.geometry);
}
function pose(t){
for(const l of items){
if(!l.pw)continue;
const u=clamp((t-l.t0)/ent),q=1-clamp((life-t-ostag*(1-l.o))/outro);
const a=smooth(u*6)*(1-smooth((q-.3)/.7));
l.outer.visible=a>.003&&t<life;
if(!l.outer.visible){l.mesh.visible=false;continue;}
// 貼られる:手前の大きな状態から降りてきて、ぺたっとつぶれて落ち着く
const air=smooth(clamp(u/.6)),k=clamp((u-.6)/.4),bounce=Math.exp(-6*k)*Math.cos(14*k)*.08;
const lift=(1-air)*1+q*.8;
const s=(1+.5*(1-air))*(1+.04*q);
const swing=q*q*l.dir*.95; // 剥がれて、上の糊のところを軸に振れて落ちる
const fall=q*q*H*.3/fit;
const tilt=l.rot0+(1-air)*l.dir*.4;
l.outer.position.set(l.x,l.y+l.ph/2*l.sc-fall,0);
l.outer.rotation.z=tilt+swing;
l.inner.position.set(0,-l.ph/2*l.sc,0);
l.inner.scale.set(l.sc*s*(1+bounce*.6),l.sc*s*(1-bounce),1);
const w=l.pw0,h=l.ph0;
l.quad.scale.set(w+2*MARGIN,h+2*MARGIN,1);
const m=l.mat.uniforms;
m.u_half.value.set(w/2,h/2);m.u_size.value.set(w+2*MARGIN,h+2*MARGIN);m.u_alpha.value=a;m.u_lift.value=lift;
l.mesh.visible=true;l.mesh.fillOpacity=a*smooth((u-.05)*8);
}
}
items.forEach(initItem);
update(0);
return {update,dispose};
語ごとに色の違うラベルのテープへ、文字をジ・ジ・ジーと刻むように印字します。
メソッド工房の「文字モーション」のサンプルから選びます。
// @fonts
// 語ごとに色の違うラベルライターのテープに、文字を「ジ・ジ・ジー」と刻むように左から印字して並べる。退場は左から巻き取られて消える。
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,accent,color,fonts,fontsFor,fontURL}=api;
const NL=String.fromCharCode(10);
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const hexNum=hex=>{let h=String(hex).trim().replace('#','');if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];return parseInt(h.slice(0,6),16)||0;};
const luma=hex=>{const n=hexNum(hex);return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255};
// シェーダーに渡す色は、色空間の変換をしない生の値で渡す
const rgb=hex=>{const n=hexNum(hex);return new THREE.Vector3((n>>16&255)/255,(n>>8&255)/255,(n&255)/255);};
const PUNCT='、。,.!?!?…「」『』()()・~〜ー-';
const PART='のはがをにへともやでよねかなてたも';
// 語に当たる run の大きさ・強調・色を、歌詞の文字位置 [start,end) から求める
const runInfo=(start,end)=>{
let o=0,sum=0,n=0,emph=false,col=null;
for(const r of runs||[]){
const len=String(r.text||'').length,a=Math.max(start,o),b=Math.min(end,o+len);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);n+=b-a;if(r.emphasis)emph=true;if(r.color&&!col)col=r.color;}
o+=len;
}
return {scale:n?sum/n:1,emph,col};
};
// その語の全部の文字を持っている書体だけから選ぶ(なければ選んだ書体)
const pickFont=(s,names,r)=>{
const cand=[];
for(const k of names){
if(fonts&&fonts[k]&&Array.from(s).every(c=>!c.trim()||fontsFor(c).includes(k)))cand.push(fonts[k]);
}
cand.push(fontURL);
return cand[Math.min(cand.length-1,Math.floor(r*cand.length))];
};
const makeQuad=(mat,w,h)=>{const m=new THREE.Mesh(new THREE.PlaneGeometry(w||1,h||1),mat);m.frustumCulled=false;return m;};
const VERT=['varying vec2 vUv;','void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}'].join(NL);
const GLSL_COMMON=[
'precision highp float;',
'varying vec2 vUv;',
'float hash21(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}',
'float vnoise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);',
' return mix(mix(hash21(i),hash21(i+vec2(1.,0.)),f.x),mix(hash21(i+vec2(0.,1.)),hash21(i+vec2(1.,1.)),f.x),f.y);}',
'float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}'
].join(NL);
const CFG={cap:8,maxChars:5,fonts:['dot','gothic'],gap:.22,vgap:.22,wob:.06,
pal:[['#16181d','#ffffff'],['#f5f3ea','#16181d'],['#ffd43b','#16181d'],['#e5384b','#ffffff'],['#2f6fe0','#ffffff'],['#27a977','#ffffff']],shift:Math.floor(random()*6),
style(l){
// テープの色は語ごとに回す。強調語はアクセント色、色指定の語はその色
let tape,ink;
if(l.info.col||l.info.emph){tape=l.info.col||accent;ink=luma(tape)>.55?'#16181d':'#ffffff';}
else{const p=CFG.pal[(l.i+CFG.shift)%CFG.pal.length];tape=p[0];ink=p[1];}
l.paper=tape;l.ink=ink;
},
size(l){return [l.tw0+l.fs*.95,l.fs*1.3];}};
// 歌詞を語に区切る(日本語は Intl.Segmenter。助詞と句読点は前の語につけ、長い語は割り、語が多すぎるときはまとめる)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').split(NL).forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({a:p.index,b:p.index+p.segment.length});}}
else{for(let i=0;i<line.length;i+=2)parts.push({a:i,b:Math.min(line.length,i+2)});}
const m=[];
for(const p of parts){
const s=line.slice(p.a,p.b),last=m[m.length-1];
const isP=Array.from(s).every(c=>PUNCT.includes(c));
const isJ=s.length===1&&PART.includes(s);
if(last&&(isP||(isJ&&last.b-last.a<CFG.maxChars)))last.b=p.b;else m.push({a:p.a,b:p.b});
}
for(const q of m)out.push({a:q.a,b:q.b,line,row,off});
off+=line.length+1;
});
// run の大きさ・強調・色が変わるところでは語を分ける(私 は 元気 です…と語ごとに大きさを変えられるように)
const sigAt=pos=>{let o=0;for(const r of runs||[]){const n=String(r.text||'').length;if(pos<o+n)return (Number.isFinite(r.scale)&&r.scale>0?r.scale:1)+'|'+(r.emphasis?1:0)+'|'+(r.color||'');o+=n;}return '1|0|';};
let list=[];
for(const q of out){
let a=q.a;
for(let i=q.a+1;i<=q.b;i++){
if(i===q.b||sigAt(q.off+i)!==sigAt(q.off+i-1)){
const s=q.line.slice(a,i),sig=sigAt(q.off+a);
if(s.length>CFG.maxChars&&s.charCodeAt(0)>255){
const k=Math.ceil(s.length/CFG.maxChars),sz=Math.ceil(s.length/k);
for(let j=0;j<s.length;j+=sz)list.push({...q,a:a+j,b:a+Math.min(s.length,j+sz),sig});
}else list.push({...q,a,b:i,sig});
a=i;
}
}
}
const squash=(strict,limit)=>{
while(list.length>limit){
let bi=-1,bl=1e9;
for(let i=0;i<list.length-1;i++){
if(list[i].row!==list[i+1].row||(strict&&list[i].sig!==list[i+1].sig))continue;
const l=list[i+1].b-list[i].a;if(l<bl){bl=l;bi=i;}
}
if(bi<0)break;
list.splice(bi,2,{...list[bi],b:list[bi+1].b});
}
};
squash(true,CFG.cap);
squash(false,Math.max(CFG.cap,14));
if(list.length===1&&list[0].b-list[0].a>=4){
const q=list[0],h=Math.ceil((q.b-q.a)/2);
list=[{...q,b:q.a+h},{...q,a:q.a+h}];
}
return list;
})();
const root=new THREE.Group();scene.add(root);
const disposables=[];
const items=[];
const userScaled=(runs||[]).some(r=>Number.isFinite(r.scale)&&r.scale>0&&r.scale!==1);
wordList.forEach((w,i)=>{
const s=w.line.slice(w.a,w.b),start=w.off+w.a,info=runInfo(start,start+s.length);
// 指定がなければ、大・小・大・小…と語ごとに大きさを変える(run の scale があるときはそれを活かす)
const big=userScaled?1+(random()-.5)*.14:(i%2?.8+.18*random():1.04+.28*random());
const l={i,s,start,row:w.row,info,sc:info.scale,big,r:[random(),random(),random(),random()]};
l.fs=em*big;
CFG.style(l);
l.mesh=createText(s,{textStart:start,font:pickFont(s,CFG.fonts,l.r[3]),fontSize:l.fs,anchorX:'center',anchorY:'middle',color:l.ink,colorRanges:null});
l.mesh.fillOpacity=0;l.mesh.visible=false;
l.texts=[l.mesh];
items.push(l);
});
const N=items.length;
const spread=N>1?Math.min(life*.3,2.2):0,ent=Math.min(.5,life*.17),outro=Math.min(.5,life*.17),ostag=Math.min(.12,life*.05);
items.forEach((l,k)=>{l.t0=N>1?spread*k/(N-1):0;l.o=N>1?k/(N-1):.5;});
let fit=1,laid=false,dead=false;
function measure(l){
const b=l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
l.tw0=(b&&b.length===4&&b.every(Number.isFinite))?Math.max(l.fs*.3,b[2]-b[0]):l.fs*Array.from(l.s).length*.95;
const z=CFG.size(l);
l.pw0=z[0];l.ph0=z[1];l.pw=l.pw0*l.sc;l.ph=l.ph0*l.sc;
}
function flow(r,k,gap,limit){
const total=r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0),cap=k>1?total/k*1.15+gap:limit;
const out=[];let cur=[],x=0;
for(const l of r){if(cur.length&&x+gap+l.pw>cap){out.push(cur);cur=[];x=0;}x+=(cur.length?gap:0)+l.pw;cur.push(l);}
out.push(cur);return out;
}
function layout(){
const gap=em*CFG.gap,vgap=em*CFG.vgap,limit=W*.84;
const hard=[];for(const l of items)(hard[l.row]=hard[l.row]||[]).push(l);
const rowsList=hard.filter(Boolean);
let best=null;
for(let k=1;k<=6;k++){
const lines=[];for(const r of rowsList)lines.push(...flow(r,k,gap,1e9));
const lh=lines.map(r=>Math.max(...r.map(l=>l.ph)));
let totalH=0;lines.forEach((r,j)=>{totalH+=lh[j]+(j?vgap:0);});
const maxW=Math.max(1,...lines.map(r=>r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0)));
const f=Math.min(1,W*.84/maxW,H*.7/Math.max(1,totalH));
if(!best||f>best.f+.001)best={f,lines,lh,totalH};
}
let y=best.totalH/2;
best.lines.forEach((r,k)=>{
const w=r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0);
let x=-w/2;y-=best.lh[k]/2;
for(const l of r){l.x=x+l.pw/2;l.y=y+(l.r[2]-.5)*l.ph*CFG.wob;x+=l.pw+gap;}
y-=best.lh[k]/2+vgap;
});
fit=best.f;
root.scale.setScalar(fit);
}
function update(time){
if(dead)return;
const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;
for(const l of items){const inf=l.mesh.textRenderInfo;if(inf&&l.info2!==inf){l.info2=inf;changed=true;}}
if(changed){for(const l of items)measure(l);layout();laid=true;}
pose(t);
}
function dispose(){
if(dead)return;dead=true;
for(const l of items)for(const m of l.texts){root.remove(m);m.dispose();}
for(const d of disposables)d.dispose();
scene.remove(root);
}
const TAPE_FRAG=[GLSL_COMMON,
'uniform vec2 u_half;uniform vec2 u_size;uniform float u_alpha;uniform float u_prog;uniform float u_from;uniform float u_unit;',
'uniform vec3 u_col;uniform vec3 u_hi;',
'void main(){',
' vec2 p=(vUv-.5)*u_size;',
' float dBox=sdRB(p,u_half,1.2*u_unit);',
' float dCh=(abs(p.x)-u_half.x+abs(p.y)-u_half.y+3.4*u_unit)*.7071;', // 角を斜めに落とした帯
' float d=max(dBox,dCh);',
' float xr=-u_half.x+u_prog*2.*u_half.x,xl=-u_half.x+u_from*2.*u_half.x;',
' float clip=smoothstep(xr+.7,xr-.7,p.x)*smoothstep(xl-.7,xl+.7,p.x);',
' float a=(1.-smoothstep(-.6,.6,d))*clip;',
' float sh=(1.-smoothstep(-2.*u_unit,2.*u_unit,sdRB(p-vec2(1.3,-2.4)*u_unit,u_half,u_unit)))*.38*clip;',
' float gy=p.y/u_half.y*.5+.5;',
' float sheen=exp(-pow((gy-.74)*3.2,2.))*.14;',
' vec3 col=u_col*(.9+.1*gy)+sheen*(vec3(1.)-u_col*.5);',
' col*=.965+.07*hash21(floor(p*1.3+3.));', // ビニールの細かいざらつき
' float ins=4.2*u_unit;',
' float ln=1.-smoothstep(0.,1.15*u_unit,abs(sdRB(p,u_half-ins,u_unit*.8)));', // エンボスの縁取り線
' float ln2=1.-smoothstep(0.,1.15*u_unit,abs(sdRB(p-vec2(.9,-.9)*u_unit,u_half-ins,u_unit*.8)));',
' col=mix(col,vec3(0.),ln2*.4*(1.-ln));',
' col=mix(col,u_hi,ln*.85);',
' float head=smoothstep(9.*u_unit,0.,xr-p.x)*step(.001,u_prog)*step(u_prog,.999);',
' col=mix(col,vec3(1.),head*.2);', // 印字ヘッドのあたり
' float oa=a+sh*(1.-a);',
' vec3 rgb=oa>.001?col*a/oa:vec3(0.);',
' gl_FragColor=vec4(rgb,oa*u_alpha);',
'}'].join(NL);
const MARGIN=Math.round(Math.max(10,em*.3));
const big=1e4;
function initItem(l){
l.g=new THREE.Group();root.add(l.g);
const un=Math.max(.5,l.fs/42);
const hi=new THREE.Color(l.paper);
const lightTape=luma(l.paper)>.55;
l.mat=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:TAPE_FRAG,
uniforms:{u_half:{value:new THREE.Vector2(1,1)},u_size:{value:new THREE.Vector2(1,1)},u_alpha:{value:0},u_prog:{value:0},u_from:{value:0},u_unit:{value:un},
u_col:{value:rgb(l.paper)},u_hi:{value:lightTape?new THREE.Vector3(.2,.2,.22):rgb(l.paper).multiplyScalar(.35).add(new THREE.Vector3(.55,.55,.55))}}});
disposables.push(l.mat);
l.quad=makeQuad(l.mat,1,1);l.quad.renderOrder=l.i*4;l.g.add(l.quad);disposables.push(l.quad.geometry);
// 文字の凹凸:少しずらした影の文字をもう1枚
const dark=luma(l.ink)>.5;
l.shade=createText(l.s,{textStart:l.start,font:l.mesh.font,fontSize:l.fs,anchorX:'center',anchorY:'middle',color:dark?'#000000':'#ffffff',colorRanges:null});
l.shade.fillOpacity=0;l.shade.visible=false;l.shade.renderOrder=l.i*4+1;l.shade.material.depthTest=false;l.shade.material.depthWrite=false;
l.shade.position.set(1.2*un,-1.2*un,0);
l.texts.push(l.shade);
l.mesh.renderOrder=l.i*4+2;l.mesh.material.depthTest=false;l.mesh.material.depthWrite=false;
l.g.add(l.shade);l.g.add(l.mesh);
l.g.visible=false;
l.steps=4+Array.from(l.s).length*2;
l.rot0=(l.r[1]-.5)*.07;
}
// 「ジ・ジ・ジー」と刻むように進む:段ごとに素早く動いて止まる
const stepped=(u,n)=>{const s=clamp(u)*n,f=s-Math.floor(s);return clamp((Math.floor(s)+smooth(f/.45))/n);};
function pose(t){
for(const l of items){
if(!l.pw)continue;
const pu=clamp((t-l.t0)/(ent*1.2)),qu=1-clamp((life-t-ostag*(1-l.o))/(outro*1.1));
const prog=stepped(pu,l.steps),from=stepped(qu,l.steps);
const vis=pu>0&&t<life&&from<.999;
l.g.visible=vis;
if(!vis){l.mesh.visible=false;l.shade.visible=false;continue;}
const printing=prog<.999&&prog>0;
const jit=printing?Math.sin(t*95+l.i*2)*.9*(l.fs/42):0;
l.g.position.set(l.x,l.y+jit,0);
l.g.rotation.z=l.rot0;
l.g.scale.setScalar(l.sc);
const w=l.pw0,h=l.ph0;
l.quad.scale.set(w+2*MARGIN,h+2*MARGIN,1);
const m=l.mat.uniforms;
m.u_half.value.set(w/2,h/2);m.u_size.value.set(w+2*MARGIN,h+2*MARGIN);m.u_alpha.value=1;m.u_prog.value=prog;m.u_from.value=from;
const xr=-w/2+prog*w,xl=-w/2+from*w;
for(const tx of [l.mesh,l.shade]){tx.visible=true;tx.clipRect=[xl,-big,xr,big];}
l.mesh.fillOpacity=1;l.shade.fillOpacity=.55;
}
}
items.forEach(initItem);
update(0);
return {update,dispose};
語ごとに色違いの二重枠の印を、上からドンと押し当てます。少し沈んで、インクがかすれます。
メソッド工房の「文字モーション」のサンプルから選びます。
// @fonts
// 語ごとに色違いの二重枠の印を、上からドンと押し当てる。少し沈み、インクがかすれてムラになる。
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,accent,color,fonts,fontsFor,fontURL}=api;
const NL=String.fromCharCode(10);
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const hexNum=hex=>{let h=String(hex).trim().replace('#','');if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];return parseInt(h.slice(0,6),16)||0;};
const luma=hex=>{const n=hexNum(hex);return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255};
// シェーダーに渡す色は、色空間の変換をしない生の値で渡す
const rgb=hex=>{const n=hexNum(hex);return new THREE.Vector3((n>>16&255)/255,(n>>8&255)/255,(n&255)/255);};
const PUNCT='、。,.!?!?…「」『』()()・~〜ー-';
const PART='のはがをにへともやでよねかなてたも';
// 語に当たる run の大きさ・強調・色を、歌詞の文字位置 [start,end) から求める
const runInfo=(start,end)=>{
let o=0,sum=0,n=0,emph=false,col=null;
for(const r of runs||[]){
const len=String(r.text||'').length,a=Math.max(start,o),b=Math.min(end,o+len);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);n+=b-a;if(r.emphasis)emph=true;if(r.color&&!col)col=r.color;}
o+=len;
}
return {scale:n?sum/n:1,emph,col};
};
// その語の全部の文字を持っている書体だけから選ぶ(なければ選んだ書体)
const pickFont=(s,names,r)=>{
const cand=[];
for(const k of names){
if(fonts&&fonts[k]&&Array.from(s).every(c=>!c.trim()||fontsFor(c).includes(k)))cand.push(fonts[k]);
}
cand.push(fontURL);
return cand[Math.min(cand.length-1,Math.floor(r*cand.length))];
};
const makeQuad=(mat,w,h)=>{const m=new THREE.Mesh(new THREE.PlaneGeometry(w||1,h||1),mat);m.frustumCulled=false;return m;};
const VERT=['varying vec2 vUv;','void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}'].join(NL);
const GLSL_COMMON=[
'precision highp float;',
'varying vec2 vUv;',
'float hash21(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}',
'float vnoise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);',
' return mix(mix(hash21(i),hash21(i+vec2(1.,0.)),f.x),mix(hash21(i+vec2(0.,1.)),hash21(i+vec2(1.,1.)),f.x),f.y);}',
'float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}'
].join(NL);
const CFG={cap:7,maxChars:4,fonts:['dela','mincho'],gap:.18,vgap:.2,wob:.1,
inks:['#d92b3a','#2352c8','#1f8f55','#7b3fb2','#23242b','#e0661f'],
papers:['#f8f2e2','#fbe8dc','#e6f1f7','#eef3df','#ece3f2','#ead7ae'],shift:Math.floor(random()*6),
style(l){
// 印の色は語ごとに回す。強調語はアクセント色(明るすぎるときは濃くする)
let ink=CFG.inks[(l.i+CFG.shift)%CFG.inks.length];
const given=l.info.col||(l.info.emph?accent:null);
if(given){
const n=hexNum(given),k=luma(given)>.55?.55:1;
const c=v=>Math.round(v*k).toString(16).padStart(2,'0');
ink='#'+c(n>>16&255)+c(n>>8&255)+c(n&255);
}
l.ink=ink;l.paper=CFG.papers[(l.i*2+CFG.shift)%CFG.papers.length];
},
size(l){return [l.tw0+l.fs*.95,l.fs*1.5];}};
// 歌詞を語に区切る(日本語は Intl.Segmenter。助詞と句読点は前の語につけ、長い語は割り、語が多すぎるときはまとめる)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').split(NL).forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({a:p.index,b:p.index+p.segment.length});}}
else{for(let i=0;i<line.length;i+=2)parts.push({a:i,b:Math.min(line.length,i+2)});}
const m=[];
for(const p of parts){
const s=line.slice(p.a,p.b),last=m[m.length-1];
const isP=Array.from(s).every(c=>PUNCT.includes(c));
const isJ=s.length===1&&PART.includes(s);
if(last&&(isP||(isJ&&last.b-last.a<CFG.maxChars)))last.b=p.b;else m.push({a:p.a,b:p.b});
}
for(const q of m)out.push({a:q.a,b:q.b,line,row,off});
off+=line.length+1;
});
// run の大きさ・強調・色が変わるところでは語を分ける(私 は 元気 です…と語ごとに大きさを変えられるように)
const sigAt=pos=>{let o=0;for(const r of runs||[]){const n=String(r.text||'').length;if(pos<o+n)return (Number.isFinite(r.scale)&&r.scale>0?r.scale:1)+'|'+(r.emphasis?1:0)+'|'+(r.color||'');o+=n;}return '1|0|';};
let list=[];
for(const q of out){
let a=q.a;
for(let i=q.a+1;i<=q.b;i++){
if(i===q.b||sigAt(q.off+i)!==sigAt(q.off+i-1)){
const s=q.line.slice(a,i),sig=sigAt(q.off+a);
if(s.length>CFG.maxChars&&s.charCodeAt(0)>255){
const k=Math.ceil(s.length/CFG.maxChars),sz=Math.ceil(s.length/k);
for(let j=0;j<s.length;j+=sz)list.push({...q,a:a+j,b:a+Math.min(s.length,j+sz),sig});
}else list.push({...q,a,b:i,sig});
a=i;
}
}
}
const squash=(strict,limit)=>{
while(list.length>limit){
let bi=-1,bl=1e9;
for(let i=0;i<list.length-1;i++){
if(list[i].row!==list[i+1].row||(strict&&list[i].sig!==list[i+1].sig))continue;
const l=list[i+1].b-list[i].a;if(l<bl){bl=l;bi=i;}
}
if(bi<0)break;
list.splice(bi,2,{...list[bi],b:list[bi+1].b});
}
};
squash(true,CFG.cap);
squash(false,Math.max(CFG.cap,14));
if(list.length===1&&list[0].b-list[0].a>=4){
const q=list[0],h=Math.ceil((q.b-q.a)/2);
list=[{...q,b:q.a+h},{...q,a:q.a+h}];
}
return list;
})();
const root=new THREE.Group();scene.add(root);
const disposables=[];
const items=[];
const userScaled=(runs||[]).some(r=>Number.isFinite(r.scale)&&r.scale>0&&r.scale!==1);
wordList.forEach((w,i)=>{
const s=w.line.slice(w.a,w.b),start=w.off+w.a,info=runInfo(start,start+s.length);
// 指定がなければ、大・小・大・小…と語ごとに大きさを変える(run の scale があるときはそれを活かす)
const big=userScaled?1+(random()-.5)*.14:(i%2?.8+.18*random():1.04+.28*random());
const l={i,s,start,row:w.row,info,sc:info.scale,big,r:[random(),random(),random(),random()]};
l.fs=em*big;
CFG.style(l);
l.mesh=createText(s,{textStart:start,font:pickFont(s,CFG.fonts,l.r[3]),fontSize:l.fs,anchorX:'center',anchorY:'middle',color:l.ink,colorRanges:null});
l.mesh.fillOpacity=0;l.mesh.visible=false;
l.texts=[l.mesh];
items.push(l);
});
const N=items.length;
const spread=N>1?Math.min(life*.3,2.2):0,ent=Math.min(.5,life*.17),outro=Math.min(.5,life*.17),ostag=Math.min(.12,life*.05);
items.forEach((l,k)=>{l.t0=N>1?spread*k/(N-1):0;l.o=N>1?k/(N-1):.5;});
let fit=1,laid=false,dead=false;
function measure(l){
const b=l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
l.tw0=(b&&b.length===4&&b.every(Number.isFinite))?Math.max(l.fs*.3,b[2]-b[0]):l.fs*Array.from(l.s).length*.95;
const z=CFG.size(l);
l.pw0=z[0];l.ph0=z[1];l.pw=l.pw0*l.sc;l.ph=l.ph0*l.sc;
}
function flow(r,k,gap,limit){
const total=r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0),cap=k>1?total/k*1.15+gap:limit;
const out=[];let cur=[],x=0;
for(const l of r){if(cur.length&&x+gap+l.pw>cap){out.push(cur);cur=[];x=0;}x+=(cur.length?gap:0)+l.pw;cur.push(l);}
out.push(cur);return out;
}
function layout(){
const gap=em*CFG.gap,vgap=em*CFG.vgap,limit=W*.84;
const hard=[];for(const l of items)(hard[l.row]=hard[l.row]||[]).push(l);
const rowsList=hard.filter(Boolean);
let best=null;
for(let k=1;k<=6;k++){
const lines=[];for(const r of rowsList)lines.push(...flow(r,k,gap,1e9));
const lh=lines.map(r=>Math.max(...r.map(l=>l.ph)));
let totalH=0;lines.forEach((r,j)=>{totalH+=lh[j]+(j?vgap:0);});
const maxW=Math.max(1,...lines.map(r=>r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0)));
const f=Math.min(1,W*.84/maxW,H*.7/Math.max(1,totalH));
if(!best||f>best.f+.001)best={f,lines,lh,totalH};
}
let y=best.totalH/2;
best.lines.forEach((r,k)=>{
const w=r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0);
let x=-w/2;y-=best.lh[k]/2;
for(const l of r){l.x=x+l.pw/2;l.y=y+(l.r[2]-.5)*l.ph*CFG.wob;x+=l.pw+gap;}
y-=best.lh[k]/2+vgap;
});
fit=best.f;
root.scale.setScalar(fit);
}
function update(time){
if(dead)return;
const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;
for(const l of items){const inf=l.mesh.textRenderInfo;if(inf&&l.info2!==inf){l.info2=inf;changed=true;}}
if(changed){for(const l of items)measure(l);layout();laid=true;}
pose(t);
}
function dispose(){
if(dead)return;dead=true;
for(const l of items)for(const m of l.texts){root.remove(m);m.dispose();}
for(const d of disposables)d.dispose();
scene.remove(root);
}
const STAMP_FRAG=[GLSL_COMMON,
'uniform vec2 u_half;uniform vec2 u_size;uniform float u_alpha;uniform float u_unit;uniform float u_seed;uniform float u_mode;',
'uniform float u_lift;uniform float u_wet;uniform float u_inkA;uniform vec3 u_paper;uniform vec3 u_ink;uniform vec2 u_frame;',
// インクの残り具合(0 でかすれて抜ける、1 で濃い)。大きなムラと細かい粒と、押す強さの偏り
'float inkV(vec2 p){',
' float blotch=vnoise(p*.045+u_seed*9.);',
' float grain=vnoise(p*.62+u_seed*3.7);',
' float fine=hash21(floor(p*1.1)+u_seed*5.);',
' float press=dot(normalize(vec2(.55,.83)),p/u_half);',
' return grain*.5+blotch*.55+fine*.14+press*.14+.06+u_wet*.12;',
'}',
'void main(){',
' vec2 p=(vUv-.5)*u_size;',
' float U=u_unit;',
' float dCard=sdRB(p,u_half,1.6*U);',
' float cardA=1.-smoothstep(-.7,.7,dCard);',
' vec3 pap=u_paper*(.985+.04*vnoise(p*.55+3.))*(.97+.05*smoothstep(-u_half.y,u_half.y,p.y));',
' pap*=1.-.08*smoothstep(-5.*U,0.,dCard+0.)*.0;',
' float v=inkV(p);',
' float k=smoothstep(.34,.52,v);', // 枠のかすれ
' float kt=smoothstep(.22,.40,v);', // 文字のかすれ(読めるように、枠より控えめ)
' if(u_mode>.5){', // 文字の上に重ねる:インクが抜けたところだけ紙の色を返す
' float inside=1.-smoothstep(0.,2.,abs(sdRB(p,u_half-vec2(3.*U),U))-2.)*0.;',
' gl_FragColor=vec4(pap,cardA*(1.-kt)*.9*u_alpha*u_inkA);',
' return;',
' }',
' float w1=(2.7+u_wet*.9)*U,gap=2.4*U,w2=1.2*U;',
' float dO=abs(sdRB(p,u_frame,3.*U))-w1*.5;',
' float dI=abs(sdRB(p,u_frame-vec2(w1*.5+gap+w2*.5),2.*U))-w2*.5;',
' float fr=max(1.-smoothstep(-.5,.5,dO),1.-smoothstep(-.5,.5,dI));',
' float inkA=fr*k*(.82+.18*u_wet)*u_inkA;',
' vec3 inkC=mix(u_ink,u_ink*.78,u_wet);',
' vec3 col=mix(pap,inkC,inkA);',
' float sh=(1.-smoothstep(-2.*U-u_lift*6.*U,2.*U+u_lift*6.*U,sdRB(p-vec2(1.4+u_lift*4.,-2.6-u_lift*8.)*U,u_half,1.6*U)))*(.34-.12*u_lift);',
' float oa=cardA+sh*(1.-cardA);',
' vec3 rgb=oa>.001?col*cardA/oa:vec3(0.);',
' gl_FragColor=vec4(rgb,oa*u_alpha);',
'}'].join(NL);
const MARGIN=Math.round(Math.max(22,em*.7));
function initItem(l){
l.outer=new THREE.Group();root.add(l.outer);
const un=Math.max(.5,l.fs/42);
const mk=mode=>new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:STAMP_FRAG,
uniforms:{u_half:{value:new THREE.Vector2(1,1)},u_size:{value:new THREE.Vector2(1,1)},u_alpha:{value:0},u_unit:{value:un},u_seed:{value:l.r[0]},u_mode:{value:mode},
u_lift:{value:0},u_wet:{value:0},u_inkA:{value:0},u_paper:{value:rgb(l.paper)},u_ink:{value:rgb(l.ink)},u_frame:{value:new THREE.Vector2(1,1)}}});
l.mat=mk(0);l.matTop=mk(1);disposables.push(l.mat,l.matTop);
l.quad=makeQuad(l.mat,1,1);l.quad.renderOrder=l.i*4;
l.top=makeQuad(l.matTop,1,1);l.top.renderOrder=l.i*4+2;
disposables.push(l.quad.geometry,l.top.geometry);
l.mesh.renderOrder=l.i*4+1;l.mesh.material.depthTest=false;l.mesh.material.depthWrite=false;
l.outer.add(l.quad,l.mesh,l.top);
l.outer.visible=false;
l.rot0=(l.r[1]-.5)*.2+(l.r[1]>.5?.03:-.03);
l.dir=l.i%2?1:-1;
}
function pose(t){
for(const l of items){
if(!l.pw)continue;
const u=clamp((t-l.t0)/ent),q=1-clamp((life-t-ostag*(1-l.o))/outro);
const hit=.4; // 押し当てる瞬間(登場の半ば)
const down=clamp(u/hit),after=clamp((u-hit)/(1-hit));
const a=smooth(u*9)*(1-smooth((q-.15)/.85));
l.outer.visible=a>.003&&t<life;
if(!l.outer.visible){l.mesh.visible=false;continue;}
const sink=u>=hit?Math.exp(-7*after)*Math.cos(11*after):0; // 押された瞬間に少し沈んで戻る
const s=(1+.6*(1-down)*(1-down))*(1-.07*sink)*(1+.05*q);
const lift=(1-down)+q*.9;
const wet=u>=hit?Math.exp(-3.2*after*(ent/.5)):0;
const ink=smooth((u-hit+.02)*14)*(1-smooth((q-.15)/.85));
const tw=(1-down)*l.dir*.3+q*q*l.dir*.5;
l.outer.position.set(l.x+q*q*l.dir*W*.06/fit,l.y+(1-down)*(1-down)*H*.09/fit-q*q*H*.2/fit,0);
l.outer.rotation.z=l.rot0+tw;
l.outer.scale.setScalar(l.sc*s);
const w=l.pw0,h=l.ph0;
const sz=[w+2*MARGIN,h+2*MARGIN];
for(const [qd,mt] of [[l.quad,l.mat],[l.top,l.matTop]]){
qd.scale.set(sz[0],sz[1],1);
const m=mt.uniforms;
m.u_half.value.set(w/2,h/2);m.u_size.value.set(sz[0],sz[1]);m.u_alpha.value=a;m.u_lift.value=lift;m.u_wet.value=wet;m.u_inkA.value=ink;
m.u_frame.value.set(w/2-l.fs*.18,h/2-l.fs*.18);
}
l.mesh.visible=true;l.mesh.fillOpacity=a*.94*ink;
}
}
items.forEach(initItem);
update(0);
return {update,dispose};
語の後ろへ蛍光ペンの帯を左から引いて塗ります。強調語は別の色で二度塗りします。
メソッド工房の「文字モーション」のサンプルから選びます。
// @fonts
// 語の後ろに蛍光ペンの帯を左から引いて、塗られた文字は濃い色に変わる。強調語は別の色で二度塗り。ペンが語から語へ移っていく。
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,accent,color,fonts,fontsFor,fontURL}=api;
const NL=String.fromCharCode(10);
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const hexNum=hex=>{let h=String(hex).trim().replace('#','');if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];return parseInt(h.slice(0,6),16)||0;};
const luma=hex=>{const n=hexNum(hex);return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255};
// シェーダーに渡す色は、色空間の変換をしない生の値で渡す
const rgb=hex=>{const n=hexNum(hex);return new THREE.Vector3((n>>16&255)/255,(n>>8&255)/255,(n&255)/255);};
const PUNCT='、。,.!?!?…「」『』()()・~〜ー-';
const PART='のはがをにへともやでよねかなてたも';
// 語に当たる run の大きさ・強調・色を、歌詞の文字位置 [start,end) から求める
const runInfo=(start,end)=>{
let o=0,sum=0,n=0,emph=false,col=null;
for(const r of runs||[]){
const len=String(r.text||'').length,a=Math.max(start,o),b=Math.min(end,o+len);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);n+=b-a;if(r.emphasis)emph=true;if(r.color&&!col)col=r.color;}
o+=len;
}
return {scale:n?sum/n:1,emph,col};
};
// その語の全部の文字を持っている書体だけから選ぶ(なければ選んだ書体)
const pickFont=(s,names,r)=>{
const cand=[];
for(const k of names){
if(fonts&&fonts[k]&&Array.from(s).every(c=>!c.trim()||fontsFor(c).includes(k)))cand.push(fonts[k]);
}
cand.push(fontURL);
return cand[Math.min(cand.length-1,Math.floor(r*cand.length))];
};
const makeQuad=(mat,w,h)=>{const m=new THREE.Mesh(new THREE.PlaneGeometry(w||1,h||1),mat);m.frustumCulled=false;return m;};
const VERT=['varying vec2 vUv;','void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}'].join(NL);
const GLSL_COMMON=[
'precision highp float;',
'varying vec2 vUv;',
'float hash21(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}',
'float vnoise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);',
' return mix(mix(hash21(i),hash21(i+vec2(1.,0.)),f.x),mix(hash21(i+vec2(0.,1.)),hash21(i+vec2(1.,1.)),f.x),f.y);}',
'float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}'
].join(NL);
const CFG={cap:8,maxChars:5,fonts:['marker'],gap:.2,vgap:.3,wob:.08,
bands:['#fff04d','#ff7fc4','#8dff6a','#59e8ff','#ffb347'],shift:Math.floor(random()*5),
style(l){
// 蛍光ペンの色は語ごとに回す。強調語はアクセント色で、二度塗り
let band;
if(l.info.col)band=l.info.col;
else if(l.info.emph)band=accent;
else band=CFG.bands[(l.i+CFG.shift)%CFG.bands.length];
l.band=band;
l.mode=(l.info.emph||l.info.col)?2:(l.r[0]<.45?0:(l.r[0]<.75?1:2));
l.ink=color; // 塗られる前は歌詞の文字色
l.hiInk=luma(band)>.5?'#1b1c24':'#ffffff'; // 塗られたところの文字色
},
size(l){return [l.tw0+l.fs*.55,l.fs*1.25];}};
// 歌詞を語に区切る(日本語は Intl.Segmenter。助詞と句読点は前の語につけ、長い語は割り、語が多すぎるときはまとめる)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').split(NL).forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({a:p.index,b:p.index+p.segment.length});}}
else{for(let i=0;i<line.length;i+=2)parts.push({a:i,b:Math.min(line.length,i+2)});}
const m=[];
for(const p of parts){
const s=line.slice(p.a,p.b),last=m[m.length-1];
const isP=Array.from(s).every(c=>PUNCT.includes(c));
const isJ=s.length===1&&PART.includes(s);
if(last&&(isP||(isJ&&last.b-last.a<CFG.maxChars)))last.b=p.b;else m.push({a:p.a,b:p.b});
}
for(const q of m)out.push({a:q.a,b:q.b,line,row,off});
off+=line.length+1;
});
// run の大きさ・強調・色が変わるところでは語を分ける(私 は 元気 です…と語ごとに大きさを変えられるように)
const sigAt=pos=>{let o=0;for(const r of runs||[]){const n=String(r.text||'').length;if(pos<o+n)return (Number.isFinite(r.scale)&&r.scale>0?r.scale:1)+'|'+(r.emphasis?1:0)+'|'+(r.color||'');o+=n;}return '1|0|';};
let list=[];
for(const q of out){
let a=q.a;
for(let i=q.a+1;i<=q.b;i++){
if(i===q.b||sigAt(q.off+i)!==sigAt(q.off+i-1)){
const s=q.line.slice(a,i),sig=sigAt(q.off+a);
if(s.length>CFG.maxChars&&s.charCodeAt(0)>255){
const k=Math.ceil(s.length/CFG.maxChars),sz=Math.ceil(s.length/k);
for(let j=0;j<s.length;j+=sz)list.push({...q,a:a+j,b:a+Math.min(s.length,j+sz),sig});
}else list.push({...q,a,b:i,sig});
a=i;
}
}
}
const squash=(strict,limit)=>{
while(list.length>limit){
let bi=-1,bl=1e9;
for(let i=0;i<list.length-1;i++){
if(list[i].row!==list[i+1].row||(strict&&list[i].sig!==list[i+1].sig))continue;
const l=list[i+1].b-list[i].a;if(l<bl){bl=l;bi=i;}
}
if(bi<0)break;
list.splice(bi,2,{...list[bi],b:list[bi+1].b});
}
};
squash(true,CFG.cap);
squash(false,Math.max(CFG.cap,14));
if(list.length===1&&list[0].b-list[0].a>=4){
const q=list[0],h=Math.ceil((q.b-q.a)/2);
list=[{...q,b:q.a+h},{...q,a:q.a+h}];
}
return list;
})();
const root=new THREE.Group();scene.add(root);
const disposables=[];
const items=[];
const userScaled=(runs||[]).some(r=>Number.isFinite(r.scale)&&r.scale>0&&r.scale!==1);
wordList.forEach((w,i)=>{
const s=w.line.slice(w.a,w.b),start=w.off+w.a,info=runInfo(start,start+s.length);
// 指定がなければ、大・小・大・小…と語ごとに大きさを変える(run の scale があるときはそれを活かす)
const big=userScaled?1+(random()-.5)*.14:(i%2?.8+.18*random():1.04+.28*random());
const l={i,s,start,row:w.row,info,sc:info.scale,big,r:[random(),random(),random(),random()]};
l.fs=em*big;
CFG.style(l);
l.mesh=createText(s,{textStart:start,font:pickFont(s,CFG.fonts,l.r[3]),fontSize:l.fs,anchorX:'center',anchorY:'middle',color:l.ink,colorRanges:null});
l.mesh.fillOpacity=0;l.mesh.visible=false;
l.texts=[l.mesh];
items.push(l);
});
const N=items.length;
const spread=N>1?Math.min(life*.3,2.2):0,ent=Math.min(.5,life*.17),outro=Math.min(.5,life*.17),ostag=Math.min(.12,life*.05);
items.forEach((l,k)=>{l.t0=N>1?spread*k/(N-1):0;l.o=N>1?k/(N-1):.5;});
let fit=1,laid=false,dead=false;
function measure(l){
const b=l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
l.tw0=(b&&b.length===4&&b.every(Number.isFinite))?Math.max(l.fs*.3,b[2]-b[0]):l.fs*Array.from(l.s).length*.95;
const z=CFG.size(l);
l.pw0=z[0];l.ph0=z[1];l.pw=l.pw0*l.sc;l.ph=l.ph0*l.sc;
}
function flow(r,k,gap,limit){
const total=r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0),cap=k>1?total/k*1.15+gap:limit;
const out=[];let cur=[],x=0;
for(const l of r){if(cur.length&&x+gap+l.pw>cap){out.push(cur);cur=[];x=0;}x+=(cur.length?gap:0)+l.pw;cur.push(l);}
out.push(cur);return out;
}
function layout(){
const gap=em*CFG.gap,vgap=em*CFG.vgap,limit=W*.84;
const hard=[];for(const l of items)(hard[l.row]=hard[l.row]||[]).push(l);
const rowsList=hard.filter(Boolean);
let best=null;
for(let k=1;k<=6;k++){
const lines=[];for(const r of rowsList)lines.push(...flow(r,k,gap,1e9));
const lh=lines.map(r=>Math.max(...r.map(l=>l.ph)));
let totalH=0;lines.forEach((r,j)=>{totalH+=lh[j]+(j?vgap:0);});
const maxW=Math.max(1,...lines.map(r=>r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0)));
const f=Math.min(1,W*.84/maxW,H*.7/Math.max(1,totalH));
if(!best||f>best.f+.001)best={f,lines,lh,totalH};
}
let y=best.totalH/2;
best.lines.forEach((r,k)=>{
const w=r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0);
let x=-w/2;y-=best.lh[k]/2;
for(const l of r){l.x=x+l.pw/2;l.y=y+(l.r[2]-.5)*l.ph*CFG.wob;x+=l.pw+gap;}
y-=best.lh[k]/2+vgap;
});
fit=best.f;
root.scale.setScalar(fit);
}
function update(time){
if(dead)return;
const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;
for(const l of items){const inf=l.mesh.textRenderInfo;if(inf&&l.info2!==inf){l.info2=inf;changed=true;}}
if(changed){for(const l of items)measure(l);layout();laid=true;}
pose(t);
}
function dispose(){
if(dead)return;dead=true;
for(const l of items)for(const m of l.texts){root.remove(m);m.dispose();}
for(const d of disposables)d.dispose();
scene.remove(root);
}
const BAND_FRAG=[GLSL_COMMON,
'uniform vec2 u_half;uniform vec2 u_size;uniform float u_alpha;uniform float u_prog;uniform float u_dir;uniform float u_unit;uniform float u_seed;uniform float u_skew;',
'uniform vec3 u_col;',
'void main(){',
' vec2 p=(vUv-.5)*u_size;',
' vec2 q=vec2(p.x-u_skew*u_unit*p.y/u_half.y,p.y);', // ペン先の斜めの切り口
' float w1=(vnoise(vec2(p.x*.05+u_seed*10.,u_seed*3.))-.5)*2.8*u_unit;',
' float w2=(vnoise(vec2(p.x*.06+u_seed*5.,7.))-.5)*2.8*u_unit;', // 手で引いた縁のゆらぎ
' float top=u_half.y*.94+w1,bot=-u_half.y*.94+w2;',
' float a=smoothstep(top+.6,top-.6,q.y)*smoothstep(bot-.6,bot+.6,q.y);',
' float ex=u_half.x*.96;',
' a*=smoothstep(ex+.7,ex-.7,abs(q.x));',
' float r=(q.x+u_half.x)/(2.*u_half.x);',
' if(u_dir<0.)r=1.-r;',
' a*=smoothstep(u_prog*1.03+.012,u_prog*1.03-.012,r);',
' float streak=.92+.1*vnoise(vec2(p.x*.02,p.y*.7/u_unit+u_seed*7.));',
' float pool=1.+.28*smoothstep(.16,0.,r);', // 引き始めはインクが濃い
' vec3 c=u_col*streak;',
' gl_FragColor=vec4(c,clamp(a*.86*pool,0.,1.)*u_alpha);',
'}'].join(NL);
const big=1e4;
const pen=new THREE.Group();pen.visible=false;root.add(pen);
const penMats={};
(function(){
const L=em*1.7,wp=em*.36;
const poly=pts=>{const sh=new THREE.Shape(pts.map(p=>new THREE.Vector2(p[0],p[1])));const g=new THREE.ShapeGeometry(sh);disposables.push(g);return g;};
const mat=(c,o)=>{const m=new THREE.MeshBasicMaterial({color:c,transparent:true,opacity:o===undefined?1:o,depthTest:false,depthWrite:false});disposables.push(m);return m;};
const add=(g,m,z)=>{const mesh=new THREE.Mesh(g,m);mesh.renderOrder=10000+z;mesh.frustumCulled=false;pen.add(mesh);return mesh;};
const o=1.3;
add(poly([[-o,-wp*.2-o],[-o,wp*.2+o],[wp*.6+o,wp*.5+o],[L+o,wp*.5+o],[L+o,-wp*.5-o],[wp*.6+o,-wp*.5-o]]),mat('#16171d'),0);
penMats.tip=mat('#fff04d');penMats.stripe=mat('#fff04d');
add(poly([[0,-wp*.2],[0,wp*.2],[wp*.6,wp*.5],[wp*.6,-wp*.5]]),penMats.tip,1);
add(poly([[wp*.6,-wp*.5],[wp*.6,wp*.5],[L,wp*.5],[L,-wp*.5]]),mat('#f6f6f1'),1);
add(poly([[wp*.6,-wp*.5],[wp*.6,wp*.5],[wp*.6+L*.14,wp*.5],[wp*.6+L*.14,-wp*.5]]),penMats.stripe,2);
add(poly([[L*.9,-wp*.5],[L*.9,wp*.5],[L,wp*.5],[L,-wp*.5]]),mat('#2b2d37'),2);
pen.rotation.z=.95;pen.scale.setScalar(1);
})();
const penAlpha=()=>{for(const k in penMats)penMats[k].opacity=pen.userData.a;};
function initItem(l){
l.g=new THREE.Group();root.add(l.g);
const un=Math.max(.5,l.fs/42);
// 帯:全面/下線/二度塗り(行って戻る)
const defs=l.mode===0?[{y:0,h:.9,dir:1,a:0,b:1}]:l.mode===1?[{y:-.24,h:.38,dir:1,a:0,b:1}]:[{y:.1,h:.62,dir:1,a:0,b:.5},{y:-.1,h:.62,dir:-1,a:.5,b:1}];
l.bands=defs.map((d,k)=>{
const mat=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:BAND_FRAG,
uniforms:{u_half:{value:new THREE.Vector2(1,1)},u_size:{value:new THREE.Vector2(1,1)},u_alpha:{value:0},u_prog:{value:0},u_dir:{value:d.dir},u_unit:{value:un},
u_seed:{value:l.r[0]*3+k*1.7},u_skew:{value:(l.r[1]>.5?1:-1)*3.2},u_col:{value:rgb(l.band)}}});
disposables.push(mat);
const quad=makeQuad(mat,1,1);quad.renderOrder=l.i*4;l.g.add(quad);disposables.push(quad.geometry);
return {d,mat,quad};
});
l.mesh.renderOrder=l.i*4+2;l.mesh.material.depthTest=false;l.mesh.material.depthWrite=false;
l.g.add(l.mesh);
if(l.mode!==1){
l.dark=createText(l.s,{textStart:l.start,font:l.mesh.font,fontSize:l.fs,anchorX:'center',anchorY:'middle',color:l.hiInk,colorRanges:null});
l.dark.fillOpacity=0;l.dark.visible=false;l.dark.renderOrder=l.i*4+3;l.dark.material.depthTest=false;l.dark.material.depthWrite=false;
l.texts.push(l.dark);l.g.add(l.dark);
}
l.g.visible=false;
l.rot0=(l.r[2]-.5)*.05;
}
// 帯の時間割:語ごとに少しずつずらして、ペンが次の語へ移っていく
const slot=N>0?Math.min(.34,life*.42/N):.2,t00=.08;
const startOf=l=>t00+l.i*slot;
const ease=x=>{x=clamp(x);return x*x*(3-2*x)*.5+x*.5};
function tipPoint(l,k,f){ // 語 l の k 番目の帯の上で、進み具合 f(0..1)のペン先の位置
const b=l.bands[k],d=b.d,x0=-l.pw0/2+l.fs*.1,x1=l.pw0/2-l.fs*.1;
const x=d.dir>0?mix(x0,x1,f):mix(x1,x0,f);
return [l.x+x*l.sc,l.y+d.y*l.ph0*l.sc-l.ph0*l.sc*.06];
}
function pose(t){
const pa=life-t-ostag;
let last=-1;
for(const l of items){
if(!l.pw)continue;
const s=startOf(l),tau=(t-s)/slot,q=1-clamp((life-t-ostag*(1-l.o))/outro);
const aIn=smooth((t-(s-.14))*9),a=aIn*(1-smooth((q-.1)/.9));
l.g.visible=a>.003&&t<life;
if(!l.g.visible){l.mesh.visible=false;if(l.dark)l.dark.visible=false;continue;}
l.g.position.set(l.x,l.y+q*q*em*.4,0);
l.g.rotation.z=l.rot0;
l.g.scale.setScalar(l.sc);
const w=l.pw0,h=l.ph0,span=.78;
let firstEdge=0;
l.bands.forEach((b,k)=>{
const d=b.d,bh=d.h*h,mw=l.fs*.1;
const f=clamp(((tau/span)-d.a)/(d.b-d.a));
b.quad.position.set(0,d.y*h,0);
b.quad.scale.set(w+2*mw,bh,1);
const m=b.mat.uniforms;
m.u_half.value.set((w+2*mw)/2,bh/2);m.u_size.value.set(w+2*mw,bh);m.u_alpha.value=a;m.u_prog.value=ease(f);
if(k===0)firstEdge=ease(f);
});
// 塗られたところだけ、文字を濃い色に切り替える
const edge=-w/2+firstEdge*w;
l.mesh.visible=true;l.mesh.fillOpacity=a;
if(l.dark){
l.dark.visible=true;l.dark.fillOpacity=a;
l.mesh.clipRect=[edge,-big,big,big];l.dark.clipRect=[-big,-big,edge,big];
}
if(tau>=0)last=l.i;
}
// ペン:いま塗っている語の帯の先にいて、次の語へ移る
const first=items[0],lastItem=items[N-1];
if(!first||!first.pw||t>=life){pen.visible=false;return;}
const tEnd=startOf(lastItem)+slot*.78+.12;
if(t>tEnd+.2){pen.visible=false;return;}
let idx=0;
for(let i=0;i<N;i++)if(t>=startOf(items[i]))idx=i;
const l=items[idx];
const tau=(t-startOf(l))/slot;
let px,py,al=1;
if(t<startOf(first)){
const k=smooth(1-(startOf(first)-t)/t00);
const p0=tipPoint(first,0,0);
px=p0[0]+(1-k)*em*1.6;py=p0[1]+(1-k)*em*1.3;al=k;
}else if(tau<.78){
const f=clamp(tau/.78);
const kk=l.bands.length;
const seg=Math.min(kk-1,Math.floor(f*kk)),lf=clamp(f*kk-seg);
const p=tipPoint(l,seg,ease(lf));
px=p[0];py=p[1];
}else{
const f=smooth((tau-.78)/.22);
const kk=l.bands.length,pa0=tipPoint(l,kk-1,1);
if(idx<N-1){const nx=items[idx+1],pb=tipPoint(nx,0,0);px=mix(pa0[0],pb[0],f);py=mix(pa0[1],pb[1],f)+Math.sin(f*Math.PI)*em*.35;}
else{px=pa0[0]+f*em*1.6;py=pa0[1]+f*em*1.3;al=1-smooth((t-startOf(l)-slot*.78)/.2);}
}
const outFade=1-smooth((t-(life-outro))/outro);
pen.visible=al*outFade>.01;
pen.position.set(px,py,0);
pen.userData.a=clamp(al*outFade);
penMats.tip.color.set(l.band);penMats.stripe.color.set(l.band);
penAlpha();
}
items.forEach(initItem);
update(0);
return {update,dispose};
切手やチケット、ちぎれた半券の紙に文字をのせて、語ごとにぽんと並べます。
メソッド工房の「文字モーション」のサンプルから選びます。
// @fonts
// 語ごとに切手・チケット・ちぎれた半券の紙に文字をのせて、下からぽんと出して並べる。パンチ穴や消印の飾り付き。
const {THREE,scene,createText,text,runs,width,height,fontSize,duration,random,accent,color,fonts,fontsFor,fontURL}=api;
const NL=String.fromCharCode(10);
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const hexNum=hex=>{let h=String(hex).trim().replace('#','');if(h.length===3)h=h[0]+h[0]+h[1]+h[1]+h[2]+h[2];return parseInt(h.slice(0,6),16)||0;};
const luma=hex=>{const n=hexNum(hex);return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255};
// シェーダーに渡す色は、色空間の変換をしない生の値で渡す
const rgb=hex=>{const n=hexNum(hex);return new THREE.Vector3((n>>16&255)/255,(n>>8&255)/255,(n&255)/255);};
const PUNCT='、。,.!?!?…「」『』()()・~〜ー-';
const PART='のはがをにへともやでよねかなてたも';
// 語に当たる run の大きさ・強調・色を、歌詞の文字位置 [start,end) から求める
const runInfo=(start,end)=>{
let o=0,sum=0,n=0,emph=false,col=null;
for(const r of runs||[]){
const len=String(r.text||'').length,a=Math.max(start,o),b=Math.min(end,o+len);
if(b>a){const s=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;sum+=s*(b-a);n+=b-a;if(r.emphasis)emph=true;if(r.color&&!col)col=r.color;}
o+=len;
}
return {scale:n?sum/n:1,emph,col};
};
// その語の全部の文字を持っている書体だけから選ぶ(なければ選んだ書体)
const pickFont=(s,names,r)=>{
const cand=[];
for(const k of names){
if(fonts&&fonts[k]&&Array.from(s).every(c=>!c.trim()||fontsFor(c).includes(k)))cand.push(fonts[k]);
}
cand.push(fontURL);
return cand[Math.min(cand.length-1,Math.floor(r*cand.length))];
};
const makeQuad=(mat,w,h)=>{const m=new THREE.Mesh(new THREE.PlaneGeometry(w||1,h||1),mat);m.frustumCulled=false;return m;};
const VERT=['varying vec2 vUv;','void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}'].join(NL);
const GLSL_COMMON=[
'precision highp float;',
'varying vec2 vUv;',
'float hash21(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}',
'float vnoise(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);',
' return mix(mix(hash21(i),hash21(i+vec2(1.,0.)),f.x),mix(hash21(i+vec2(0.,1.)),hash21(i+vec2(1.,1.)),f.x),f.y);}',
'float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}'
].join(NL);
const CFG={cap:7,maxChars:4,fonts:['mincho','round'],gap:.24,vgap:.26,wob:.1,
pal:[['#f6ecd0','#8a5a2b','#3a2a1a'],['#ffd9a0','#9a4a00','#40210a'],['#cfe8ff','#1f4e8a','#10264a'],['#ffc9d4','#9a2a48','#4a1224'],['#d9f2c2','#2d6a1f','#17340f'],['#fff2a0','#7a6a00','#3a3200']],
shift:Math.floor(random()*6),kshift:Math.floor(random()*4),
style(l){
// 切手(0)・左に半券(1)・右に半券(2)・ちぎれた半券(3)を語ごとに回す。強調語はアクセント色の紙
l.kind=[0,1,3,2][(l.i+CFG.kshift)%4];
const given=l.info.col||(l.info.emph?accent:null);
if(given){l.paper=given;l.ink=luma(given)>.55?'#2a1d12':'#ffffff';l.edge=l.ink;}
else{const p=CFG.pal[(l.i+CFG.shift)%CFG.pal.length];l.paper=p[0];l.edge=p[1];l.ink=p[2];}
l.stub=l.kind===0?0:(l.kind===3?l.fs*.45:l.fs*.85);
},
size(l){return [l.tw0+l.fs*(l.kind===0?1.1:.95)+l.stub,l.fs*(l.kind===0?1.7:1.55)];}};
// 歌詞を語に区切る(日本語は Intl.Segmenter。助詞と句読点は前の語につけ、長い語は割り、語が多すぎるときはまとめる)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').split(NL).forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({a:p.index,b:p.index+p.segment.length});}}
else{for(let i=0;i<line.length;i+=2)parts.push({a:i,b:Math.min(line.length,i+2)});}
const m=[];
for(const p of parts){
const s=line.slice(p.a,p.b),last=m[m.length-1];
const isP=Array.from(s).every(c=>PUNCT.includes(c));
const isJ=s.length===1&&PART.includes(s);
if(last&&(isP||(isJ&&last.b-last.a<CFG.maxChars)))last.b=p.b;else m.push({a:p.a,b:p.b});
}
for(const q of m)out.push({a:q.a,b:q.b,line,row,off});
off+=line.length+1;
});
// run の大きさ・強調・色が変わるところでは語を分ける(私 は 元気 です…と語ごとに大きさを変えられるように)
const sigAt=pos=>{let o=0;for(const r of runs||[]){const n=String(r.text||'').length;if(pos<o+n)return (Number.isFinite(r.scale)&&r.scale>0?r.scale:1)+'|'+(r.emphasis?1:0)+'|'+(r.color||'');o+=n;}return '1|0|';};
let list=[];
for(const q of out){
let a=q.a;
for(let i=q.a+1;i<=q.b;i++){
if(i===q.b||sigAt(q.off+i)!==sigAt(q.off+i-1)){
const s=q.line.slice(a,i),sig=sigAt(q.off+a);
if(s.length>CFG.maxChars&&s.charCodeAt(0)>255){
const k=Math.ceil(s.length/CFG.maxChars),sz=Math.ceil(s.length/k);
for(let j=0;j<s.length;j+=sz)list.push({...q,a:a+j,b:a+Math.min(s.length,j+sz),sig});
}else list.push({...q,a,b:i,sig});
a=i;
}
}
}
const squash=(strict,limit)=>{
while(list.length>limit){
let bi=-1,bl=1e9;
for(let i=0;i<list.length-1;i++){
if(list[i].row!==list[i+1].row||(strict&&list[i].sig!==list[i+1].sig))continue;
const l=list[i+1].b-list[i].a;if(l<bl){bl=l;bi=i;}
}
if(bi<0)break;
list.splice(bi,2,{...list[bi],b:list[bi+1].b});
}
};
squash(true,CFG.cap);
squash(false,Math.max(CFG.cap,14));
if(list.length===1&&list[0].b-list[0].a>=4){
const q=list[0],h=Math.ceil((q.b-q.a)/2);
list=[{...q,b:q.a+h},{...q,a:q.a+h}];
}
return list;
})();
const root=new THREE.Group();scene.add(root);
const disposables=[];
const items=[];
const userScaled=(runs||[]).some(r=>Number.isFinite(r.scale)&&r.scale>0&&r.scale!==1);
wordList.forEach((w,i)=>{
const s=w.line.slice(w.a,w.b),start=w.off+w.a,info=runInfo(start,start+s.length);
// 指定がなければ、大・小・大・小…と語ごとに大きさを変える(run の scale があるときはそれを活かす)
const big=userScaled?1+(random()-.5)*.14:(i%2?.8+.18*random():1.04+.28*random());
const l={i,s,start,row:w.row,info,sc:info.scale,big,r:[random(),random(),random(),random()]};
l.fs=em*big;
CFG.style(l);
l.mesh=createText(s,{textStart:start,font:pickFont(s,CFG.fonts,l.r[3]),fontSize:l.fs,anchorX:'center',anchorY:'middle',color:l.ink,colorRanges:null});
l.mesh.fillOpacity=0;l.mesh.visible=false;
l.texts=[l.mesh];
items.push(l);
});
const N=items.length;
const spread=N>1?Math.min(life*.3,2.2):0,ent=Math.min(.5,life*.17),outro=Math.min(.5,life*.17),ostag=Math.min(.12,life*.05);
items.forEach((l,k)=>{l.t0=N>1?spread*k/(N-1):0;l.o=N>1?k/(N-1):.5;});
let fit=1,laid=false,dead=false;
function measure(l){
const b=l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
l.tw0=(b&&b.length===4&&b.every(Number.isFinite))?Math.max(l.fs*.3,b[2]-b[0]):l.fs*Array.from(l.s).length*.95;
const z=CFG.size(l);
l.pw0=z[0];l.ph0=z[1];l.pw=l.pw0*l.sc;l.ph=l.ph0*l.sc;
}
function flow(r,k,gap,limit){
const total=r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0),cap=k>1?total/k*1.15+gap:limit;
const out=[];let cur=[],x=0;
for(const l of r){if(cur.length&&x+gap+l.pw>cap){out.push(cur);cur=[];x=0;}x+=(cur.length?gap:0)+l.pw;cur.push(l);}
out.push(cur);return out;
}
function layout(){
const gap=em*CFG.gap,vgap=em*CFG.vgap,limit=W*.84;
const hard=[];for(const l of items)(hard[l.row]=hard[l.row]||[]).push(l);
const rowsList=hard.filter(Boolean);
let best=null;
for(let k=1;k<=6;k++){
const lines=[];for(const r of rowsList)lines.push(...flow(r,k,gap,1e9));
const lh=lines.map(r=>Math.max(...r.map(l=>l.ph)));
let totalH=0;lines.forEach((r,j)=>{totalH+=lh[j]+(j?vgap:0);});
const maxW=Math.max(1,...lines.map(r=>r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0)));
const f=Math.min(1,W*.84/maxW,H*.7/Math.max(1,totalH));
if(!best||f>best.f+.001)best={f,lines,lh,totalH};
}
let y=best.totalH/2;
best.lines.forEach((r,k)=>{
const w=r.reduce((s,l,j)=>s+l.pw+(j?gap:0),0);
let x=-w/2;y-=best.lh[k]/2;
for(const l of r){l.x=x+l.pw/2;l.y=y+(l.r[2]-.5)*l.ph*CFG.wob;x+=l.pw+gap;}
y-=best.lh[k]/2+vgap;
});
fit=best.f;
root.scale.setScalar(fit);
}
function update(time){
if(dead)return;
const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;
for(const l of items){const inf=l.mesh.textRenderInfo;if(inf&&l.info2!==inf){l.info2=inf;changed=true;}}
if(changed){for(const l of items)measure(l);layout();laid=true;}
pose(t);
}
function dispose(){
if(dead)return;dead=true;
for(const l of items)for(const m of l.texts){root.remove(m);m.dispose();}
for(const d of disposables)d.dispose();
scene.remove(root);
}
const TICKET_FRAG=[GLSL_COMMON,
'uniform vec2 u_half;uniform vec2 u_size;uniform float u_alpha;uniform float u_unit;uniform float u_seed;uniform float u_kind;uniform float u_stub;',
'uniform float u_lift;uniform float u_punch;uniform vec3 u_paper;uniform vec3 u_ink;',
'float sdBox(vec2 p,vec2 b){vec2 q=abs(p)-b;return length(max(q,0.))+min(max(q.x,q.y),0.);}',
'float shape(vec2 p){',
' vec2 h=u_half;float U=u_unit;',
' float d=sdRB(p,h,1.5*U);',
' if(u_kind<.5){', // 切手:縁をぐるりと丸く抜く
' vec2 n=max(floor(2.*h/(7.*U)+.5),vec2(3.));',
' vec2 pp=2.*h/n;',
' float r=min(pp.x,pp.y)*.33;',
' float qx=mod(p.x+h.x,pp.x)-pp.x*.5;',
' float qy=mod(p.y+h.y,pp.y)-pp.y*.5;',
' float c1=length(vec2(qx,abs(p.y)-h.y))-r;',
' float c2=length(vec2(abs(p.x)-h.x,qy))-r;',
' d=max(sdBox(p,h),-min(c1,c2));',
' }else{',
' float rn=h.y*.26;',
' if(u_kind<2.5){',
' float nl=length(p-vec2(-h.x,0.))-rn,nr=length(p-vec2(h.x,0.))-rn;', // 左右の半円の切り欠き
' d=max(d,-min(nl,nr));',
' }',
' if(u_kind>2.5){', // ちぎれたギザギザの縁
' float amp=3.*U,per=6.5*U;',
' float zz=abs(fract(p.y/per)-.5)*2.*amp;',
' d=max(d,(-h.x+zz)-p.x);',
' }',
' if(u_kind>.5&&u_kind<2.5){', // ミシン目
' float sx=u_kind<1.5?-h.x+u_stub:h.x-u_stub;',
' float qy=mod(p.y+h.y,5.*U)-2.5*U;',
' float hole=length(vec2(p.x-sx,qy))-1.05*U;',
' hole=max(hole,abs(p.y)-(h.y-3.*U));',
' d=max(d,-hole);',
' }',
' if(u_kind>.5&&u_punch>0.){', // 改札のパンチ穴
' float cx=u_kind<1.5?-h.x+u_stub*.5:(u_kind<2.5?h.x-u_stub*.5:-h.x+u_stub*.5+3.*U);',
' float R=min(u_stub*.5,h.y*.55)*.5*u_punch;',
' d=max(d,-(length(p-vec2(cx,0.))-R));',
' }',
' }',
' return d;',
'}',
'void main(){',
' vec2 p=(vUv-.5)*u_size;',
' vec2 h=u_half;float U=u_unit;',
' float d=shape(p);',
' float a=1.-smoothstep(-.7,.7,d);',
' float gy=p.y/h.y*.5+.5;',
' vec3 col=u_paper*(.96+.07*gy)*(.975+.05*vnoise(p*.6+u_seed*9.));',
' float ln=0.;',
' float ins=4.2*U;',
' float l1=1.-smoothstep(0.,.9*U,abs(sdRB(p,h-vec2(ins),U))-.45*U);',
' ln=max(ln,l1);',
' if(u_kind<.5){',
' float l2=1.-smoothstep(0.,.9*U,abs(sdRB(p,h-vec2(ins+3.2*U),U))-.3*U);',
' ln=max(ln,l2*.8);',
' float inner=1.-smoothstep(-.6,.6,sdRB(p,h-vec2(ins+3.2*U),U));',
' col=mix(col,mix(u_paper,vec3(1.),.45),inner*.9);', // 絵柄の面は少し明るく
' vec2 c=vec2(h.x*.55,-h.y*.2);float Rm=min(h.x,h.y)*.62;', // 消印:円と波線
' float ring=1.-smoothstep(0.,.9*U,abs(length(p-c)-Rm)-.5*U);',
' float ring2=1.-smoothstep(0.,.9*U,abs(length(p-c)-Rm*.78)-.3*U);',
' float wl=0.;',
' for(int k=0;k<4;k++){float fk=float(k);float yy=c.y+(fk-1.5)*3.3*U+sin(p.x*.32/U+fk*1.3)*1.2*U;',
' wl=max(wl,(1.-smoothstep(0.,.8*U,abs(p.y-yy)-.45*U))*step(p.x,c.x-Rm*.9)*step(c.x-Rm-h.x*.9,p.x));}',
' col=mix(col,u_ink,max(max(ring,ring2*.8),wl)*.38);',
' }else{',
' float sx=u_kind<1.5?-h.x+u_stub:(u_kind<2.5?h.x-u_stub:-h.x+u_stub);',
' float inStub=u_kind<2.5&&u_kind>.5?(u_kind<1.5?step(p.x,sx-1.5*U):step(sx+1.5*U,p.x)):step(p.x,sx);',
' float bidx=floor(p.y/(1.9*U));',
' float bar=step(.42,hash21(vec2(bidx,u_seed*13.)))*step(abs(p.y),h.y-6.*U);',
' float bx0=u_kind<1.5?-h.x+3.5*U:(u_kind<2.5?h.x-u_stub+3.5*U:-h.x+5.*U);',
' float bx1=u_kind<1.5?-h.x+u_stub-4.5*U:(u_kind<2.5?h.x-3.5*U:-h.x+u_stub-1.*U);',
' float inBar=step(bx0,p.x)*step(p.x,bx1);',
' if(u_kind<3.){ln=max(ln,bar*inBar*inStub*.9);}',
' else{float dash=step(abs(p.y-h.y*.34),.5*U)+step(abs(p.y+h.y*.34),.5*U);ln=max(ln,dash*inBar*.8);}',
' float tear=1.-smoothstep(0.,1.*U,abs(p.x-sx)-.3*U);', // 半券との境の実線
' if(u_kind>2.5)ln=max(ln,tear*.0);',
' float rimLeft=0.;',
' }',
' col=mix(col,u_ink,ln*.82);',
' float sh=(1.-smoothstep(-2.*U-u_lift*6.*U,2.*U+u_lift*6.*U,shape(p-vec2(1.4+u_lift*4.,-2.6-u_lift*8.)*U)))*(.36-.12*u_lift);',
' float oa=a+sh*(1.-a);',
' vec3 rgb=oa>.001?col*a/oa:vec3(0.);',
' gl_FragColor=vec4(rgb,oa*u_alpha);',
'}'].join(NL);
const MARGIN=Math.round(Math.max(24,em*.75));
function initItem(l){
l.outer=new THREE.Group();root.add(l.outer);
const un=Math.max(.5,l.fs/42);
l.mat=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:TICKET_FRAG,
uniforms:{u_half:{value:new THREE.Vector2(1,1)},u_size:{value:new THREE.Vector2(1,1)},u_alpha:{value:0},u_unit:{value:un},u_seed:{value:l.r[0]},
u_kind:{value:l.kind},u_stub:{value:l.stub},u_lift:{value:0},u_punch:{value:0},u_paper:{value:rgb(l.paper)},u_ink:{value:rgb(l.edge)}}});
disposables.push(l.mat);
l.quad=makeQuad(l.mat,1,1);l.quad.renderOrder=l.i*4;l.outer.add(l.quad);disposables.push(l.quad.geometry);
l.mesh.renderOrder=l.i*4+1;l.mesh.material.depthTest=false;l.mesh.material.depthWrite=false;
l.mesh.position.x=l.kind===1||l.kind===3?l.stub/2:(l.kind===2?-l.stub/2:0);
l.outer.add(l.mesh);
l.outer.visible=false;
l.rot0=(l.r[1]-.5)*.14;
l.dir=l.i%2?1:-1;
}
function pose(t){
for(const l of items){
if(!l.pw)continue;
const u=clamp((t-l.t0)/ent),q=1-clamp((life-t-ostag*(1-l.o))/outro);
const a=smooth(u*7)*(1-smooth((q-.2)/.8));
l.outer.visible=a>.003&&t<life;
if(!l.outer.visible){l.mesh.visible=false;continue;}
const e=spring(u); // 下から出てきて、ぽんと弾んで止まる
const rise=1-Math.min(1.15,e);
const lift=clamp(1-u*1.6)+q*.8;
const s=1+.25*rise*(rise>0?1:0)+.04*q;
l.outer.position.set(l.x+q*q*l.dir*W*.16/fit,l.y-rise*H*.2/fit+q*q*H*.12/fit,0);
l.outer.rotation.z=l.rot0+rise*l.dir*.35+q*q*l.dir*.7;
l.outer.scale.setScalar(l.sc*s);
const w=l.pw0,h=l.ph0;
l.quad.scale.set(w+2*MARGIN,h+2*MARGIN,1);
const m=l.mat.uniforms;
m.u_half.value.set(w/2,h/2);m.u_size.value.set(w+2*MARGIN,h+2*MARGIN);m.u_alpha.value=a;m.u_lift.value=lift;
m.u_punch.value=smooth((u-.72)*4)*(1-q);
l.mesh.visible=true;l.mesh.fillOpacity=a*smooth((u-.08)*8);
}
}
items.forEach(initItem);
update(0);
return {update,dispose};
語ごとに縁と影のあるステッカーになって、上からペタッと貼られて少し跳ねます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語ごとに白いダイカットの縁と影を持つステッカーになって、上からペタッと貼られる。色・大きさ・傾き・めくれた角・きらっと光る筋が語ごとに違う
const MAXWORDS=9;
const {THREE,scene,createText,text,width,height,fontSize,duration,random,runs,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const back=x=>{x=clamp(x)-1;return 1+2.2*x*x*x+1.2*x*x};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const outro=Math.min(.5,life*.18);
const root=new THREE.Group();scene.add(root);
const lum=c=>{try{const n=new THREE.Color(c).getHex();return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255}catch(e){return .5}};
const shade=(c,to,k)=>{const a=new THREE.Color(c);a.lerp(new THREE.Color(to),k);return '#'+a.getHexString()};
const V=(x,y)=>new THREE.Vector2(x,y);
// 同じ書式(強調・色・大きさ)の中か
function runAtOff(o){let a=0;for(const r of runs||[]){const n=String(r.text||'').length;if(o<a+n)return r;a+=n;}return null;}
function sameRun(a,b){const x=runAtOff(a)||{},y=runAtOff(b)||{};return !!x.emphasis===!!y.emphasis&&(x.color||'')===(y.color||'')&&(x.scale||1)===(y.scale||1);}
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。多すぎる語はまとめ、1語だけなら半分に割る)
function splitWords(maxWords){
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
const last=merged[merged.length-1];
if(last&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))last.s+=p.s;
else if(last&&/^[ぁ-ゟー]{1,3}$/.test(p.s)&&last.s.length+p.s.length<=6&&!/[、。,.!?!?…]$/.test(last.s)&&last.start+last.s.length===p.start&&sameRun(last.start+off,p.start+off))last.s+=p.s; // 助詞や語尾は前の語につける
else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>maxWords){const c=Math.ceil(list.length/maxWords),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);if(g.every(x=>x.row===g[0].row))m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});else g.forEach(x=>m.push(x));}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2),f=a.slice(0,h).join('');list=[{s:f,start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+f.length,row:list[0].row}];}
return list;
}
// 語の強調・色・大きさ(run の scale)を調べる
function runInfo(start,len){
let o=0,emph=false,col=null,ws=0,ss=0;
for(const r of runs||[]){
const n=String(r.text||'').length,a=Math.max(o,start),b=Math.min(o+n,start+len);
if(b>a){const sc=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;let w=0;for(const ch of Array.from(String(text).slice(a,b)))w+=ch.codePointAt(0)<256?.6:1;w=Math.max(.3,w);ws+=w;ss+=w*sc;if(r.emphasis||r.color)emph=true;if(r.color&&!col)col=r.color;}
o+=n;if(o>=start+len)break;
}
return {emph,col,scale:ws?ss/ws:1};
}
const SIZES=[1.16,.86,1.04,.9,1.22,.92];
const items=splitWords(MAXWORDS).map((w,i)=>{
const inf=runInfo(w.start,w.s.length);
return {...w,i,emph:inf.emph,col:inf.col,rs:clamp(inf.scale,.4,3),sz:SIZES[i%SIZES.length]*(.93+random()*.14)*(inf.emph?1.08:1),tw:0,th:0,bw:0,bh:0,x:0,y:0,dy:0,info:null,fx:[]};
});
const nWords=items.length,spread=Math.min(life*.25,(nWords-1)*.26);
items.forEach(it=>{it.t0=nWords>1?spread*it.i/(nWords-1):0;});
// 語の文字メッシュ(強調語の色・縁取り・中抜き・語の大きさは createText が textStart から自動で効かせる)
function makeText(it,ink,extra){
const opt={textStart:it.start,fontSize:em*it.sz,anchorX:'center',anchorY:'middle',...(extra||{})};if(ink)opt.color=ink;
const m=createText(it.s,opt);
m.fillOpacity=0;m.outlineOpacity=0;m.renderOrder=it.i*16+14;
if(m.material){m.material.depthTest=false;m.material.depthWrite=false;}
it.mesh=m;return m;
}
function rrPts(w,h,r,cut,cs){
r=Math.max(.01,Math.min(r,w/2,h/2));const hw=w/2,hh=h/2,P=[];
const arc=(cx,cy,a0)=>{for(let k=0;k<=6;k++){const a=a0+k*Math.PI/12;P.push(V(cx+r*Math.cos(a),cy+r*Math.sin(a)));}};
arc(hw-r,-hh+r,-Math.PI/2);
if(cut>0){P.push(V(hw,hh-cut),V(hw-cut,hh));}else arc(hw-r,hh-r,0);
arc(-hw+r,hh-r,Math.PI/2);
arc(-hw+r,-hh+r,Math.PI);
if(cs<0)for(const p of P)p.x=-p.x;
return P;
}
const rrGeo=(w,h,r,cut,cs)=>new THREE.ShapeGeometry(new THREE.Shape(rrPts(w,h,r,cut,cs)));
function starGeo(R,r,n,rot){const P=[];for(let k=0;k<n*2;k++){const a=rot+k*Math.PI/n,rr=k%2?r:R;P.push(V(Math.cos(a)*rr,Math.sin(a)*rr));}return new THREE.ShapeGeometry(new THREE.Shape(P));}
// 筆の線:点列を幅つきの帯にして、描画範囲(drawRange)で一筆書きのように伸ばす
function ribbonGeo(pts,wf){
const n=pts.length,pos=new Float32Array(n*6),idx=[];
for(let i=0;i<n;i++){
const a=pts[Math.max(0,i-1)],b=pts[Math.min(n-1,i+1)];
let dx=b.x-a.x,dy=b.y-a.y;const l=Math.hypot(dx,dy)||1;dx/=l;dy/=l;
const w=wf(i/(n-1),i)/2,nx=-dy*w,ny=dx*w;
pos.set([pts[i].x+nx,pts[i].y+ny,0,pts[i].x-nx,pts[i].y-ny,0],i*6);
if(i<n-1){const k=i*2;idx.push(k,k+1,k+2,k+1,k+3,k+2);}
}
const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.BufferAttribute(pos,3));g.setIndex(idx);g.userData.segs=n-1;return g;
}
const drawTo=(m,p)=>{const g=m.geometry,s=g.userData.segs||0;g.setDrawRange(0,Math.floor(s*clamp(p))*6);m.visible=p>.001;};
const mkMat=(c,o=1)=>new THREE.MeshBasicMaterial({color:c,transparent:true,opacity:o,depthTest:false,depthWrite:false,side:THREE.DoubleSide});
// 装飾メッシュ。it.fx に不透明度の基準を覚えさせ、まとめて薄くできる
function deco(it,geo,c,o,order){
const m=new THREE.Mesh(geo,mkMat(c,o==null?1:o));m.renderOrder=it.i*16+order;it.fx.push([m.material,o==null?1:o]);return m;
}
function regeo(m,geo){if(m.geometry)m.geometry.dispose();m.geometry=geo;}
function fade(it,a){for(const f of it.fx)f[0].opacity=f[1]*a;}
function measure(it){
const r=it.mesh&&it.mesh.textRenderInfo,b=r&&r.blockBounds;
let base;
if(b&&b.length===4&&b.every(Number.isFinite))base=Math.max(em*it.sz*.3,b[2]-b[0]);
else{let n=0;for(const ch of Array.from(it.s))n+=ch.codePointAt(0)<256?.6:1;base=em*it.sz*n;}
it.tw=base*it.rs;it.th=em*it.sz*1.12*Math.max(.7,it.rs);
}
let fit=1,laid=false,dead=false,FITW=.86,FITH=.7,GAPX=.22,GAPY=.1,boxW=1,boxH=1;
function layout(){
for(const it of items){measure(it);size(it);}
const gap=em*GAPX,limit=W*FITW,rows=[],hard=[];
for(const it of items)(hard[it.row]=hard[it.row]||[]).push(it);
for(const row of hard){if(!row)continue;
const total=row.reduce((s,l,k)=>s+l.bw+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.bw>cap){rows.push(cur);cur=[];x=0;}x+=(cur.length?gap:0)+l.bw;cur.push(l);}
rows.push(cur);}
const rowH=rows.map(r=>Math.max(...r.map(l=>l.bh))),vgap=em*GAPY,totH=rowH.reduce((a,h)=>a+h,0)+vgap*(rows.length-1);
let y=totH/2,maxW=1;
rows.forEach((row,r)=>{
let x=0;for(const l of row){l.x=x+l.bw/2;x+=l.bw+gap;}
const tw=x-gap;maxW=Math.max(maxW,tw);
const cy=y-rowH[r]/2;for(const l of row){l.x-=tw/2;l.y=cy+l.dy;}
y-=rowH[r]+vgap;
});
fit=Math.min(1,W*FITW/maxW,H*FITH/totH);root.scale.setScalar(fit);boxW=maxW;boxH=totH;
if(typeof afterLayout==='function')afterLayout();
}
// ---- ステッカー ----
const PAL=['#ffd23f','#ff6f9c','#4fd8ff','#7cf29a','#ff9f43','#b79cff','#fff1cf','#2b2f55'];
const DARK='#1c1a2b',DIE='#fffdf6';
const quadGeo=()=>{const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.BufferAttribute(new Float32Array(12),3));g.setIndex([0,1,2,0,2,3]);return g;};
for(const it of items){
const accentCol=it.col||accent;
it.face=it.emph?(lum(accentCol)>.55?'#2b2f55':'#fff1cf'):PAL[(it.i*3+(random()<.5?0:1))%PAL.length];
const ink=it.emph?it.face==='#2b2f55'?'#ffffff':DARK:(lum(it.face)>.55?DARK:'#ffffff');
it.type=(it.i+Math.floor(random()*2))%3;it.cs=random()<.5?1:-1;
it.tilt=(random()-.5)*.13;it.spin=(random()<.5?-1:1)*(.35+random()*.3);
it.dy=(random()-.5)*em*.16;
const g=new THREE.Group();g.visible=false;root.add(g);it.g=g;
it.shadow=deco(it,new THREE.BufferGeometry(),'#0b0d16',.36,0);
it.die=deco(it,new THREE.BufferGeometry(),DIE,1,1);
it.base=deco(it,new THREE.BufferGeometry(),shade(it.face,'#000000',.2),1,2);
it.panel=deco(it,new THREE.BufferGeometry(),it.face,1,3);
it.dots=deco(it,new THREE.BufferGeometry(),shade(it.face,'#ffffff',.38),1,4);
it.shine=deco(it,quadGeo(),'#ffffff',.55,5);
it.flap=deco(it,new THREE.BufferGeometry(),'#ece6d3',1,6);
g.add(it.shadow,it.die,it.base,it.panel,it.dots,it.shine,it.flap);
if(it.emph){
it.starOut=deco(it,new THREE.BufferGeometry(),DARK,1,7);it.star=deco(it,new THREE.BufferGeometry(),'#ffd23f',1,8);
g.add(it.starOut,it.star);
}
const tx=makeText(it,ink);g.add(tx);
}
function size(it){
const sz=it.sz,b=em*.09*sz,padX=em*.36*sz,padY=em*.2*sz,cs=it.cs;
const fw=Math.max(it.tw+2*padX,em*sz*1.3),fh=it.th+2*padY,r=it.type===1?fh/2:it.type===0?fh*.3:fh*.13;
const cut=Math.min(fh*.36,em*.34*sz),e=fh*.075;
Object.assign(it,{fw,fh,b,cut,r});
regeo(it.shadow,rrGeo(fw+2*b,fh+2*b,r+b,cut+b*.586,cs));
regeo(it.die,rrGeo(fw+2*b,fh+2*b,r+b,cut+b*.586,cs));
regeo(it.base,rrGeo(fw,fh,r,cut,cs));
const ph=fh-2*e-fh*.13;
regeo(it.panel,rrGeo(fw-2*e,ph,Math.max(.01,r-e),Math.max(0,cut-e*.586),cs));
it.panel.position.y=fh*.065;
// 水玉・しま
const shapes=[];
if(it.type===0){for(const [sx,y] of [[-1,.2],[-1,-.2],[1,-.2],[1,.2]]){if(sx*it.cs>0&&y>0)continue;shapes.push(new THREE.Shape(new THREE.Path().absarc(sx*(fw/2-fh*.15),y*fh,fh*.05,0,Math.PI*2,false).getPoints(10)));}}
else if(it.type===1){for(let k=-3;k<=3;k++){const x=k*fh*.34;shapes.push(new THREE.Shape([V(x-fh*.07-fh*.2,-fh*.42),V(x+fh*.07-fh*.2,-fh*.42),V(x+fh*.07+fh*.2,fh*.42),V(x-fh*.07+fh*.2,fh*.42)].map(p=>V(clamp(p.x,-fw/2+r*.9,fw/2-r*.9),p.y))));}}
if(shapes.length)regeo(it.dots,new THREE.ShapeGeometry(shapes));else regeo(it.dots,new THREE.BufferGeometry());
it.dots.position.y=fh*.065;
// めくれた角
const hw=fw/2,hh=fh/2,s=cs,A=V(s*(hw-cut),hh),B=V(s*hw,hh-cut),C=V(s*(hw-cut),hh-cut),M=V((A.x+B.x)/2,(A.y+B.y)/2);
const L=cut/Math.SQRT2,d=cut/Math.SQRT2;
regeo(it.flap,new THREE.ShapeGeometry(new THREE.Shape([V(0,-d),V(0,d),V(L,0)])));
it.flap.position.set(M.x,M.y,0);it.flap.rotation.z=Math.atan2(C.y-M.y,C.x-M.x);
if(it.star){
const R=em*.25*sz;
regeo(it.starOut,starGeo(R*1.18,R*.56,5,Math.PI/2));regeo(it.star,starGeo(R,R*.45,5,Math.PI/2));
it.starOut.position.set(-s*(hw+b*.3),hh+b*.3,0);it.star.position.copy(it.starOut.position);
it.starR=R;
}
const sm=it.star?it.starR*.8:0;
it.bw=fw+2*b+em*.1+sm;it.bh=fh+2*b+em*.1+sm*.8;
}
function pose(it,t,lt,q){
const g=it.g;
if(lt<0||q<=0.001){g.visible=false;it.mesh.visible=false;return;}
g.visible=true;
const L=.2,u=clamp(lt/L),land=Math.max(0,lt-L),a=smooth(lt/.06)*q;
let s=lt<L?mix(2.0,.88,u*u):1-.12*Math.exp(-9*land)*Math.cos(14*land);
const k=lt<L?0:Math.exp(-10*land)*Math.cos(17*land);
let rot=it.tilt+(1-smooth(u))*it.spin+(lt<L?0:.04*Math.exp(-7*land)*Math.sin(19*land)*(it.i%2?1:-1));
const ex=1-q;s*=1-.25*ex;rot+=it.spin*.5*ex;
g.position.set(it.x,it.y-em*.18*ex,0);g.rotation.z=rot;g.scale.set(s*(1+.08*k),s*(1-.08*k),1);
// 影:貼る前は遠くて薄く、貼ると縮んで濃くなる
const near=smooth(u);
it.shadow.position.set(it.b*mix(3.2,.7,near),-it.b*mix(4.4,1.1,near),0);
it.shadow.scale.setScalar(mix(1.08,1,near));
fade(it,a);it.shadow.material.opacity=.36*a*mix(.35,1,near);
// きらっと光る筋
const p=(t-(it.t0+.5+it.i*.05))/.55,sh=it.shine;
if(p>0&&p<1&&a>.01){
const fw=it.fw,fh=it.fh,cx=mix(-fw/2-fh*.3,fw/2+fh*.3,p),bwid=fh*.36,sk=fh*.45,lim=fw/2-it.r*.9,yy=fh/2-fh*.075;
const lo=-lim-(it.cs<0?it.cut*.8:0),hi=lim-(it.cs>0?it.cut*.8:0);
const c=v=>clamp(v,-lim,lim),ct=v=>clamp(v,lo,hi);
const pos=sh.geometry.attributes.position.array;
const pts=[[ct(cx-bwid/2+sk/2),yy],[ct(cx+bwid/2+sk/2),yy],[c(cx+bwid/2-sk/2),-yy],[c(cx-bwid/2-sk/2),-yy]];
pts.forEach((v,j)=>{pos[j*3]=v[0];pos[j*3+1]=v[1];pos[j*3+2]=0;});
sh.geometry.attributes.position.needsUpdate=true;sh.visible=true;sh.material.opacity=.55*a*Math.sin(Math.PI*p);
}else sh.visible=false;
it.flap.scale.x=1+.14*Math.sin(t*3.1+it.i*1.7);
if(it.star){it.star.rotation.z=it.starOut.rotation.z=.25*Math.sin(t*2.6+it.i);}
it.mesh.visible=a>.002;it.mesh.fillOpacity=a*smooth((lt-.06)/.1);it.mesh.outlineOpacity=it.mesh.fillOpacity;
}
function update(time){
if(dead)return;
const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;
for(const it of items){const r=it.mesh&&it.mesh.textRenderInfo;if(r&&it.info!==r){it.info=r;changed=true;}}
if(changed){layout();laid=true;}
const q=smooth(clamp((life-t)/outro));
for(const it of items)pose(it,t,t-it.t0,q);
if(typeof frame==='function')frame(t,q);
}
function dispose(){
if(dead)return;dead=true;
root.traverse(o=>{
if(typeof o.dispose==='function'){o.dispose();return;}
if(o.geometry)o.geometry.dispose();
if(o.material&&o.material.dispose)o.material.dispose();
});
scene.remove(root);
}
update(0);
return {update,dispose};
語ごとに大きさの違う吹き出しが、しっぽの先からぽこぽこ膨らんで現れます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語ごとに吹き出し(まる・角丸・もくもく雲・とげとげ)になって、しっぽの先からぽこぽこ膨らんで現れる。大小・色・しっぽの向きが語ごとに違い、膨らんだ後は息をするように揺れる
const MAXWORDS=8;
const {THREE,scene,createText,text,width,height,fontSize,duration,random,runs,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const back=x=>{x=clamp(x)-1;return 1+2.2*x*x*x+1.2*x*x};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const outro=Math.min(.5,life*.18);
const root=new THREE.Group();scene.add(root);
const lum=c=>{try{const n=new THREE.Color(c).getHex();return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255}catch(e){return .5}};
const shade=(c,to,k)=>{const a=new THREE.Color(c);a.lerp(new THREE.Color(to),k);return '#'+a.getHexString()};
const V=(x,y)=>new THREE.Vector2(x,y);
// 同じ書式(強調・色・大きさ)の中か
function runAtOff(o){let a=0;for(const r of runs||[]){const n=String(r.text||'').length;if(o<a+n)return r;a+=n;}return null;}
function sameRun(a,b){const x=runAtOff(a)||{},y=runAtOff(b)||{};return !!x.emphasis===!!y.emphasis&&(x.color||'')===(y.color||'')&&(x.scale||1)===(y.scale||1);}
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。多すぎる語はまとめ、1語だけなら半分に割る)
function splitWords(maxWords){
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
const last=merged[merged.length-1];
if(last&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))last.s+=p.s;
else if(last&&/^[ぁ-ゟー]{1,3}$/.test(p.s)&&last.s.length+p.s.length<=6&&!/[、。,.!?!?…]$/.test(last.s)&&last.start+last.s.length===p.start&&sameRun(last.start+off,p.start+off))last.s+=p.s; // 助詞や語尾は前の語につける
else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>maxWords){const c=Math.ceil(list.length/maxWords),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);if(g.every(x=>x.row===g[0].row))m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});else g.forEach(x=>m.push(x));}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2),f=a.slice(0,h).join('');list=[{s:f,start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+f.length,row:list[0].row}];}
return list;
}
// 語の強調・色・大きさ(run の scale)を調べる
function runInfo(start,len){
let o=0,emph=false,col=null,ws=0,ss=0;
for(const r of runs||[]){
const n=String(r.text||'').length,a=Math.max(o,start),b=Math.min(o+n,start+len);
if(b>a){const sc=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;let w=0;for(const ch of Array.from(String(text).slice(a,b)))w+=ch.codePointAt(0)<256?.6:1;w=Math.max(.3,w);ws+=w;ss+=w*sc;if(r.emphasis||r.color)emph=true;if(r.color&&!col)col=r.color;}
o+=n;if(o>=start+len)break;
}
return {emph,col,scale:ws?ss/ws:1};
}
const SIZES=[1.16,.86,1.04,.9,1.22,.92];
const items=splitWords(MAXWORDS).map((w,i)=>{
const inf=runInfo(w.start,w.s.length);
return {...w,i,emph:inf.emph,col:inf.col,rs:clamp(inf.scale,.4,3),sz:SIZES[i%SIZES.length]*(.93+random()*.14)*(inf.emph?1.08:1),tw:0,th:0,bw:0,bh:0,x:0,y:0,dy:0,info:null,fx:[]};
});
const nWords=items.length,spread=Math.min(life*.25,(nWords-1)*.26);
items.forEach(it=>{it.t0=nWords>1?spread*it.i/(nWords-1):0;});
// 語の文字メッシュ(強調語の色・縁取り・中抜き・語の大きさは createText が textStart から自動で効かせる)
function makeText(it,ink,extra){
const opt={textStart:it.start,fontSize:em*it.sz,anchorX:'center',anchorY:'middle',...(extra||{})};if(ink)opt.color=ink;
const m=createText(it.s,opt);
m.fillOpacity=0;m.outlineOpacity=0;m.renderOrder=it.i*16+14;
if(m.material){m.material.depthTest=false;m.material.depthWrite=false;}
it.mesh=m;return m;
}
function rrPts(w,h,r,cut,cs){
r=Math.max(.01,Math.min(r,w/2,h/2));const hw=w/2,hh=h/2,P=[];
const arc=(cx,cy,a0)=>{for(let k=0;k<=6;k++){const a=a0+k*Math.PI/12;P.push(V(cx+r*Math.cos(a),cy+r*Math.sin(a)));}};
arc(hw-r,-hh+r,-Math.PI/2);
if(cut>0){P.push(V(hw,hh-cut),V(hw-cut,hh));}else arc(hw-r,hh-r,0);
arc(-hw+r,hh-r,Math.PI/2);
arc(-hw+r,-hh+r,Math.PI);
if(cs<0)for(const p of P)p.x=-p.x;
return P;
}
const rrGeo=(w,h,r,cut,cs)=>new THREE.ShapeGeometry(new THREE.Shape(rrPts(w,h,r,cut,cs)));
function starGeo(R,r,n,rot){const P=[];for(let k=0;k<n*2;k++){const a=rot+k*Math.PI/n,rr=k%2?r:R;P.push(V(Math.cos(a)*rr,Math.sin(a)*rr));}return new THREE.ShapeGeometry(new THREE.Shape(P));}
// 筆の線:点列を幅つきの帯にして、描画範囲(drawRange)で一筆書きのように伸ばす
function ribbonGeo(pts,wf){
const n=pts.length,pos=new Float32Array(n*6),idx=[];
for(let i=0;i<n;i++){
const a=pts[Math.max(0,i-1)],b=pts[Math.min(n-1,i+1)];
let dx=b.x-a.x,dy=b.y-a.y;const l=Math.hypot(dx,dy)||1;dx/=l;dy/=l;
const w=wf(i/(n-1),i)/2,nx=-dy*w,ny=dx*w;
pos.set([pts[i].x+nx,pts[i].y+ny,0,pts[i].x-nx,pts[i].y-ny,0],i*6);
if(i<n-1){const k=i*2;idx.push(k,k+1,k+2,k+1,k+3,k+2);}
}
const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.BufferAttribute(pos,3));g.setIndex(idx);g.userData.segs=n-1;return g;
}
const drawTo=(m,p)=>{const g=m.geometry,s=g.userData.segs||0;g.setDrawRange(0,Math.floor(s*clamp(p))*6);m.visible=p>.001;};
const mkMat=(c,o=1)=>new THREE.MeshBasicMaterial({color:c,transparent:true,opacity:o,depthTest:false,depthWrite:false,side:THREE.DoubleSide});
// 装飾メッシュ。it.fx に不透明度の基準を覚えさせ、まとめて薄くできる
function deco(it,geo,c,o,order){
const m=new THREE.Mesh(geo,mkMat(c,o==null?1:o));m.renderOrder=it.i*16+order;it.fx.push([m.material,o==null?1:o]);return m;
}
function regeo(m,geo){if(m.geometry)m.geometry.dispose();m.geometry=geo;}
function fade(it,a){for(const f of it.fx)f[0].opacity=f[1]*a;}
function measure(it){
const r=it.mesh&&it.mesh.textRenderInfo,b=r&&r.blockBounds;
let base;
if(b&&b.length===4&&b.every(Number.isFinite))base=Math.max(em*it.sz*.3,b[2]-b[0]);
else{let n=0;for(const ch of Array.from(it.s))n+=ch.codePointAt(0)<256?.6:1;base=em*it.sz*n;}
it.tw=base*it.rs;it.th=em*it.sz*1.12*Math.max(.7,it.rs);
}
let fit=1,laid=false,dead=false,FITW=.86,FITH=.7,GAPX=.22,GAPY=.1,boxW=1,boxH=1;
function layout(){
for(const it of items){measure(it);size(it);}
const gap=em*GAPX,limit=W*FITW,rows=[],hard=[];
for(const it of items)(hard[it.row]=hard[it.row]||[]).push(it);
for(const row of hard){if(!row)continue;
const total=row.reduce((s,l,k)=>s+l.bw+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.bw>cap){rows.push(cur);cur=[];x=0;}x+=(cur.length?gap:0)+l.bw;cur.push(l);}
rows.push(cur);}
const rowH=rows.map(r=>Math.max(...r.map(l=>l.bh))),vgap=em*GAPY,totH=rowH.reduce((a,h)=>a+h,0)+vgap*(rows.length-1);
let y=totH/2,maxW=1;
rows.forEach((row,r)=>{
let x=0;for(const l of row){l.x=x+l.bw/2;x+=l.bw+gap;}
const tw=x-gap;maxW=Math.max(maxW,tw);
const cy=y-rowH[r]/2;for(const l of row){l.x-=tw/2;l.y=cy+l.dy;}
y-=rowH[r]+vgap;
});
fit=Math.min(1,W*FITW/maxW,H*FITH/totH);root.scale.setScalar(fit);boxW=maxW;boxH=totH;
if(typeof afterLayout==='function')afterLayout();
}
// ---- 吹き出し ----
const FILLS=['#ffffff','#fff3b0','#ffd6e0','#c9f4e4','#cfe6ff','#ffe0b8','#e6dcff'];
const INK='#1c1a2b',LIGHT='#fffdf6',SHADOW='#4a4470';
const ellPts=(rx,ry,n=40,rot=0)=>{const P=[];for(let k=0;k<n;k++){const a=rot+k/n*Math.PI*2;P.push(V(Math.cos(a)*rx,Math.sin(a)*ry));}return P;};
const polyGeo=P=>new THREE.ShapeGeometry(new THREE.Shape(P));
function bodyShapes(it,rx,ry,grow){
// grow: 縁取りのぶん外へ広げる量
const o=grow||0;
if(it.kind==='ell')return [new THREE.Shape(ellPts(rx+o,ry+o))];
if(it.kind==='rect'){const P=rrPts(2*(rx+o),2*(ry+o),Math.min(rx,ry)*.55+o,0,1);return [new THREE.Shape(P)];}
if(it.kind==='burst'){
const n=it.spk.length,P=[];
for(let k=0;k<n*2;k++){const a=(k/(n*2))*Math.PI*2+.1,spike=k%2===0,j=it.spk[k>>1];const f=spike?1.18+j*.1:.82;P.push(V(Math.cos(a)*(rx*f+o*1.2),Math.sin(a)*(ry*f+o*1.2)));}
return [new THREE.Shape(P)];
}
// 雲:まるを輪に並べ、中を楕円で埋める
const rc=ry*.58,sh=[new THREE.Shape(ellPts(rx-rc*.45+o,ry-rc*.45+o,32))],n=Math.max(7,Math.round((rx+ry)/(rc*.62)));
for(let k=0;k<n;k++){const a=k/n*Math.PI*2+.2,cx=Math.cos(a)*(rx-rc*.55),cy=Math.sin(a)*(ry-rc*.55),r=rc*(.86+it.spk[k%it.spk.length]*.3)+o;sh.push(new THREE.Shape(ellPts(r,r,14,0).map(p=>V(p.x+cx,p.y+cy))));}
return sh;
}
function tailShapes(it,o){
const {rx,ry,tx,ty,wb}=it;
if(it.kind==='cloud'){
const r1=em*.12*it.sz+o,r2=em*.1*it.sz+o;
const c1=V(tx*.55,-(ry+r1*.1)),c2=V(tx,ty+r2*.9);
return [new THREE.Shape(ellPts(r1,r1,16).map(p=>V(p.x+c1.x,p.y+c1.y))),new THREE.Shape(ellPts(r2,r2,12).map(p=>V(p.x+c2.x,p.y+c2.y)))];
}
const by=-ry*.55,bx=tx*.5,g=o*1.1;
return [new THREE.Shape([V(bx-wb-g,by),V(bx+wb+g,by),V(tx+Math.sign(tx||1)*g*.6,ty-g*1.4)])];
}
for(const it of items){
const accentCol=it.col||accent;
it.kind=it.emph?'burst':['ell','rect','cloud'][(it.i+Math.floor(random()*2))%3];
it.spk=[random(),random(),random(),random(),random(),random(),random(),random(),random(),random(),random(),random()];
if(it.kind==='burst')it.spk=it.spk.slice(0,9);
it.face=it.emph?(lum(accentCol)>.5?'#2a2540':'#fffbe8'):FILLS[(it.i*2+Math.floor(random()*2))%FILLS.length];
it.line=lum(it.face)<.4?LIGHT:INK;
const ink=lum(it.face)<.4?'#ffffff':INK;
it.side=(it.i+(random()<.3?1:0))%2?1:-1;it.tilt=(random()-.5)*.1;
it.dy=(random()-.5)*em*.2;
const g=new THREE.Group();g.visible=false;root.add(g);it.g=g;
const inner=new THREE.Group();g.add(inner);it.inner=inner;
const parts=[['sB',SHADOW,0],['sT',SHADOW,1],['hB',it.line===INK?LIGHT:INK,2],['hT',it.line===INK?LIGHT:INK,3],['oB',it.line,4],['oT',it.line,5],['fB',it.face,6],['fT',it.face,7]];
for(const [k,c,ord] of parts){it[k]=deco(it,new THREE.BufferGeometry(),c,1,ord);inner.add(it[k]);}
it.glint=deco(it,new THREE.BufferGeometry(),'#ffffff',.7,8);inner.add(it.glint);
const tx=makeText(it,ink);inner.add(tx);
}
function size(it){
const sz=it.sz,o=em*.062*Math.min(1.2,sz);
let rx,ry;
if(it.kind==='rect'){rx=it.tw/2+em*.36*sz;ry=it.th/2+em*.2*sz;}
else if(it.kind==='cloud'){rx=it.tw/2*1.2+em*.28*sz;ry=it.th/2*1.35+em*.16*sz;}
else if(it.kind==='burst'){rx=it.tw/2*1.18+em*.3*sz;ry=it.th/2*1.2+em*.2*sz;}
else{rx=it.tw/2*1.34+em*.12*sz;ry=it.th/2*1.4+em*.1*sz;}
const tl=Math.min(em*.5*sz,ry*.8)+(it.kind==='cloud'?ry*.15:0);
Object.assign(it,{rx,ry,o,tl,tx:it.side*rx*(.38+.12*hash(it.i,4)),ty:-(ry+tl),wb:Math.max(em*.08*sz,Math.min(rx*.14,em*.2*sz))});
const off=o*1.1;
regeo(it.sB,new THREE.ShapeGeometry(bodyShapes(it,rx,ry,o)));regeo(it.sT,new THREE.ShapeGeometry(tailShapes(it,o)));
it.sB.position.set(off,-off,0);it.sT.position.set(off,-off,0);
regeo(it.hB,new THREE.ShapeGeometry(bodyShapes(it,rx,ry,o*1.6)));regeo(it.hT,new THREE.ShapeGeometry(tailShapes(it,o*1.6)));
regeo(it.oB,new THREE.ShapeGeometry(bodyShapes(it,rx,ry,o)));regeo(it.oT,new THREE.ShapeGeometry(tailShapes(it,o)));
regeo(it.fB,new THREE.ShapeGeometry(bodyShapes(it,rx,ry,0)));regeo(it.fT,new THREE.ShapeGeometry(tailShapes(it,-o*.2)));
// 左上のつや
const gw=rx*.34,gh=Math.max(em*.045,ry*.09);
regeo(it.glint,new THREE.ShapeGeometry(new THREE.Shape(rrPts(gw,gh,gh/2,0,1))));
it.glint.position.set(-rx*.45,ry*.58,0);it.glint.rotation.z=.32;
it.glint.visible=it.kind!=='burst'&&lum(it.face)<.97;
it.inner.position.set(0,tl/2,0);
it.bw=2*(rx+o)*(it.kind==='burst'?1.18:1)+o*2.4;it.bh=2*(ry+o)*(it.kind==='burst'?1.18:1)+tl+o*2.4;
it.pivot=V(it.tx,tl/2+ty0(it));
}
function ty0(it){return -(it.ry+it.tl);}
function pose(it,t,lt,q){
const g=it.g;
if(lt<0||q<=0.001){g.visible=false;it.mesh.visible=false;return;}
const u=clamp(lt/.55),pop=lt<.55?spring(u):1;
const live=smooth((lt-.6)/.4),breathe=1+.022*live*Math.sin(t*3.6+it.i*1.9);
let s=Math.max(0,pop)*breathe*q;
const wob=Math.exp(-5*lt)*Math.sin(lt*21+it.i)*.07;
const rot=it.tilt+wob;
const sx=s*(1-.28*(pop-1)),sy=s*(1+.28*(pop-1));
g.visible=s>.004;
// しっぽの先を支点にして膨らむ(支点の位置は箱の中心から見たしっぽの先)
const px=it.pivot.x,py=it.pivot.y,c=Math.cos(rot),n=Math.sin(rot);
g.position.set(it.x+px-(px*c*sx-py*n*sy),it.y+py-(px*n*sx+py*c*sy),0);
g.rotation.z=rot;g.scale.set(sx,sy,1);
const a=smooth((lt-.14)/.12)*smooth(q*1.5);
it.mesh.visible=g.visible&&a>.002;it.mesh.fillOpacity=a;it.mesh.outlineOpacity=a;
}
function update(time){
if(dead)return;
const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;
for(const it of items){const r=it.mesh&&it.mesh.textRenderInfo;if(r&&it.info!==r){it.info=r;changed=true;}}
if(changed){layout();laid=true;}
const q=smooth(clamp((life-t)/outro));
for(const it of items)pose(it,t,t-it.t0,q);
if(typeof frame==='function')frame(t,q);
}
function dispose(){
if(dead)return;dead=true;
root.traverse(o=>{
if(typeof o.dispose==='function'){o.dispose();return;}
if(o.geometry)o.geometry.dispose();
if(o.material&&o.material.dispose)o.material.dispose();
});
scene.remove(root);
}
update(0);
return {update,dispose};
語のまわりに手描きの丸や下線が、一筆書きのようにシュッと伸びて描かれます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語ごとに手描きの印が一筆書きで伸びる。強調語は丸で囲み、そのほかは下線・波線・二重線・蛍光ペンの帯。線は手描きらしく細かく震え、語の大きさと傾きも語ごとに違う
const MAXWORDS=8;
const {THREE,scene,createText,text,width,height,fontSize,duration,random,runs,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const back=x=>{x=clamp(x)-1;return 1+2.2*x*x*x+1.2*x*x};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const outro=Math.min(.5,life*.18);
const root=new THREE.Group();scene.add(root);
const lum=c=>{try{const n=new THREE.Color(c).getHex();return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255}catch(e){return .5}};
const shade=(c,to,k)=>{const a=new THREE.Color(c);a.lerp(new THREE.Color(to),k);return '#'+a.getHexString()};
const V=(x,y)=>new THREE.Vector2(x,y);
// 同じ書式(強調・色・大きさ)の中か
function runAtOff(o){let a=0;for(const r of runs||[]){const n=String(r.text||'').length;if(o<a+n)return r;a+=n;}return null;}
function sameRun(a,b){const x=runAtOff(a)||{},y=runAtOff(b)||{};return !!x.emphasis===!!y.emphasis&&(x.color||'')===(y.color||'')&&(x.scale||1)===(y.scale||1);}
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。多すぎる語はまとめ、1語だけなら半分に割る)
function splitWords(maxWords){
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
const last=merged[merged.length-1];
if(last&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))last.s+=p.s;
else if(last&&/^[ぁ-ゟー]{1,3}$/.test(p.s)&&last.s.length+p.s.length<=6&&!/[、。,.!?!?…]$/.test(last.s)&&last.start+last.s.length===p.start&&sameRun(last.start+off,p.start+off))last.s+=p.s; // 助詞や語尾は前の語につける
else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>maxWords){const c=Math.ceil(list.length/maxWords),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);if(g.every(x=>x.row===g[0].row))m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});else g.forEach(x=>m.push(x));}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2),f=a.slice(0,h).join('');list=[{s:f,start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+f.length,row:list[0].row}];}
return list;
}
// 語の強調・色・大きさ(run の scale)を調べる
function runInfo(start,len){
let o=0,emph=false,col=null,ws=0,ss=0;
for(const r of runs||[]){
const n=String(r.text||'').length,a=Math.max(o,start),b=Math.min(o+n,start+len);
if(b>a){const sc=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;let w=0;for(const ch of Array.from(String(text).slice(a,b)))w+=ch.codePointAt(0)<256?.6:1;w=Math.max(.3,w);ws+=w;ss+=w*sc;if(r.emphasis||r.color)emph=true;if(r.color&&!col)col=r.color;}
o+=n;if(o>=start+len)break;
}
return {emph,col,scale:ws?ss/ws:1};
}
const SIZES=[1.16,.86,1.04,.9,1.22,.92];
const items=splitWords(MAXWORDS).map((w,i)=>{
const inf=runInfo(w.start,w.s.length);
return {...w,i,emph:inf.emph,col:inf.col,rs:clamp(inf.scale,.4,3),sz:SIZES[i%SIZES.length]*(.93+random()*.14)*(inf.emph?1.08:1),tw:0,th:0,bw:0,bh:0,x:0,y:0,dy:0,info:null,fx:[]};
});
const nWords=items.length,spread=Math.min(life*.25,(nWords-1)*.26);
items.forEach(it=>{it.t0=nWords>1?spread*it.i/(nWords-1):0;});
// 語の文字メッシュ(強調語の色・縁取り・中抜き・語の大きさは createText が textStart から自動で効かせる)
function makeText(it,ink,extra){
const opt={textStart:it.start,fontSize:em*it.sz,anchorX:'center',anchorY:'middle',...(extra||{})};if(ink)opt.color=ink;
const m=createText(it.s,opt);
m.fillOpacity=0;m.outlineOpacity=0;m.renderOrder=it.i*16+14;
if(m.material){m.material.depthTest=false;m.material.depthWrite=false;}
it.mesh=m;return m;
}
function rrPts(w,h,r,cut,cs){
r=Math.max(.01,Math.min(r,w/2,h/2));const hw=w/2,hh=h/2,P=[];
const arc=(cx,cy,a0)=>{for(let k=0;k<=6;k++){const a=a0+k*Math.PI/12;P.push(V(cx+r*Math.cos(a),cy+r*Math.sin(a)));}};
arc(hw-r,-hh+r,-Math.PI/2);
if(cut>0){P.push(V(hw,hh-cut),V(hw-cut,hh));}else arc(hw-r,hh-r,0);
arc(-hw+r,hh-r,Math.PI/2);
arc(-hw+r,-hh+r,Math.PI);
if(cs<0)for(const p of P)p.x=-p.x;
return P;
}
const rrGeo=(w,h,r,cut,cs)=>new THREE.ShapeGeometry(new THREE.Shape(rrPts(w,h,r,cut,cs)));
function starGeo(R,r,n,rot){const P=[];for(let k=0;k<n*2;k++){const a=rot+k*Math.PI/n,rr=k%2?r:R;P.push(V(Math.cos(a)*rr,Math.sin(a)*rr));}return new THREE.ShapeGeometry(new THREE.Shape(P));}
// 筆の線:点列を幅つきの帯にして、描画範囲(drawRange)で一筆書きのように伸ばす
function ribbonGeo(pts,wf){
const n=pts.length,pos=new Float32Array(n*6),idx=[];
for(let i=0;i<n;i++){
const a=pts[Math.max(0,i-1)],b=pts[Math.min(n-1,i+1)];
let dx=b.x-a.x,dy=b.y-a.y;const l=Math.hypot(dx,dy)||1;dx/=l;dy/=l;
const w=wf(i/(n-1),i)/2,nx=-dy*w,ny=dx*w;
pos.set([pts[i].x+nx,pts[i].y+ny,0,pts[i].x-nx,pts[i].y-ny,0],i*6);
if(i<n-1){const k=i*2;idx.push(k,k+1,k+2,k+1,k+3,k+2);}
}
const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.BufferAttribute(pos,3));g.setIndex(idx);g.userData.segs=n-1;return g;
}
const drawTo=(m,p)=>{const g=m.geometry,s=g.userData.segs||0;g.setDrawRange(0,Math.floor(s*clamp(p))*6);m.visible=p>.001;};
const mkMat=(c,o=1)=>new THREE.MeshBasicMaterial({color:c,transparent:true,opacity:o,depthTest:false,depthWrite:false,side:THREE.DoubleSide});
// 装飾メッシュ。it.fx に不透明度の基準を覚えさせ、まとめて薄くできる
function deco(it,geo,c,o,order){
const m=new THREE.Mesh(geo,mkMat(c,o==null?1:o));m.renderOrder=it.i*16+order;it.fx.push([m.material,o==null?1:o]);return m;
}
function regeo(m,geo){if(m.geometry)m.geometry.dispose();m.geometry=geo;}
function fade(it,a){for(const f of it.fx)f[0].opacity=f[1]*a;}
function measure(it){
const r=it.mesh&&it.mesh.textRenderInfo,b=r&&r.blockBounds;
let base;
if(b&&b.length===4&&b.every(Number.isFinite))base=Math.max(em*it.sz*.3,b[2]-b[0]);
else{let n=0;for(const ch of Array.from(it.s))n+=ch.codePointAt(0)<256?.6:1;base=em*it.sz*n;}
it.tw=base*it.rs;it.th=em*it.sz*1.12*Math.max(.7,it.rs);
}
let fit=1,laid=false,dead=false,FITW=.86,FITH=.7,GAPX=.22,GAPY=.1,boxW=1,boxH=1;
function layout(){
for(const it of items){measure(it);size(it);}
const gap=em*GAPX,limit=W*FITW,rows=[],hard=[];
for(const it of items)(hard[it.row]=hard[it.row]||[]).push(it);
for(const row of hard){if(!row)continue;
const total=row.reduce((s,l,k)=>s+l.bw+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.bw>cap){rows.push(cur);cur=[];x=0;}x+=(cur.length?gap:0)+l.bw;cur.push(l);}
rows.push(cur);}
const rowH=rows.map(r=>Math.max(...r.map(l=>l.bh))),vgap=em*GAPY,totH=rowH.reduce((a,h)=>a+h,0)+vgap*(rows.length-1);
let y=totH/2,maxW=1;
rows.forEach((row,r)=>{
let x=0;for(const l of row){l.x=x+l.bw/2;x+=l.bw+gap;}
const tw=x-gap;maxW=Math.max(maxW,tw);
const cy=y-rowH[r]/2;for(const l of row){l.x-=tw/2;l.y=cy+l.dy;}
y-=rowH[r]+vgap;
});
fit=Math.min(1,W*FITW/maxW,H*FITH/totH);root.scale.setScalar(fit);boxW=maxW;boxH=totH;
if(typeof afterLayout==='function')afterLayout();
}
// ---- 手描きの丸囲み・下線 ----
const PEN=['#ff5d73','#ffd23f','#4fd8ff','#7cf29a','#ff9f43','#c79bff'];
const MARK=['#ffd83a','#ff7fb5','#58d0ff','#9be84a'];
for(const it of items){
const pen=it.emph?(it.col||accent):PEN[(it.i*5+Math.floor(random()*2))%PEN.length];
it.pen=pen;it.style=it.emph?'circle':['circle','swoosh','mark','wave','double','circle'][it.i%6];
it.ph=random()*6.28;it.dir=random()<.5?1:-1;it.tilt=(random()-.5)*.12;it.dy=(random()-.5)*em*.14;
it.r1=random();it.r2=random();it.r3=random();
const g=new THREE.Group();g.visible=false;root.add(g);it.g=g;
const sg=new THREE.Group();g.add(sg);it.sg=sg;
const tx=makeText(it,null,{outlineWidth:'4%',outlineColor:'#101819'});g.add(tx);
it.strokes=[];
const add=(c,o,ord)=>{const m=deco(it,new THREE.BufferGeometry(),c,o,ord);sg.add(m);it.strokes.push(m);return m;};
if(it.style==='circle'){it.s1=add(pen,1,2);it.s2=add(pen,1,3);it.s3=add(pen,1,4);it.s4=add(pen,1,5);}
else if(it.style==='mark'){it.s1=add(MARK[it.i%MARK.length],.85,1);it.s2=add(pen,1,2);}
else if(it.style==='double'){it.s1=add(pen,1,2);it.s2=add(pen,1,3);}
else{it.s1=add(pen,1,2);it.s2=add(pen,1,3);}
}
function size(it){
const sz=it.sz,tw=it.tw,th=it.th,lw=Math.max(3,em*.12*Math.min(1.2,sz));it.lw=lw;
const rx=tw/2+em*.3*sz,ry=th/2+em*.17*sz;
it.bw=it.style==='circle'?2*rx*1.2:tw+em*.8*sz+em*.2*sz;it.bh=it.style==='circle'?2*(ry*1.16+em*sz*.3):th+em*.55*sz+lw*3;
const R=it.r1;
if(it.style==='circle'){
const N=72,P=[],a0=Math.PI*.82,sweep=Math.PI*2*1.1*it.dir;
for(let k=0;k<N;k++){const u=k/(N-1),a=a0+sweep*u,wob=1+.035*Math.sin(u*11+it.ph),grow=.96+.1*u;
P.push(V(Math.cos(a)*rx*grow*wob+u*rx*.03,Math.sin(a)*ry*grow*wob*(1+.05*Math.sin(u*7+it.ph*2))));}
regeo(it.s1,ribbonGeo(P,u=>lw*1.15*(.35+.65*smooth(u/.12))*(1-.75*smooth((u-.86)/.14))));
// きらきらの短い線(右上):楕円の縁から外向きに
[it.s2,it.s3,it.s4].forEach((m,j)=>{
const a=1.0+(j-1)*.42+it.r2*.15,px=Math.cos(a)*rx*1.1,py=Math.sin(a)*ry*1.1;
let nx=Math.cos(a)/rx,ny=Math.sin(a)/ry;const nl=Math.hypot(nx,ny);nx/=nl;ny/=nl;
const len=em*sz*(.24+.05*(j%2));
const pts=[];for(let k=0;k<7;k++){const u=k/6;pts.push(V(px+nx*len*u,py+ny*len*u));}
regeo(m,ribbonGeo(pts,u=>lw*.9*(1-.6*u)));
});
}else if(it.style==='mark'){
const w=tw+em*.34*sz,h=th*.62,sk=h*.28;
regeo(it.s1,new THREE.ShapeGeometry(new THREE.Shape([V(0,-h/2),V(w-sk,-h/2),V(w,h/2),V(sk*.4,h/2)])));
it.s1.position.set(-w/2,-th*.1,0);it.s1.rotation.z=-.025;
const y0=-th/2-lw*.2,pts=[];for(let k=0;k<30;k++){const u=k/29;pts.push(V(-tw/2-em*.1+u*(tw+em*.2),y0-Math.sin(u*Math.PI)*lw*.9+(it.r2-.5)*lw));}
regeo(it.s2,ribbonGeo(pts,u=>lw*(.3+.7*Math.sin(Math.PI*u)**.6)));
}else{
const w=tw+em*.24*sz,n=44,y0=-th*.5+em*.02*sz;
const line=(len,dy,wav)=>{const pts=[];for(let k=0;k<n;k++){const u=k/(n-1),x=-len/2+u*len;
let y=y0+dy-Math.sin(u*Math.PI)*lw*.7*(it.dir)+(it.r2-.5)*lw*1.6*(u-.5);
if(wav)y+=Math.sin(u*Math.PI*2*Math.max(2,Math.round(len/(em*.5)))+it.ph)*lw*1.1;
pts.push(V(x,y));}return pts;};
const R=em*.2*sz;it.spR=R;
if(it.style!=='double'){regeo(it.s2,starGeo(R,R*.28,4,Math.PI/2));it.s2.position.set(w/2+R*.7,-th*.5+R*.9,0);}
if(it.style==='wave')regeo(it.s1,ribbonGeo(line(w,0,true),u=>lw*(.45+.55*Math.sin(Math.PI*u)**.5)));
else regeo(it.s1,ribbonGeo(line(w,0,false),u=>lw*(.3+.7*Math.sin(Math.PI*u)**.7)));
if(it.style==='double')regeo(it.s2,ribbonGeo(line(w*.72,-lw*2.6,false),u=>lw*.8*(.3+.7*Math.sin(Math.PI*u)**.7)));
}
}
function drawSpan(lt,t0,d){return clamp((lt-t0)/d);}
function pose(it,t,lt,q){
const g=it.g;
if(lt<0||q<=0.001){g.visible=false;it.mesh.visible=false;return;}
g.visible=true;
const u=clamp(lt/.28),pop=back(u),a=smooth(lt/.1)*q;
const ex=1-q;
const f=Math.floor(t*9);
g.position.set(it.x,it.y-em*.1*ex,0);
g.rotation.z=it.tilt+(1-pop)*.25*(it.i%2?1:-1);
g.scale.setScalar(mix(.55,1,pop)*(1-.12*ex));
// 手描きの線は細かく震える(時刻から決める)
it.sg.position.set((hash(f,it.i)-.5)*em*.022,(hash(f,it.i+9)-.5)*em*.022,0);it.sg.rotation.z=(hash(f,it.i+17)-.5)*.014;
fade(it,a);
const s=it.style;
if(s==='circle'){
drawTo(it.s1,drawSpan(lt,.2,.62));
drawTo(it.s2,drawSpan(lt,.78,.1));drawTo(it.s3,drawSpan(lt,.86,.1));drawTo(it.s4,drawSpan(lt,.94,.1));
}else if(s==='mark'){
it.s1.scale.x=Math.max(.001,smooth(drawSpan(lt,.18,.4)));it.s1.visible=it.s1.scale.x>.002;
drawTo(it.s2,drawSpan(lt,.5,.3));
}else if(s==='double'){
drawTo(it.s1,drawSpan(lt,.2,.36));drawTo(it.s2,drawSpan(lt,.48,.3));
}else{drawTo(it.s1,drawSpan(lt,.2,.45));const k=back(drawSpan(lt,.6,.3));it.s2.scale.setScalar(Math.max(.001,k));it.s2.rotation.z=(1-k)*2;it.s2.visible=k>.01;}
it.mesh.visible=a>.002;it.mesh.fillOpacity=a;it.mesh.outlineOpacity=a;
}
function update(time){
if(dead)return;
const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;
for(const it of items){const r=it.mesh&&it.mesh.textRenderInfo;if(r&&it.info!==r){it.info=r;changed=true;}}
if(changed){layout();laid=true;}
const q=smooth(clamp((life-t)/outro));
for(const it of items)pose(it,t,t-it.t0,q);
if(typeof frame==='function')frame(t,q);
}
function dispose(){
if(dead)return;dead=true;
root.traverse(o=>{
if(typeof o.dispose==='function'){o.dispose();return;}
if(o.geometry)o.geometry.dispose();
if(o.material&&o.material.dispose)o.material.dispose();
});
scene.remove(root);
}
update(0);
return {update,dispose};
黒板に語ごとのチョーク文字と下線が書かれ、かすれや粉が時刻とともに揺らぎます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 黒板に、語ごとにチョークで書いた文字と下線・星の落書きが現れる。文字は震えるかすれと粉で、チョークの粉っぽさは時刻から決める。黒板は枠とチョーク受けつきで、縁の線が一周して描かれる
const MAXWORDS=8;
const {THREE,scene,createText,text,width,height,fontSize,duration,random,runs,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const back=x=>{x=clamp(x)-1;return 1+2.2*x*x*x+1.2*x*x};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const outro=Math.min(.5,life*.18);
const root=new THREE.Group();scene.add(root);
const lum=c=>{try{const n=new THREE.Color(c).getHex();return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255}catch(e){return .5}};
const shade=(c,to,k)=>{const a=new THREE.Color(c);a.lerp(new THREE.Color(to),k);return '#'+a.getHexString()};
const V=(x,y)=>new THREE.Vector2(x,y);
// 同じ書式(強調・色・大きさ)の中か
function runAtOff(o){let a=0;for(const r of runs||[]){const n=String(r.text||'').length;if(o<a+n)return r;a+=n;}return null;}
function sameRun(a,b){const x=runAtOff(a)||{},y=runAtOff(b)||{};return !!x.emphasis===!!y.emphasis&&(x.color||'')===(y.color||'')&&(x.scale||1)===(y.scale||1);}
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。多すぎる語はまとめ、1語だけなら半分に割る)
function splitWords(maxWords){
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
const last=merged[merged.length-1];
if(last&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))last.s+=p.s;
else if(last&&/^[ぁ-ゟー]{1,3}$/.test(p.s)&&last.s.length+p.s.length<=6&&!/[、。,.!?!?…]$/.test(last.s)&&last.start+last.s.length===p.start&&sameRun(last.start+off,p.start+off))last.s+=p.s; // 助詞や語尾は前の語につける
else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>maxWords){const c=Math.ceil(list.length/maxWords),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);if(g.every(x=>x.row===g[0].row))m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});else g.forEach(x=>m.push(x));}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2),f=a.slice(0,h).join('');list=[{s:f,start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+f.length,row:list[0].row}];}
return list;
}
// 語の強調・色・大きさ(run の scale)を調べる
function runInfo(start,len){
let o=0,emph=false,col=null,ws=0,ss=0;
for(const r of runs||[]){
const n=String(r.text||'').length,a=Math.max(o,start),b=Math.min(o+n,start+len);
if(b>a){const sc=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;let w=0;for(const ch of Array.from(String(text).slice(a,b)))w+=ch.codePointAt(0)<256?.6:1;w=Math.max(.3,w);ws+=w;ss+=w*sc;if(r.emphasis||r.color)emph=true;if(r.color&&!col)col=r.color;}
o+=n;if(o>=start+len)break;
}
return {emph,col,scale:ws?ss/ws:1};
}
const SIZES=[1.16,.86,1.04,.9,1.22,.92];
const items=splitWords(MAXWORDS).map((w,i)=>{
const inf=runInfo(w.start,w.s.length);
return {...w,i,emph:inf.emph,col:inf.col,rs:clamp(inf.scale,.4,3),sz:SIZES[i%SIZES.length]*(.93+random()*.14)*(inf.emph?1.08:1),tw:0,th:0,bw:0,bh:0,x:0,y:0,dy:0,info:null,fx:[]};
});
const nWords=items.length,spread=Math.min(life*.25,(nWords-1)*.26);
items.forEach(it=>{it.t0=nWords>1?spread*it.i/(nWords-1):0;});
// 語の文字メッシュ(強調語の色・縁取り・中抜き・語の大きさは createText が textStart から自動で効かせる)
function makeText(it,ink,extra){
const opt={textStart:it.start,fontSize:em*it.sz,anchorX:'center',anchorY:'middle',...(extra||{})};if(ink)opt.color=ink;
const m=createText(it.s,opt);
m.fillOpacity=0;m.outlineOpacity=0;m.renderOrder=it.i*16+14;
if(m.material){m.material.depthTest=false;m.material.depthWrite=false;}
it.mesh=m;return m;
}
function rrPts(w,h,r,cut,cs){
r=Math.max(.01,Math.min(r,w/2,h/2));const hw=w/2,hh=h/2,P=[];
const arc=(cx,cy,a0)=>{for(let k=0;k<=6;k++){const a=a0+k*Math.PI/12;P.push(V(cx+r*Math.cos(a),cy+r*Math.sin(a)));}};
arc(hw-r,-hh+r,-Math.PI/2);
if(cut>0){P.push(V(hw,hh-cut),V(hw-cut,hh));}else arc(hw-r,hh-r,0);
arc(-hw+r,hh-r,Math.PI/2);
arc(-hw+r,-hh+r,Math.PI);
if(cs<0)for(const p of P)p.x=-p.x;
return P;
}
const rrGeo=(w,h,r,cut,cs)=>new THREE.ShapeGeometry(new THREE.Shape(rrPts(w,h,r,cut,cs)));
function starGeo(R,r,n,rot){const P=[];for(let k=0;k<n*2;k++){const a=rot+k*Math.PI/n,rr=k%2?r:R;P.push(V(Math.cos(a)*rr,Math.sin(a)*rr));}return new THREE.ShapeGeometry(new THREE.Shape(P));}
// 筆の線:点列を幅つきの帯にして、描画範囲(drawRange)で一筆書きのように伸ばす
function ribbonGeo(pts,wf){
const n=pts.length,pos=new Float32Array(n*6),idx=[];
for(let i=0;i<n;i++){
const a=pts[Math.max(0,i-1)],b=pts[Math.min(n-1,i+1)];
let dx=b.x-a.x,dy=b.y-a.y;const l=Math.hypot(dx,dy)||1;dx/=l;dy/=l;
const w=wf(i/(n-1),i)/2,nx=-dy*w,ny=dx*w;
pos.set([pts[i].x+nx,pts[i].y+ny,0,pts[i].x-nx,pts[i].y-ny,0],i*6);
if(i<n-1){const k=i*2;idx.push(k,k+1,k+2,k+1,k+3,k+2);}
}
const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.BufferAttribute(pos,3));g.setIndex(idx);g.userData.segs=n-1;return g;
}
const drawTo=(m,p)=>{const g=m.geometry,s=g.userData.segs||0;g.setDrawRange(0,Math.floor(s*clamp(p))*6);m.visible=p>.001;};
const mkMat=(c,o=1)=>new THREE.MeshBasicMaterial({color:c,transparent:true,opacity:o,depthTest:false,depthWrite:false,side:THREE.DoubleSide});
// 装飾メッシュ。it.fx に不透明度の基準を覚えさせ、まとめて薄くできる
function deco(it,geo,c,o,order){
const m=new THREE.Mesh(geo,mkMat(c,o==null?1:o));m.renderOrder=it.i*16+order;it.fx.push([m.material,o==null?1:o]);return m;
}
function regeo(m,geo){if(m.geometry)m.geometry.dispose();m.geometry=geo;}
function fade(it,a){for(const f of it.fx)f[0].opacity=f[1]*a;}
function measure(it){
const r=it.mesh&&it.mesh.textRenderInfo,b=r&&r.blockBounds;
let base;
if(b&&b.length===4&&b.every(Number.isFinite))base=Math.max(em*it.sz*.3,b[2]-b[0]);
else{let n=0;for(const ch of Array.from(it.s))n+=ch.codePointAt(0)<256?.6:1;base=em*it.sz*n;}
it.tw=base*it.rs;it.th=em*it.sz*1.12*Math.max(.7,it.rs);
}
let fit=1,laid=false,dead=false,FITW=.86,FITH=.7,GAPX=.22,GAPY=.1,boxW=1,boxH=1;
function layout(){
for(const it of items){measure(it);size(it);}
const gap=em*GAPX,limit=W*FITW,rows=[],hard=[];
for(const it of items)(hard[it.row]=hard[it.row]||[]).push(it);
for(const row of hard){if(!row)continue;
const total=row.reduce((s,l,k)=>s+l.bw+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.bw>cap){rows.push(cur);cur=[];x=0;}x+=(cur.length?gap:0)+l.bw;cur.push(l);}
rows.push(cur);}
const rowH=rows.map(r=>Math.max(...r.map(l=>l.bh))),vgap=em*GAPY,totH=rowH.reduce((a,h)=>a+h,0)+vgap*(rows.length-1);
let y=totH/2,maxW=1;
rows.forEach((row,r)=>{
let x=0;for(const l of row){l.x=x+l.bw/2;x+=l.bw+gap;}
const tw=x-gap;maxW=Math.max(maxW,tw);
const cy=y-rowH[r]/2;for(const l of row){l.x-=tw/2;l.y=cy+l.dy;}
y-=rowH[r]+vgap;
});
fit=Math.min(1,W*FITW/maxW,H*FITH/totH);root.scale.setScalar(fit);boxW=maxW;boxH=totH;
if(typeof afterLayout==='function')afterLayout();
}
// ---- 黒板チョーク ----
FITW=.64;FITH=.44;GAPX=.3;GAPY=.16;
const CH=['#f7f5e8','#ffe28a','#ffb7c5','#aee3ff','#c9f2a6','#f7f5e8'];
const SLATE='#25433a',DUSTC='#f2efe0';
const bd=new THREE.Group();root.add(bd);
const bdFx=[];
function bdMesh(c,o,ord){const m=new THREE.Mesh(new THREE.BufferGeometry(),mkMat(c,o));m.renderOrder=-60+ord;bdFx.push([m.material,o]);bd.add(m);return m;}
const frameM=bdMesh('#9a7449',1,0),slateM=bdMesh(SLATE,.97,1),sm1=bdMesh('#294a40',1,2),sm2=bdMesh('#294a40',1,2),trayM=bdMesh('#7d5c38',1,0),ch1=bdMesh('#f7f5e8',1,3),ch2=bdMesh('#ffb7c5',1,3),borderM=bdMesh('#e9e6d6',.7,4);
for(const it of items){
const ink=CH[(it.i*2+Math.floor(random()*2))%CH.length];
it.ink=ink;it.tilt=(random()-.5)*.08;it.dy=(random()-.5)*em*.12;
it.under=it.emph||it.i%3===1;it.r1=random();it.r2=random();
const g=new THREE.Group();g.visible=false;root.add(g);it.g=g;
const sg=new THREE.Group();g.add(sg);it.sg=sg;
const ghost=makeText(it,ink);ghost.renderOrder=it.i*16+13;
const base=makeText(it,ink);
it.ghost=ghost;g.add(ghost);g.add(base);
const mk=(c,o)=>{const m=deco(it,new THREE.BufferGeometry(),c,o,10);m.renderOrder=it.i*16+9;sg.add(m);return m;};
const uc=it.emph?(it.col||accent):it.ink;
it.u1=mk(uc,.9);it.u2=mk(uc,.55);
it.star=it.emph?mk(uc,.9):null;
}
function size(it){
const sz=it.sz,tw=it.tw,th=it.th,lw=Math.max(2,em*.062*Math.min(1.2,sz));
it.bw=tw+em*.3*sz+(it.emph?em*.35*sz:0);it.bh=th+em*.3*sz+(it.under?lw*2:0);
const chalkW=(len,seed,k0)=>u=>lw*k0*(.3+.7*hash(Math.floor(u*60),seed))*(hash(Math.floor(u*38)+3,seed)>.84?.15:1)*(.45+.55*Math.sin(Math.PI*Math.min(1,u*1.1)));
const n=48,y0=-th*.5+lw*.2;
const pts=(len,dy,wob)=>{const P=[];for(let k=0;k<n;k++){const u=k/(n-1);P.push(V(-len/2+u*len,y0+dy+Math.sin(u*3+it.r1*6)*lw*wob-u*lw*.8*(it.r2-.5)));}return P;};
const len=tw+em*.2*sz;
regeo(it.u1,ribbonGeo(pts(len,0,.5),chalkW(len,it.i+1,1.1)));
regeo(it.u2,ribbonGeo(pts(len*.8,-lw*1.7,.6).map(p=>V(p.x+len*.1,p.y)),chalkW(len,it.i+7,.7)));
if(it.star){
const R=em*.2*sz,P=[],cx=tw/2+em*.12*sz,cy=th/2;
for(let k=0;k<=10;k++){const a=Math.PI/2+k*Math.PI*4/5,u=k/10;P.push(V(cx+Math.cos(a)*R,cy+Math.sin(a)*R));}
regeo(it.star,ribbonGeo(P,u=>lw*.75*(1-.4*u)));
}
}
// 粉:文字の欠け(板の色の点)と、落ちる粉
const SPK=Math.min(46,MAXWORDS*8),DST=Math.min(30,MAXWORDS*4);
function pointsMesh(n,col,sizePx,op,ord){
const geo=new THREE.BufferGeometry();geo.setAttribute('position',new THREE.BufferAttribute(new Float32Array(n*3),3));
const m=new THREE.Points(geo,new THREE.PointsMaterial({color:col,size:sizePx,sizeAttenuation:false,transparent:true,opacity:op,depthTest:false,depthWrite:false}));
m.renderOrder=ord;m.frustumCulled=false;root.add(m);return m;
}
const specks=pointsMesh(SPK,SLATE,Math.max(1.8,H/130),1,5000);
const dust=pointsMesh(DST,DUSTC,Math.max(1.6,H/150),.6,5001);
const spk=[],dst=[];
for(let k=0;k<SPK;k++)spk.push({u:random()-.5,v:random()-.5,s:random()});
for(let k=0;k<DST;k++)dst.push({u:random()-.5,s:random()});
function afterLayout(){
const mB=Math.min(em*.5,W*.06),bw=boxW*fit+2*mB,bh=boxH*fit+2*mB,fr=Math.max(3,em*.1);
bd.scale.setScalar(1/fit);
const rr=(w,h,r)=>rrGeo(w,h,r,0,1);
regeo(frameM,rr(bw+2*fr,bh+2*fr,fr*1.4));regeo(slateM,rr(bw,bh,fr*.5));
regeo(sm1,new THREE.ShapeGeometry(new THREE.Shape(ellPtsC(bw*.3,bh*.16,28,-bw*.18,bh*.2))));
regeo(sm2,new THREE.ShapeGeometry(new THREE.Shape(ellPtsC(bw*.22,bh*.12,28,bw*.2,-bh*.24))));
const th=fr*1.5;regeo(trayM,rr(bw*.86,th,th*.3));trayM.position.set(0,-(bh/2+fr+th*.35),0);
regeo(ch1,rr(bw*.12,th*.55,th*.2));ch1.position.set(-bw*.2,-(bh/2+fr+th*.35)+th*.55,0);
regeo(ch2,rr(bw*.07,th*.5,th*.2));ch2.position.set(-bw*.06,-(bh/2+fr+th*.35)+th*.5,0);ch2.rotation.z=.05;
// 縁の線(一周して描かれる)
const ins=mB*.4,w2=bw-2*ins,h2=bh-2*ins,P=[],per=2*(w2+h2),N=90;
for(let k=0;k<=N;k++){let d=k/N*per,x,y;
if(d<w2){x=-w2/2+d;y=h2/2;}else if((d-=w2)<h2){x=w2/2;y=h2/2-d;}else if((d-=h2)<w2){x=w2/2-d;y=-h2/2;}else{d-=w2;x=-w2/2;y=-h2/2+d;}
P.push(V(x+Math.sin(k*.7)*.7,y+Math.cos(k*.9)*.7));}
regeo(borderM,ribbonGeo(P,(u,i)=>Math.max(1.5,em*.028)*(.4+.6*hash(i,5))*(hash(i,9)>.88?.1:1)));
it0.mB=mB;bdInfo.bw=bw;bdInfo.bh=bh;
}
const it0={},bdInfo={bw:1,bh:1};
function ellPtsC(rx,ry,n,cx,cy){const P=[];for(let k=0;k<n;k++){const a=k/n*Math.PI*2;P.push(V(cx+Math.cos(a)*rx,cy+Math.sin(a)*ry));}return P;}
function pose(it,t,lt,q){
const g=it.g;
if(lt<0||q<=0.001){g.visible=false;it.mesh.visible=false;it.ghost.visible=false;return;}
g.visible=true;
const u=clamp(lt/.22),a=smooth(lt/.1)*q,f=Math.floor(t*10);
g.position.set(it.x,it.y,0);g.rotation.z=it.tilt;
const s=mix(1.12,1,smooth(u));g.scale.setScalar(s);
// かすれて揺れる:下書きの影が、時刻ごとに少しずれる
it.ghost.position.set((hash(f,it.i)-.5)*em*.035,(hash(f,it.i+5)-.5)*em*.035,0);
it.sg.position.set((hash(f,it.i+9)-.5)*em*.016,(hash(f,it.i+13)-.5)*em*.016,0);
const w=.82+.18*hash(Math.floor(t*7),it.i+21);
it.mesh.visible=a>.002;it.mesh.fillOpacity=a*.96*w;it.mesh.outlineOpacity=0;
it.ghost.visible=a>.002;it.ghost.fillOpacity=a*.5*(1.15-w);it.ghost.outlineOpacity=0;
fade(it,a);
const d0=.3+(it.i%2)*.04;
if(it.under){drawTo(it.u1,clamp((lt-d0)/.4));drawTo(it.u2,clamp((lt-d0-.32)/.3));}else{it.u1.visible=false;it.u2.visible=false;}
if(it.star)drawTo(it.star,clamp((lt-d0-.55)/.35));
}
function frame(t,q){
// 黒板:上からふわっと現れる
const u=clamp(t/.4),a=smooth(t/.15)*q;
bd.scale.set(1/fit*mix(.94,1,spring(u)),1/fit*mix(.7,1,spring(u)),1);
for(const f of bdFx)f[0].opacity=f[1]*a;
borderM.visible=t>.15;if(t>.15)drawTo(borderM,clamp((t-.15)/.7));
// 文字の欠け
const sp=specks.geometry.attributes.position.array,fl=Math.floor(t*6);
for(let k=0;k<SPK;k++){
const it=items[k%items.length],c=spk[k],on=it.g.visible&&q>.98&&t-it.t0>.3&&hash(k,fl)>.4;
if(on){sp[k*3]=it.x+c.u*it.tw*1.02;sp[k*3+1]=it.y+c.v*it.th*.8;sp[k*3+2]=0;}else{sp[k*3]=1e5;sp[k*3+1]=1e5;sp[k*3+2]=0;}
}
specks.geometry.attributes.position.needsUpdate=true;specks.material.opacity=a;
// 落ちる粉
const dp=dust.geometry.attributes.position.array;
for(let k=0;k<DST;k++){
const it=items[k%items.length],c=dst[k],age=((t*.9+c.s)%1+1)%1,on=it.g.visible&&q>.5&&t-it.t0>.3&&age<.9;
if(on){dp[k*3]=it.x+c.u*it.tw+Math.sin(age*9+k)*em*.03;dp[k*3+1]=it.y-it.th*.3-age*em*.42;dp[k*3+2]=0;}else{dp[k*3]=1e5;dp[k*3+1]=1e5;dp[k*3+2]=0;}
}
dust.geometry.attributes.position.needsUpdate=true;dust.material.opacity=.6*q*(.6+.4*Math.sin(t*5));
}
function update(time){
if(dead)return;
const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;
for(const it of items){const r=it.mesh&&it.mesh.textRenderInfo;if(r&&it.info!==r){it.info=r;changed=true;}}
if(changed){layout();laid=true;}
const q=smooth(clamp((life-t)/outro));
for(const it of items)pose(it,t,t-it.t0,q);
if(typeof frame==='function')frame(t,q);
}
function dispose(){
if(dead)return;dead=true;
root.traverse(o=>{
if(typeof o.dispose==='function'){o.dispose();return;}
if(o.geometry)o.geometry.dispose();
if(o.material&&o.material.dispose)o.material.dispose();
});
scene.remove(root);
}
update(0);
return {update,dispose};
語ごとに縫い目つきのワッペンになり、コインのように返ったり回ったりして止まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語ごとに縁取りと縫い目のあるワッペンになって、くるっと回って止まる。丸・波打つ縁・六角・角丸が混ざり、回り方(コインのように返る/くるくる回る)も語ごとに違う
const MAXWORDS=8;
const {THREE,scene,createText,text,width,height,fontSize,duration,random,runs,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const back=x=>{x=clamp(x)-1;return 1+2.2*x*x*x+1.2*x*x};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const outro=Math.min(.5,life*.18);
const root=new THREE.Group();scene.add(root);
const lum=c=>{try{const n=new THREE.Color(c).getHex();return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255}catch(e){return .5}};
const shade=(c,to,k)=>{const a=new THREE.Color(c);a.lerp(new THREE.Color(to),k);return '#'+a.getHexString()};
const V=(x,y)=>new THREE.Vector2(x,y);
// 同じ書式(強調・色・大きさ)の中か
function runAtOff(o){let a=0;for(const r of runs||[]){const n=String(r.text||'').length;if(o<a+n)return r;a+=n;}return null;}
function sameRun(a,b){const x=runAtOff(a)||{},y=runAtOff(b)||{};return !!x.emphasis===!!y.emphasis&&(x.color||'')===(y.color||'')&&(x.scale||1)===(y.scale||1);}
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。多すぎる語はまとめ、1語だけなら半分に割る)
function splitWords(maxWords){
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
const last=merged[merged.length-1];
if(last&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))last.s+=p.s;
else if(last&&/^[ぁ-ゟー]{1,3}$/.test(p.s)&&last.s.length+p.s.length<=6&&!/[、。,.!?!?…]$/.test(last.s)&&last.start+last.s.length===p.start&&sameRun(last.start+off,p.start+off))last.s+=p.s; // 助詞や語尾は前の語につける
else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>maxWords){const c=Math.ceil(list.length/maxWords),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);if(g.every(x=>x.row===g[0].row))m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});else g.forEach(x=>m.push(x));}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2),f=a.slice(0,h).join('');list=[{s:f,start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+f.length,row:list[0].row}];}
return list;
}
// 語の強調・色・大きさ(run の scale)を調べる
function runInfo(start,len){
let o=0,emph=false,col=null,ws=0,ss=0;
for(const r of runs||[]){
const n=String(r.text||'').length,a=Math.max(o,start),b=Math.min(o+n,start+len);
if(b>a){const sc=Number.isFinite(r.scale)&&r.scale>0?r.scale:1;let w=0;for(const ch of Array.from(String(text).slice(a,b)))w+=ch.codePointAt(0)<256?.6:1;w=Math.max(.3,w);ws+=w;ss+=w*sc;if(r.emphasis||r.color)emph=true;if(r.color&&!col)col=r.color;}
o+=n;if(o>=start+len)break;
}
return {emph,col,scale:ws?ss/ws:1};
}
const SIZES=[1.16,.86,1.04,.9,1.22,.92];
const items=splitWords(MAXWORDS).map((w,i)=>{
const inf=runInfo(w.start,w.s.length);
return {...w,i,emph:inf.emph,col:inf.col,rs:clamp(inf.scale,.4,3),sz:SIZES[i%SIZES.length]*(.93+random()*.14)*(inf.emph?1.08:1),tw:0,th:0,bw:0,bh:0,x:0,y:0,dy:0,info:null,fx:[]};
});
const nWords=items.length,spread=Math.min(life*.25,(nWords-1)*.26);
items.forEach(it=>{it.t0=nWords>1?spread*it.i/(nWords-1):0;});
// 語の文字メッシュ(強調語の色・縁取り・中抜き・語の大きさは createText が textStart から自動で効かせる)
function makeText(it,ink,extra){
const opt={textStart:it.start,fontSize:em*it.sz,anchorX:'center',anchorY:'middle',...(extra||{})};if(ink)opt.color=ink;
const m=createText(it.s,opt);
m.fillOpacity=0;m.outlineOpacity=0;m.renderOrder=it.i*16+14;
if(m.material){m.material.depthTest=false;m.material.depthWrite=false;}
it.mesh=m;return m;
}
function rrPts(w,h,r,cut,cs){
r=Math.max(.01,Math.min(r,w/2,h/2));const hw=w/2,hh=h/2,P=[];
const arc=(cx,cy,a0)=>{for(let k=0;k<=6;k++){const a=a0+k*Math.PI/12;P.push(V(cx+r*Math.cos(a),cy+r*Math.sin(a)));}};
arc(hw-r,-hh+r,-Math.PI/2);
if(cut>0){P.push(V(hw,hh-cut),V(hw-cut,hh));}else arc(hw-r,hh-r,0);
arc(-hw+r,hh-r,Math.PI/2);
arc(-hw+r,-hh+r,Math.PI);
if(cs<0)for(const p of P)p.x=-p.x;
return P;
}
const rrGeo=(w,h,r,cut,cs)=>new THREE.ShapeGeometry(new THREE.Shape(rrPts(w,h,r,cut,cs)));
function starGeo(R,r,n,rot){const P=[];for(let k=0;k<n*2;k++){const a=rot+k*Math.PI/n,rr=k%2?r:R;P.push(V(Math.cos(a)*rr,Math.sin(a)*rr));}return new THREE.ShapeGeometry(new THREE.Shape(P));}
// 筆の線:点列を幅つきの帯にして、描画範囲(drawRange)で一筆書きのように伸ばす
function ribbonGeo(pts,wf){
const n=pts.length,pos=new Float32Array(n*6),idx=[];
for(let i=0;i<n;i++){
const a=pts[Math.max(0,i-1)],b=pts[Math.min(n-1,i+1)];
let dx=b.x-a.x,dy=b.y-a.y;const l=Math.hypot(dx,dy)||1;dx/=l;dy/=l;
const w=wf(i/(n-1),i)/2,nx=-dy*w,ny=dx*w;
pos.set([pts[i].x+nx,pts[i].y+ny,0,pts[i].x-nx,pts[i].y-ny,0],i*6);
if(i<n-1){const k=i*2;idx.push(k,k+1,k+2,k+1,k+3,k+2);}
}
const g=new THREE.BufferGeometry();g.setAttribute('position',new THREE.BufferAttribute(pos,3));g.setIndex(idx);g.userData.segs=n-1;return g;
}
const drawTo=(m,p)=>{const g=m.geometry,s=g.userData.segs||0;g.setDrawRange(0,Math.floor(s*clamp(p))*6);m.visible=p>.001;};
const mkMat=(c,o=1)=>new THREE.MeshBasicMaterial({color:c,transparent:true,opacity:o,depthTest:false,depthWrite:false,side:THREE.DoubleSide});
// 装飾メッシュ。it.fx に不透明度の基準を覚えさせ、まとめて薄くできる
function deco(it,geo,c,o,order){
const m=new THREE.Mesh(geo,mkMat(c,o==null?1:o));m.renderOrder=it.i*16+order;it.fx.push([m.material,o==null?1:o]);return m;
}
function regeo(m,geo){if(m.geometry)m.geometry.dispose();m.geometry=geo;}
function fade(it,a){for(const f of it.fx)f[0].opacity=f[1]*a;}
function measure(it){
const r=it.mesh&&it.mesh.textRenderInfo,b=r&&r.blockBounds;
let base;
if(b&&b.length===4&&b.every(Number.isFinite))base=Math.max(em*it.sz*.3,b[2]-b[0]);
else{let n=0;for(const ch of Array.from(it.s))n+=ch.codePointAt(0)<256?.6:1;base=em*it.sz*n;}
it.tw=base*it.rs;it.th=em*it.sz*1.12*Math.max(.7,it.rs);
}
let fit=1,laid=false,dead=false,FITW=.86,FITH=.7,GAPX=.22,GAPY=.1,boxW=1,boxH=1;
function layout(){
for(const it of items){measure(it);size(it);}
const gap=em*GAPX,limit=W*FITW,rows=[],hard=[];
for(const it of items)(hard[it.row]=hard[it.row]||[]).push(it);
for(const row of hard){if(!row)continue;
const total=row.reduce((s,l,k)=>s+l.bw+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.bw>cap){rows.push(cur);cur=[];x=0;}x+=(cur.length?gap:0)+l.bw;cur.push(l);}
rows.push(cur);}
const rowH=rows.map(r=>Math.max(...r.map(l=>l.bh))),vgap=em*GAPY,totH=rowH.reduce((a,h)=>a+h,0)+vgap*(rows.length-1);
let y=totH/2,maxW=1;
rows.forEach((row,r)=>{
let x=0;for(const l of row){l.x=x+l.bw/2;x+=l.bw+gap;}
const tw=x-gap;maxW=Math.max(maxW,tw);
const cy=y-rowH[r]/2;for(const l of row){l.x-=tw/2;l.y=cy+l.dy;}
y-=rowH[r]+vgap;
});
fit=Math.min(1,W*FITW/maxW,H*FITH/totH);root.scale.setScalar(fit);boxW=maxW;boxH=totH;
if(typeof afterLayout==='function')afterLayout();
}
// ---- ワッペン・バッジ ----
// [地, ふち, 縫い糸, 文字]
const PAIRS=[['#1f3a8a','#f4c430','#f6e7a1','#ffffff'],['#c0392b','#fff4e0','#ffe0a8','#ffffff'],['#14746f','#f2d16b','#fdf3c8','#ffffff'],['#f7e7b4','#2b2d42','#2b2d42','#2b2d42'],['#7b4fc4','#ffd6e8','#ffe9f2','#ffffff'],['#f2a43a','#2b2d42','#2b2d42','#2b2d42'],['#2e8b57','#fff4e0','#d8f5df','#ffffff']];
function ringPts(kind,w,h){
const rx=w/2,ry=h/2,P=[];
if(kind==='circle'){for(let k=0;k<48;k++){const a=k/48*Math.PI*2;P.push(V(Math.cos(a)*rx,Math.sin(a)*ry));}}
else if(kind==='scallop'){for(let k=0;k<120;k++){const a=k/120*Math.PI*2,m=1+.06*Math.cos(a*13);P.push(V(Math.cos(a)*rx*m,Math.sin(a)*ry*m));}}
else if(kind==='hex'){const c=Math.min(ry*.9,rx*.5);P.push(V(-rx,0),V(-rx+c,ry),V(rx-c,ry),V(rx,0),V(rx-c,-ry),V(-rx+c,-ry));}
else return rrPts(w,h,Math.min(rx,ry)*.5,0,1);
return P;
}
function resample(P,step){
const out=[],n=P.length;let carry=0;
for(let i=0;i<n;i++){
const a=P[i],b=P[(i+1)%n],len=Math.hypot(b.x-a.x,b.y-a.y);
let d=carry;
while(d<len){const k=d/len;out.push(V(a.x+(b.x-a.x)*k,a.y+(b.y-a.y)*k));d+=step;}
carry=d-len;
}
return out;
}
for(const it of items){
const nCh=Array.from(it.s).length;
it.kind=it.emph?'scallop':nCh<=2?(it.i%2?'circle':'scallop'):(it.i%2?'rrect':'hex');
let pair=PAIRS[(it.i*3+Math.floor(random()*2))%PAIRS.length];
if(it.emph)pair=lum(it.col||accent)>.5?['#1f2a5a','#ffd23f','#fff3c0','#ffffff']:['#fff4e0','#c0392b','#e8a08f','#2b2d42'];
it.pair=pair;it.mode=it.i%2;it.dir=random()<.5?1:-1;it.tilt=(random()-.5)*.12;it.dy=(random()-.5)*em*.14;
const g=new THREE.Group();g.visible=false;root.add(g);it.g=g;
it.shadow=deco(it,new THREE.BufferGeometry(),'#0b0d16',.38,0);
it.rim=deco(it,new THREE.BufferGeometry(),pair[1],1,1);
it.face=deco(it,new THREE.BufferGeometry(),pair[0],1,2);
it.back=deco(it,new THREE.BufferGeometry(),pair[2],1,3);
it.stitch=deco(it,new THREE.BufferGeometry(),pair[2],1,4);
it.stars=deco(it,new THREE.BufferGeometry(),pair[2],1,5);
it.veil=deco(it,new THREE.BufferGeometry(),'#000000',0,9);
g.add(it.shadow,it.rim,it.face,it.back,it.stitch,it.stars,it.veil);
const tx=makeText(it,pair[3]);g.add(tx);
}
function size(it){
const sz=it.sz,tw=it.tw,th=it.th,k=it.kind;
let fw,fh;
if(k==='circle'||k==='scallop'){fw=tw*1.28+em*.34*sz;fh=th*1.26+em*.22*sz;if(nChars(it)===1){fw=fh=Math.max(fw,fh);}}
else{fw=tw+em*.9*sz;fh=th+em*.34*sz;}
const e=Math.max(3,em*.1*sz),s=Math.max(2,em*.07*sz);
Object.assign(it,{fw,fh,e});
const pts=P=>new THREE.ShapeGeometry(new THREE.Shape(P));
regeo(it.shadow,pts(ringPts(k,fw+2*e,fh+2*e)));it.shadow.position.set(e*.7,-e*1.1,0);
regeo(it.rim,pts(ringPts(k,fw+2*e,fh+2*e)));
regeo(it.face,pts(ringPts(k,fw,fh)));
// 縫い目:内側に沿って短い糸を並べる
const ring=resample(ringPts(k,fw-2*s*1.6,fh-2*s*1.6),Math.max(5,em*.12*sz)),shapes=[],dl=Math.max(2.5,em*.06*sz),dw=Math.max(1.2,em*.024*sz);
const cap=Math.min(ring.length,110);
for(let j=0;j<cap;j++){
const a=ring[j],b=ring[(j+1)%ring.length];let dx=b.x-a.x,dy=b.y-a.y;const l=Math.hypot(dx,dy)||1;dx/=l;dy/=l;
const nx=-dy*dw/2,ny=dx*dw/2,ex=dx*dl;
shapes.push(new THREE.Shape([V(a.x+nx,a.y+ny),V(a.x+ex+nx,a.y+dy*dl+ny),V(a.x+ex-nx,a.y+dy*dl-ny),V(a.x-nx,a.y-ny)]));
}
regeo(it.stitch,new THREE.ShapeGeometry(shapes));
// 左右の小さな星(横長のとき)
const sh=[];
if((k==='rrect'||k==='hex')){const R=em*.1*sz,x=tw/2+(fw-tw)/4;for(const sx of [-1,1]){const P=[];for(let j=0;j<10;j++){const a=Math.PI/2+j*Math.PI/5,r=j%2?R*.45:R;P.push(V(sx*x+Math.cos(a)*r,Math.sin(a)*r));}sh.push(new THREE.Shape(P));}}
regeo(it.stars,sh.length?new THREE.ShapeGeometry(sh):new THREE.BufferGeometry());
{const R=Math.min(fw,fh)*.3,P=[];for(let j=0;j<10;j++){const a=Math.PI/2+j*Math.PI/5,r=j%2?R*.42:R;P.push(V(Math.cos(a)*r,Math.sin(a)*r));}regeo(it.back,pts(P));}
regeo(it.veil,pts(ringPts(k,fw+2*e,fh+2*e)));
it.bw=fw+2*e+e*2;it.bh=fh+2*e+e*2+em*.12;
}
function nChars(it){return Array.from(it.s).length;}
function pose(it,t,lt,q){
const g=it.g;
if(lt<0||q<=0.001){g.visible=false;it.mesh.visible=false;return;}
g.visible=true;
const D=.7,u=clamp(lt/D),e=1-Math.pow(1-u,3),land=Math.max(0,lt-D);
const ex=1-q;
let sx=1,front=true,rot=it.tilt;
const hop=Math.sin(Math.PI*u)*em*.38*(1-.4*u);
if(it.mode===0){const th=(1-e)*Math.PI*3;const c=Math.cos(th);sx=Math.max(.03,Math.abs(c));front=c>0;}
else{rot+=(1-e)*Math.PI*4*it.dir;}
const pop=mix(.25,1,smooth(lt/.22))*(lt>D?1-.1*Math.exp(-8*land)*Math.cos(16*land):1)*(1-.3*ex);
rot+=land>0?.025*Math.sin(t*2.3+it.i*1.3)*smooth(land*3):0;
g.position.set(it.x,it.y+hop-em*.1*ex,0);g.rotation.z=rot;g.scale.set(pop*sx,pop,1);
const a=smooth(lt/.08)*q;
fade(it,a);it.shadow.material.opacity=.38*a;
it.veil.material.opacity=(1-sx)*.5*a;
it.back.visible=!front&&a>.01;it.stitch.visible=front&&a>.01;it.stars.visible=front&&a>.01;
it.mesh.visible=front&&a>.002;it.mesh.fillOpacity=a;it.mesh.outlineOpacity=a;
}
function update(time){
if(dead)return;
const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;
for(const it of items){const r=it.mesh&&it.mesh.textRenderInfo;if(r&&it.info!==r){it.info=r;changed=true;}}
if(changed){layout();laid=true;}
const q=smooth(clamp((life-t)/outro));
for(const it of items)pose(it,t,t-it.t0,q);
if(typeof frame==='function')frame(t,q);
}
function dispose(){
if(dead)return;dead=true;
root.traverse(o=>{
if(typeof o.dispose==='function'){o.dispose();return;}
if(o.geometry)o.geometry.dispose();
if(o.material&&o.material.dispose)o.material.dispose();
});
scene.remove(root);
}
update(0);
return {update,dispose};
絵が線画の落書きになって、揺れながらばらばらに散っていきます。
// 絵が線画の落書きになって、揺れながらばらばらに散っていく
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float e = smoothstep(ph(1.) * .2, .45 + ph(1.) * .3, amount);
float a = ph(2.) * 6.2832 + uTime * (3. + ph(3.) * 4.);
vec2 scrawl = vec2(cos(a), sin(a * 1.37)) * (.05 + .14 * ph(4.)) * e;
vec2 drift = (vec2(ph(5.), ph(6.)) - .5) * 1.6 * e;
vec2 q = c + rot((ph(7.) - .5) * 2.4 * e) * l * (1. + e * 1.2) + scrawl + drift;
gKind = 9.;
return vec3(q, (ph(8.) - .5) * .5 * e);
}
線画になった粒が、渦を巻きながら中心へ絡まっていきます。
// 線画になった粒が、渦を巻きながら中心へ絡まっていく
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float r = length(c) / 3.;
float e = smoothstep(r * .15, .5 + r * .3, amount);
float turn = e * (3. + 2.5 * sin(r * 7. + uTime * .5)) * (1.2 - r);
vec2 q = rot(turn) * c * (1. - .5 * e) + rot(turn * 1.5) * l * (1. + e * 1.6);
q += vec2(sin(c.y * 6. + uTime * 2.), cos(c.x * 6. + uTime * 2.3)) * .05 * e;
gKind = 9.;
return vec3(q, sin(turn) * .3 * e);
}
絵が線画になり、ペンで殴り描きするように細かく震えます。
// 絵が線画になり、ペンで殴り描きするように細かく震える
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float e = smoothstep(0., .4, amount);
vec2 jit = vec2(sin(c.y * 11. + uTime * 17.), cos(c.x * 11. + uTime * 13.)) * .035 * e
+ vec2(sin(uTime * 29. + c.x * 5.), cos(uTime * 31. + c.y * 5.)) * .02 * e;
vec2 q = c + l * (1. + .9 * e) + jit;
gKind = 9.;
return vec3(q, 0.);
}
線画になった絵が、上から順に切れ落ちるように崩れていきます。
// 線画になった絵が、上から順に切れ落ちるように崩れていく
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float top = clamp(.5 - c.y / uSize.y, 0., 1.);
float e = smoothstep(top * .35 + ph(1.) * .12, top * .35 + .38 + ph(1.) * .12, amount);
float fall = e * e * (1.2 + ph(2.) * 1.8);
vec2 q = c + rot((ph(3.) - .5) * 3. * e) * l + vec2((ph(4.) - .5) * .5 * e, -fall);
gKind = 9.;
return vec3(q, (ph(5.) - .5) * .4 * e);
}
絵がほどけて、細い糸のように長く伸びて流れていきます。
// 絵がほどけて、細い糸のように長く伸びて流れていく
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float e = smoothstep(ph(1.) * .25, .5 + ph(1.) * .25, amount);
float ang = (ph(2.) - .5) * .9 + sin(c.y * 2. + uTime) * .2;
vec2 along = vec2(cos(ang), sin(ang)), across = vec2(-along.y, along.x);
vec2 stretched = along * dot(l, along) * (1. + e * 5.) + across * dot(l, across) * (1. - .5 * e);
vec2 q = c + stretched + along * e * (.4 + ph(3.) * 1.2) + vec2(0., sin(uTime * 1.5 + c.x * 2.) * .1 * e);
gKind = 9.;
return vec3(q, 0.);
}
色が水に滲むように粒がぼやけて広がり、まわりと混ざります。
// 色が水に滲むように、粒がぼやけて広がり、まわりの色と混ざっていく
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float e = smoothstep(ph(1.) * .2, .5 + ph(1.) * .25, amount);
vec2 flow = vec2(sin(c.y * 1.8 + uTime * .6), cos(c.x * 1.4 + uTime * .5)) * .15 * e;
vec2 q = c + l * (1. + e * 3.4) + flow;
gKind = 2.; gLocal = 1.;
gAlpha = mix(1., .5, e);
return vec3(q, -e * .15 * ph(2.));
}
絵の具が溶けて、色の筋になって下へ垂れていきます。
// 絵の具が溶けて、色の筋になって下へ垂れていく
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float e = smoothstep(ph(1.) * .3, .55 + ph(1.) * .25, amount);
float len = .3 + ph(2.) * 1.6;
vec2 q = c + vec2(l.x * (1. - .35 * e) + sin(uTime * 1.2 + ph(3.) * 6.) * .03 * e, l.y * (1. + e * 6.) - e * len);
gKind = 2.; gLocal = 1.;
gAlpha = mix(1., .7, e);
return vec3(q, 0.);
}
色が中心から外へ滲み出して、やわらかく広がっていきます。
// 色が中心から外へ滲み出して、やわらかく広がっていく
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float r = length(c);
float e = smoothstep(r * .12, .45 + r * .12 + ph(1.) * .15, amount);
vec2 dir = normalize(c + vec2(.001));
vec2 q = c + dir * e * (.5 + ph(2.) * .9) + l * (1. + e * 2.8);
gKind = 2.; gLocal = 1.;
gAlpha = mix(1., .55, e);
return vec3(q, 0.);
}
書体も紙も傾きも違う切り抜き文字を、1拍に1文字ずつゆっくり貼ります。
メソッド工房の「文字モーション」のサンプルから選びます。
// @fonts
// 切り貼り文字(ゆっくり): 1文字ごとに書体・紙の色・大きさ・傾きが違う切り抜き文字を、拍に合わせて1文字ずつ貼っていく。貼るたびに画面が拍で揺れる。
// 貼る速さは1拍の何分の1か(1)と、貼り終えるまでに使う割合(0.8)、歌詞の「動きの速さ」で変わる。api.fonts / api.fontsFor は「// @fonts」の行があると使える。
const {THREE,scene,createText,text,width,height,fontSize,duration,bpm,random,fonts,fontsFor,fontURL,color,accent,runs}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const back=x=>{x=clamp(x)-1;return 1+2.4*x*x*x+1.4*x*x};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.15),life=Math.max(.05,duration);
const SHAKE=0.8,DIV=1,FILL=0.8; // FILL: 歌詞の表示時間のうち、貼り終えるまでに使う割合の上限
// 紙と文字の色の組(切り抜きの紙、文字)
const PAPERS=[['#faf9f4','#141316'],['#ece8dc','#141316'],['#cec9bc','#141316'],['#141316','#faf9f4'],['#60b6ba','#141316'],['#e45a8c','#faf9f4'],['#f4d632','#141316'],['#faf9f4','#e45a8c']];
const luma=hex=>{const n=parseInt(String(hex).replace('#','').slice(0,6),16)||0;return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255};
const runAt=offset=>{let o=0;for(const r of runs||[]){const n=String(r.text||'').length;if(offset<o+n)return r;o+=n}return null};
// 文字を1つずつ作る(空白は間、改行は行替え)
const rows=[[]];let offset=0,visibleCount=0;
for(const ch of Array.from(String(text||'').replace(/\r/g,''))){
const len=ch.length;
if(ch==='\n'){rows.push([]);offset+=len;continue}
if(/\s/.test(ch)){rows[rows.length-1].push({gap:true,w:em*.4});offset+=len;continue}
const run=runAt(offset),cp=ch.codePointAt(0);
const sc=.84+random()*.4,fs=em*sc,adv=(cp<256?.62:1)*fs;
const choices=[...fontsFor(ch).map(k=>fonts[k]),fontURL]; // その字を持っている書体だけ
const font=choices[Math.floor(random()*choices.length)];
let paper,ink;
if(run&&run.color){paper=run.color;ink=luma(run.color)>.55?'#141316':'#faf9f4'}
else if(run&&run.emphasis){paper=accent;ink=luma(accent)>.55?'#141316':'#faf9f4'}
else{const p=PAPERS[Math.floor(random()*PAPERS.length)];paper=p[0];ink=p[1]}
rows[rows.length-1].push({ch,font,fs,w:adv+fs*.3,h:fs*1.18,paper,ink,rot:(random()-.5)*.31,dy:(random()-.5)*.2*fs,tape:random()<.18,tapeRot:(random()-.5)*1.05,jit:[random(),random(),random(),random(),random(),random(),random(),random()],slam:(random()-.5)*.7,idx:visibleCount++});
offset+=len;
}
// 行に分ける(はみ出す行は折り返し、全体が画面に収まるよう縮める)
const limit=W*.86,lines=[];
for(const row of rows){let cur=[],w=0;for(const it of row){const iw=it.w*.94;if(cur.length&&w+iw>limit&&!it.gap){lines.push(cur);cur=[];w=0}cur.push(it);w+=iw}lines.push(cur)}
const rowW=r=>r.reduce((a,it)=>a+it.w*.94,0),rowH=r=>Math.max(em*.9,...r.filter(it=>!it.gap).map(it=>it.h));
const totalH=lines.reduce((a,r)=>a+rowH(r)*1.12,0);
const k=Math.min(1,limit/Math.max(1,...lines.map(rowW)),H*.74/Math.max(1,totalH));
const root=new THREE.Group();scene.add(root);
const mats=[],geos=[];
const chars=[];
let y=totalH*k/2;
for(const r of lines){
const rh=rowH(r)*k*1.12;y-=rh/2;
let x=-rowW(r)*k/2;
for(const it of r){
const w=it.w*k*.94;
if(it.gap){x+=w;continue}
const fs=it.fs*k,tw=it.w*k,th=it.h*k,g=new THREE.Group(),j=it.jit;
const jt=n=>(j[n]-.5)*fs*.1;
const shape=new THREE.Shape([new THREE.Vector2(-tw/2+jt(0),-th/2+jt(1)),new THREE.Vector2(tw/2+jt(2),-th/2+jt(3)),new THREE.Vector2(tw/2+jt(4),th/2+jt(5)),new THREE.Vector2(-tw/2+jt(6),th/2+jt(7))]);
const geo=new THREE.ShapeGeometry(shape);geos.push(geo);
const mk=(c,o)=>{const m=new THREE.MeshBasicMaterial({color:c,transparent:true,opacity:o,depthWrite:false,depthTest:false});mats.push(m);return m};
const shadow=new THREE.Mesh(geo,mk('#000000',.36)),tile=new THREE.Mesh(geo,mk(it.paper,1));
shadow.position.set(fs*.06,-fs*.08,0);
const base=it.idx*4;shadow.renderOrder=base;tile.renderOrder=base+1;
const label=createText(it.ch,{font:it.font,fontSize:fs,color:it.ink,colorRanges:null,anchorX:'center',anchorY:'middle'});
label.renderOrder=base+2;label.material.depthTest=false;label.material.depthWrite=false;
g.add(shadow,tile,label);
let tape=null;
if(it.tape){tape=new THREE.Mesh(new THREE.PlaneGeometry(tw*.9,th*.2),mk('#eee8c8',.55));geos.push(tape.geometry);tape.position.set(0,th*.52,0);tape.rotation.z=it.tapeRot;tape.renderOrder=base+3;g.add(tape)}
g.position.set(x+w/2,y+it.dy*k,0);g.rotation.z=it.rot;g.visible=false;
root.add(g);
chars.push({g,it,label,shadow,tile,tape,x:g.position.x,y:g.position.y});
x+=w;
}
y-=rh/2;
}
const n=Math.max(1,chars.length),step=Math.max(.03,Math.min(60/Math.max(40,bpm||120)/DIV,life*FILL/n)),t0=.05,exit=Math.min(.35,life*.15);
function update(t){
const tick=Math.floor(t*12),ph=((t*Math.max(40,bpm||120)/60)%1+1)%1,punch=Math.exp(-ph*6);
const sh=em*.03*SHAKE*(.5+punch);
root.position.set((hash(tick,1)-.5)*2*sh,(hash(tick,2)-.5)*2*sh,0);
root.rotation.z=(hash(tick,3)-.5)*.012*SHAKE;
const zoom=1+.018*punch*SHAKE;root.scale.set(zoom,zoom,1);
const fade=t>=life?0:smooth((life-t)/exit);
for(const c of chars){
const ti=t0+c.it.idx*step,u=(t-ti)/.15;
if(u<=0||fade<=0){c.g.visible=false;continue}
c.g.visible=true;
const e=back(u),a=smooth(u*4)*fade;
c.g.scale.setScalar(mix(1.7,1,e));
c.g.position.set(c.x+(hash(c.it.idx,tick)-.5)*em*.035,c.y+(hash(c.it.idx,tick+7)-.5)*em*.035,0);
c.g.rotation.z=c.it.rot+(1-e)*c.it.slam+(hash(c.it.idx,tick+3)-.5)*.016;
c.label.fillOpacity=a;c.shadow.material.opacity=.36*a;c.tile.material.opacity=a;if(c.tape)c.tape.material.opacity=.55*a;
}
}
function dispose(){for(const m of mats)m.dispose();for(const g of geos)g.dispose();scene.remove(root)}
update(0);
return {update,dispose};
書体も紙も傾きも違う切り抜き文字を、拍に合わせて貼っていきます。
メソッド工房の「文字モーション」のサンプルから選びます。
// @fonts
// 切り貼り文字: 1文字ごとに書体・紙の色・大きさ・傾きが違う切り抜き文字を、拍に合わせて1文字ずつ貼っていく。貼るたびに画面が拍で揺れる。
// 貼る速さは1拍の何分の1か(2)と、貼り終えるまでに使う割合(0.6)、歌詞の「動きの速さ」で変わる。api.fonts / api.fontsFor は「// @fonts」の行があると使える。
const {THREE,scene,createText,text,width,height,fontSize,duration,bpm,random,fonts,fontsFor,fontURL,color,accent,runs}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const back=x=>{x=clamp(x)-1;return 1+2.4*x*x*x+1.4*x*x};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.15),life=Math.max(.05,duration);
const SHAKE=1,DIV=2,FILL=0.6; // FILL: 歌詞の表示時間のうち、貼り終えるまでに使う割合の上限
// 紙と文字の色の組(切り抜きの紙、文字)
const PAPERS=[['#faf9f4','#141316'],['#ece8dc','#141316'],['#cec9bc','#141316'],['#141316','#faf9f4'],['#60b6ba','#141316'],['#e45a8c','#faf9f4'],['#f4d632','#141316'],['#faf9f4','#e45a8c']];
const luma=hex=>{const n=parseInt(String(hex).replace('#','').slice(0,6),16)||0;return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255};
const runAt=offset=>{let o=0;for(const r of runs||[]){const n=String(r.text||'').length;if(offset<o+n)return r;o+=n}return null};
// 文字を1つずつ作る(空白は間、改行は行替え)
const rows=[[]];let offset=0,visibleCount=0;
for(const ch of Array.from(String(text||'').replace(/\r/g,''))){
const len=ch.length;
if(ch==='\n'){rows.push([]);offset+=len;continue}
if(/\s/.test(ch)){rows[rows.length-1].push({gap:true,w:em*.4});offset+=len;continue}
const run=runAt(offset),cp=ch.codePointAt(0);
const sc=.84+random()*.4,fs=em*sc,adv=(cp<256?.62:1)*fs;
const choices=[...fontsFor(ch).map(k=>fonts[k]),fontURL]; // その字を持っている書体だけ
const font=choices[Math.floor(random()*choices.length)];
let paper,ink;
if(run&&run.color){paper=run.color;ink=luma(run.color)>.55?'#141316':'#faf9f4'}
else if(run&&run.emphasis){paper=accent;ink=luma(accent)>.55?'#141316':'#faf9f4'}
else{const p=PAPERS[Math.floor(random()*PAPERS.length)];paper=p[0];ink=p[1]}
rows[rows.length-1].push({ch,font,fs,w:adv+fs*.3,h:fs*1.18,paper,ink,rot:(random()-.5)*.31,dy:(random()-.5)*.2*fs,tape:random()<.18,tapeRot:(random()-.5)*1.05,jit:[random(),random(),random(),random(),random(),random(),random(),random()],slam:(random()-.5)*.7,idx:visibleCount++});
offset+=len;
}
// 行に分ける(はみ出す行は折り返し、全体が画面に収まるよう縮める)
const limit=W*.86,lines=[];
for(const row of rows){let cur=[],w=0;for(const it of row){const iw=it.w*.94;if(cur.length&&w+iw>limit&&!it.gap){lines.push(cur);cur=[];w=0}cur.push(it);w+=iw}lines.push(cur)}
const rowW=r=>r.reduce((a,it)=>a+it.w*.94,0),rowH=r=>Math.max(em*.9,...r.filter(it=>!it.gap).map(it=>it.h));
const totalH=lines.reduce((a,r)=>a+rowH(r)*1.12,0);
const k=Math.min(1,limit/Math.max(1,...lines.map(rowW)),H*.74/Math.max(1,totalH));
const root=new THREE.Group();scene.add(root);
const mats=[],geos=[];
const chars=[];
let y=totalH*k/2;
for(const r of lines){
const rh=rowH(r)*k*1.12;y-=rh/2;
let x=-rowW(r)*k/2;
for(const it of r){
const w=it.w*k*.94;
if(it.gap){x+=w;continue}
const fs=it.fs*k,tw=it.w*k,th=it.h*k,g=new THREE.Group(),j=it.jit;
const jt=n=>(j[n]-.5)*fs*.1;
const shape=new THREE.Shape([new THREE.Vector2(-tw/2+jt(0),-th/2+jt(1)),new THREE.Vector2(tw/2+jt(2),-th/2+jt(3)),new THREE.Vector2(tw/2+jt(4),th/2+jt(5)),new THREE.Vector2(-tw/2+jt(6),th/2+jt(7))]);
const geo=new THREE.ShapeGeometry(shape);geos.push(geo);
const mk=(c,o)=>{const m=new THREE.MeshBasicMaterial({color:c,transparent:true,opacity:o,depthWrite:false,depthTest:false});mats.push(m);return m};
const shadow=new THREE.Mesh(geo,mk('#000000',.36)),tile=new THREE.Mesh(geo,mk(it.paper,1));
shadow.position.set(fs*.06,-fs*.08,0);
const base=it.idx*4;shadow.renderOrder=base;tile.renderOrder=base+1;
const label=createText(it.ch,{font:it.font,fontSize:fs,color:it.ink,colorRanges:null,anchorX:'center',anchorY:'middle'});
label.renderOrder=base+2;label.material.depthTest=false;label.material.depthWrite=false;
g.add(shadow,tile,label);
let tape=null;
if(it.tape){tape=new THREE.Mesh(new THREE.PlaneGeometry(tw*.9,th*.2),mk('#eee8c8',.55));geos.push(tape.geometry);tape.position.set(0,th*.52,0);tape.rotation.z=it.tapeRot;tape.renderOrder=base+3;g.add(tape)}
g.position.set(x+w/2,y+it.dy*k,0);g.rotation.z=it.rot;g.visible=false;
root.add(g);
chars.push({g,it,label,shadow,tile,tape,x:g.position.x,y:g.position.y});
x+=w;
}
y-=rh/2;
}
const n=Math.max(1,chars.length),step=Math.max(.03,Math.min(60/Math.max(40,bpm||120)/DIV,life*FILL/n)),t0=.05,exit=Math.min(.35,life*.15);
function update(t){
const tick=Math.floor(t*12),ph=((t*Math.max(40,bpm||120)/60)%1+1)%1,punch=Math.exp(-ph*6);
const sh=em*.03*SHAKE*(.5+punch);
root.position.set((hash(tick,1)-.5)*2*sh,(hash(tick,2)-.5)*2*sh,0);
root.rotation.z=(hash(tick,3)-.5)*.012*SHAKE;
const zoom=1+.018*punch*SHAKE;root.scale.set(zoom,zoom,1);
const fade=t>=life?0:smooth((life-t)/exit);
for(const c of chars){
const ti=t0+c.it.idx*step,u=(t-ti)/.15;
if(u<=0||fade<=0){c.g.visible=false;continue}
c.g.visible=true;
const e=back(u),a=smooth(u*4)*fade;
c.g.scale.setScalar(mix(1.7,1,e));
c.g.position.set(c.x+(hash(c.it.idx,tick)-.5)*em*.035,c.y+(hash(c.it.idx,tick+7)-.5)*em*.035,0);
c.g.rotation.z=c.it.rot+(1-e)*c.it.slam+(hash(c.it.idx,tick+3)-.5)*.016;
c.label.fillOpacity=a;c.shadow.material.opacity=.36*a;c.tile.material.opacity=a;if(c.tape)c.tape.material.opacity=.55*a;
}
}
function dispose(){for(const m of mats)m.dispose();for(const g of geos)g.dispose();scene.remove(root)}
update(0);
return {update,dispose};
切り抜き文字を拍の4分で素早く貼り、画面を大きく揺らします。
メソッド工房の「文字モーション」のサンプルから選びます。
// @fonts
// 切り貼り文字(連打): 1文字ごとに書体・紙の色・大きさ・傾きが違う切り抜き文字を、拍に合わせて1文字ずつ貼っていく。貼るたびに画面が拍で揺れる。
// 貼る速さは1拍の何分の1か(4)と、貼り終えるまでに使う割合(0.45)、歌詞の「動きの速さ」で変わる。api.fonts / api.fontsFor は「// @fonts」の行があると使える。
const {THREE,scene,createText,text,width,height,fontSize,duration,bpm,random,fonts,fontsFor,fontURL,color,accent,runs}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const back=x=>{x=clamp(x)-1;return 1+2.4*x*x*x+1.4*x*x};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.15),life=Math.max(.05,duration);
const SHAKE=1.7,DIV=4,FILL=0.45; // FILL: 歌詞の表示時間のうち、貼り終えるまでに使う割合の上限
// 紙と文字の色の組(切り抜きの紙、文字)
const PAPERS=[['#faf9f4','#141316'],['#ece8dc','#141316'],['#cec9bc','#141316'],['#141316','#faf9f4'],['#60b6ba','#141316'],['#e45a8c','#faf9f4'],['#f4d632','#141316'],['#faf9f4','#e45a8c']];
const luma=hex=>{const n=parseInt(String(hex).replace('#','').slice(0,6),16)||0;return (.299*(n>>16&255)+.587*(n>>8&255)+.114*(n&255))/255};
const runAt=offset=>{let o=0;for(const r of runs||[]){const n=String(r.text||'').length;if(offset<o+n)return r;o+=n}return null};
// 文字を1つずつ作る(空白は間、改行は行替え)
const rows=[[]];let offset=0,visibleCount=0;
for(const ch of Array.from(String(text||'').replace(/\r/g,''))){
const len=ch.length;
if(ch==='\n'){rows.push([]);offset+=len;continue}
if(/\s/.test(ch)){rows[rows.length-1].push({gap:true,w:em*.4});offset+=len;continue}
const run=runAt(offset),cp=ch.codePointAt(0);
const sc=.84+random()*.4,fs=em*sc,adv=(cp<256?.62:1)*fs;
const choices=[...fontsFor(ch).map(k=>fonts[k]),fontURL]; // その字を持っている書体だけ
const font=choices[Math.floor(random()*choices.length)];
let paper,ink;
if(run&&run.color){paper=run.color;ink=luma(run.color)>.55?'#141316':'#faf9f4'}
else if(run&&run.emphasis){paper=accent;ink=luma(accent)>.55?'#141316':'#faf9f4'}
else{const p=PAPERS[Math.floor(random()*PAPERS.length)];paper=p[0];ink=p[1]}
rows[rows.length-1].push({ch,font,fs,w:adv+fs*.3,h:fs*1.18,paper,ink,rot:(random()-.5)*.31,dy:(random()-.5)*.2*fs,tape:random()<.18,tapeRot:(random()-.5)*1.05,jit:[random(),random(),random(),random(),random(),random(),random(),random()],slam:(random()-.5)*.7,idx:visibleCount++});
offset+=len;
}
// 行に分ける(はみ出す行は折り返し、全体が画面に収まるよう縮める)
const limit=W*.86,lines=[];
for(const row of rows){let cur=[],w=0;for(const it of row){const iw=it.w*.94;if(cur.length&&w+iw>limit&&!it.gap){lines.push(cur);cur=[];w=0}cur.push(it);w+=iw}lines.push(cur)}
const rowW=r=>r.reduce((a,it)=>a+it.w*.94,0),rowH=r=>Math.max(em*.9,...r.filter(it=>!it.gap).map(it=>it.h));
const totalH=lines.reduce((a,r)=>a+rowH(r)*1.12,0);
const k=Math.min(1,limit/Math.max(1,...lines.map(rowW)),H*.74/Math.max(1,totalH));
const root=new THREE.Group();scene.add(root);
const mats=[],geos=[];
const chars=[];
let y=totalH*k/2;
for(const r of lines){
const rh=rowH(r)*k*1.12;y-=rh/2;
let x=-rowW(r)*k/2;
for(const it of r){
const w=it.w*k*.94;
if(it.gap){x+=w;continue}
const fs=it.fs*k,tw=it.w*k,th=it.h*k,g=new THREE.Group(),j=it.jit;
const jt=n=>(j[n]-.5)*fs*.1;
const shape=new THREE.Shape([new THREE.Vector2(-tw/2+jt(0),-th/2+jt(1)),new THREE.Vector2(tw/2+jt(2),-th/2+jt(3)),new THREE.Vector2(tw/2+jt(4),th/2+jt(5)),new THREE.Vector2(-tw/2+jt(6),th/2+jt(7))]);
const geo=new THREE.ShapeGeometry(shape);geos.push(geo);
const mk=(c,o)=>{const m=new THREE.MeshBasicMaterial({color:c,transparent:true,opacity:o,depthWrite:false,depthTest:false});mats.push(m);return m};
const shadow=new THREE.Mesh(geo,mk('#000000',.36)),tile=new THREE.Mesh(geo,mk(it.paper,1));
shadow.position.set(fs*.06,-fs*.08,0);
const base=it.idx*4;shadow.renderOrder=base;tile.renderOrder=base+1;
const label=createText(it.ch,{font:it.font,fontSize:fs,color:it.ink,colorRanges:null,anchorX:'center',anchorY:'middle'});
label.renderOrder=base+2;label.material.depthTest=false;label.material.depthWrite=false;
g.add(shadow,tile,label);
let tape=null;
if(it.tape){tape=new THREE.Mesh(new THREE.PlaneGeometry(tw*.9,th*.2),mk('#eee8c8',.55));geos.push(tape.geometry);tape.position.set(0,th*.52,0);tape.rotation.z=it.tapeRot;tape.renderOrder=base+3;g.add(tape)}
g.position.set(x+w/2,y+it.dy*k,0);g.rotation.z=it.rot;g.visible=false;
root.add(g);
chars.push({g,it,label,shadow,tile,tape,x:g.position.x,y:g.position.y});
x+=w;
}
y-=rh/2;
}
const n=Math.max(1,chars.length),step=Math.max(.03,Math.min(60/Math.max(40,bpm||120)/DIV,life*FILL/n)),t0=.05,exit=Math.min(.35,life*.15);
function update(t){
const tick=Math.floor(t*12),ph=((t*Math.max(40,bpm||120)/60)%1+1)%1,punch=Math.exp(-ph*6);
const sh=em*.03*SHAKE*(.5+punch);
root.position.set((hash(tick,1)-.5)*2*sh,(hash(tick,2)-.5)*2*sh,0);
root.rotation.z=(hash(tick,3)-.5)*.012*SHAKE;
const zoom=1+.018*punch*SHAKE;root.scale.set(zoom,zoom,1);
const fade=t>=life?0:smooth((life-t)/exit);
for(const c of chars){
const ti=t0+c.it.idx*step,u=(t-ti)/.15;
if(u<=0||fade<=0){c.g.visible=false;continue}
c.g.visible=true;
const e=back(u),a=smooth(u*4)*fade;
c.g.scale.setScalar(mix(1.7,1,e));
c.g.position.set(c.x+(hash(c.it.idx,tick)-.5)*em*.035,c.y+(hash(c.it.idx,tick+7)-.5)*em*.035,0);
c.g.rotation.z=c.it.rot+(1-e)*c.it.slam+(hash(c.it.idx,tick+3)-.5)*.016;
c.label.fillOpacity=a;c.shadow.material.opacity=.36*a;c.tile.material.opacity=a;if(c.tape)c.tape.material.opacity=.55*a;
}
}
function dispose(){for(const m of mats)m.dispose();for(const g of geos)g.dispose();scene.remove(root)}
update(0);
return {update,dispose};
市松模様が波のようにうねりながら流れる、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 市松模様の角丸の四角が、波のようにうねりながら脈打つ背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.)*9.;
p.y+=sin(p.x*.8+u_time*1.5)*.6;p.x+=cos(p.y*.7+u_time*1.1)*.4;
vec2 g=floor(p),f=fract(p)-.5;
float chk=mod(g.x+g.y,2.);
float sq=1.-smoothstep(.34,.46,max(abs(f.x),abs(f.y)));
float pulse=.55+.45*sin(u_time*2.+g.x*.6+g.y*.8);
vec3 col=mix(u_accent,vec3(1.),.4+.3*sin(g.x*.5+u_time*.8));
float a=sq*chk*pulse*.6;
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
中心から放射状の光線が、ゆっくり回りながら広がる背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 中心から放射状に伸びる光線が、ゆっくり回転する背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float ang=atan(p.y,p.x),r=length(p);
float k=ang/TAU*16.+u_time*.18;
float f=fract(k);
float ray=smoothstep(.0,.1,f)*(1.-smoothstep(.34,.44,f));
float fall=smoothstep(.04,.22,r)*(.4+.6*smoothstep(.0,.9,r));
vec3 col=mix(u_accent,vec3(1.,.95,.8),.5+.5*sin(k*.7));
float a=ray*fall*.6;
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
方眼の線が波のように歪んで揺れる、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 方眼の線が波のように歪んで揺れる背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.)*7.;
p+=vec2(sin(p.y*1.1+u_time*1.3),cos(p.x*1.0+u_time*1.1))*.45;
vec2 f=abs(fract(p)-.5);
float line=smoothstep(.40,.46,max(f.x,f.y));
float glow=.5+.5*sin(u_time*1.7+p.x*.9+p.y*.6);
vec3 col=mix(u_accent,vec3(1.),glow*.6);
float a=line*(.35+.4*glow);
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
やわらかい光の玉がゆっくり漂う、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// やわらかい光の玉が漂うボケの背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec3 acc=vec3(0.);float al=0.;
for(int i=0;i<14;i++){
float fi=float(i);
float h1=hash(vec2(fi,1.)),h2=hash(vec2(fi,2.)),h3=hash(vec2(fi,3.)),h4=hash(vec2(fi,4.));
vec2 c=vec2((h1-.5)*aspect*.95,(h2-.5)*.9);
c+=vec2(sin(u_time*(.15+h3*.25)+fi*1.3),cos(u_time*(.12+h4*.22)+fi*2.1))*.08;
float r=.05+h3*.08;
float d=length(p-c);
float disc=smoothstep(r,r*.55,d)*(.3+.3*h4);
float rim=smoothstep(r*.78,r,d)*smoothstep(r*1.08,r,d)*.4;
float k=disc+rim;
acc+=mix(pal(h1+u_time*.03),u_accent,.4)*k;al+=k;
}
vec3 col=acc/max(al,.001);
float a=min(.75,al);
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
色がうねる等高線のような線が流れる、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// うねるプラズマの等高線が流れる背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.)*3.;
float t=u_time*.6;
float v=sin(p.x*2.+t)+sin(p.y*2.3-t*1.2)+sin((p.x+p.y)*1.7+t*.7)+sin(length(p)*3.5-t*1.4);
float line=smoothstep(.40,.47,abs(fract(v*.6)-.5));
vec3 col=mix(u_accent,pal(v*.12+.2),.6);
float a=line*.65;
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
六角形のタイルが順番に光って脈打つ、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 六角形のタイルが波のように順番に光る背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.)*6.;
vec2 r=vec2(1.,1.7320508),h=r*.5;
vec2 ga=mod(p,r)-h,gb=mod(p-h,r)-h;
vec2 gv=dot(ga,ga)<dot(gb,gb)?ga:gb;
vec2 id=p-gv;
vec2 q=abs(gv);
float hd=max(dot(q,normalize(vec2(1.,1.7320508))),q.x);
float wave=sin(u_time*1.8-length(id)*1.1+hash(floor(id*4.+.5))*3.)*.5+.5;
float on=smoothstep(.55,.75,wave);
float fill=smoothstep(.5,.4,hd)*on*.55;
float rim=smoothstep(.38,.44,hd)*smoothstep(.5,.46,hd)*on*.45;
vec3 col=mix(u_accent,vec3(1.),.35+.4*wave);
float a=max(fill,rim);
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
何本もの線がくねくね絡み合って走る、落書きのような背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 何本もの線がくねくねと絡み合って走る、落書きのような背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float a=0.;vec3 col=u_accent;
for(int i=0;i<8;i++){
float fi=float(i),ph=fi*1.7;
float y=.34*sin(p.x*(2.2+fi*.55)+u_time*(.7+fi*.18)+ph)*cos(p.x*(.9+fi*.3)-u_time*.45+ph*1.3)+.1*sin(p.x*9.+u_time*1.5+ph);
float l=smoothstep(.014,.004,abs(p.y-y));
if(l>a){a=l;col=mix(u_accent,vec3(1.),mod(fi,2.)*.75);}
}
a*=.75;
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
斜めの細い筋が次々に降り注ぐ、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 斜めの細い筋が次々に降り注ぐ背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=rot(-.35)*((vUv-.5)*vec2(aspect,1.));
float cols=24.;
float c=floor(p.x*cols),h=hash(vec2(c,7.));
float y=fract(p.y*.9+u_time*(.35+h*.5)+h*10.);
float len=.15+.4*h;
float streak=step(y,len)*smoothstep(len,0.,y);
float w=smoothstep(.22,.08,abs(fract(p.x*cols)-.5));
vec3 col=mix(u_accent,vec3(1.),.4+.5*h);
float a=streak*w*.8;
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
網点の大きさが波のように変わって脈打つ背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 網点の大きさが波のように変わって脈打つ背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float n=22.;
vec2 g=p*n,id=floor(g),f=fract(g)-.5;
float wave=.5+.5*sin(u_time*1.5-length(id/n)*7.+hash(id)*.8);
float rad=.08+.26*wave;
float dotm=smoothstep(rad,rad-.07,length(f));
vec3 col=mix(u_accent,vec3(1.),.25+.4*wave);
float a=dotm*.6;
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
ジグザグの帯が横に流れていく、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// ジグザグの帯が横に流れていく背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.)*5.;
float tri=abs(fract(p.x*.8+u_time*.15)-.5)*2.;
float yy=p.y*1.2+tri*1.6-u_time*.35;
float f=fract(yy);
float band=smoothstep(.0,.08,f)*(1.-smoothstep(.3,.38,f));
vec3 col=mix(u_accent,vec3(1.),mod(floor(yy),2.)*.7);
float a=band*.6;
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
色のインクがにじんで混ざり合う、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 色のインクがにじんで混ざり合う背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.)*2.2;
float t=u_time*.25;
vec2 q=p;
for(int i=0;i<3;i++){float fi=float(i);q+=.55*vec2(vn(q*1.6+t+fi*3.1),vn(q*1.6-t*.8+fi*5.3))-.275;}
float v=vn(q*2.1+t);
vec3 col=mix(u_accent,pal(v*1.3+t),.6);
float a=smoothstep(.5,.64,v)*.55;
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
小さな星がきらめきながら横に流れていく背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 小さな星がきらめきながら横に流れていく背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash(vec2 p){return fract(sin(dot(p,vec2(127.1,311.7)))*43758.5453);}
mat2 rot(float a){return mat2(cos(a),-sin(a),sin(a),cos(a));}
float vn(vec2 p){vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash(i),hash(i+vec2(1.,0.)),f.x),mix(hash(i+vec2(0.,1.)),hash(i+vec2(1.,1.)),f.x),f.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float a=0.;
for(int L=0;L<3;L++){
float fl=float(L);
vec2 q=p*(14.+fl*10.);q.x+=u_time*(.6+fl*.5);
vec2 id=floor(q),f=fract(q)-.5;
float h=hash(id+fl*17.);
vec2 off=(vec2(hash(id+3.),hash(id+7.))-.5)*.6;
vec2 d2=f-off;
float tw=.5+.5*sin(u_time*3.+h*20.);
float star=smoothstep(.12+.05*h,0.,length(d2))*step(.55,h)*tw;
float cross=(smoothstep(.02,0.,abs(d2.x))*smoothstep(.3,0.,abs(d2.y))+smoothstep(.02,0.,abs(d2.y))*smoothstep(.3,0.,abs(d2.x)))*step(.8,h)*tw;
a=max(a,max(star,cross*.7));
}
vec3 col=mix(u_accent,vec3(1.,.97,.85),.6);
a*=.9;
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,a*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
歌詞が下へ少しずつ小さく薄くなりながら、残像のように連なって現れます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 歌詞を下へ向けて少しずつ小さく薄くしながら重ね、残像のように連なって現れる。登場は上の段から、退場は下の段から畳まれる
const {scene,createText,text,width,height,fontSize,duration}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const life=Math.max(.05,duration),enter=Math.min(.55,life*.2),exit=Math.min(.5,life*.18);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrap=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.88,n=Math.ceil(longest/limit-.02);return {at:n>1?longest/n*1.12:limit,lines:rows.length*Math.max(1,n)};})();
const wrapAt=wrap.at,lines=wrap.lines;
const K=lines>1?3:5; // 2行以上の歌詞は段数を減らして高さに収める
const SHADE=['#ffffff','#e0dbee','#bdb4d2','#9a91b4','#7b7396'];
const SY=[1,.86,.75,.66,.58],ALPHA=[1,.72,.52,.36,.24];
const copies=[];
for(let i=0;i<K;i++){
const m=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.15,anchorX:'center',anchorY:'top',outlineWidth:0,...(i?{color:SHADE[i],colorRanges:null}:{})});
m.fillOpacity=0;scene.add(m);copies.push(m);
}
let fit=1,h0=0,total=1,measured=false;
function measure(){
const info=copies[0].textRenderInfo;if(!info)return;
const b=info.blockBounds,w=Math.max(1,b[2]-b[0]);h0=Math.max(1,b[3]-b[1]);
total=h0*(SY.slice(0,K-1).reduce((a,s)=>a+s*.8,0)+SY[K-1]);
fit=Math.min(1,height*.74/total,width*.92/w);measured=true;
}
function update(t){
if(!measured)measure();
const top=total*fit/2;
let y=top;
for(let i=0;i<K;i++){
const m=copies[i];
const e=spring(clamp((t-i*.09)/enter)),q=smooth(clamp((life-t-(K-1-i)*.05)/exit));
const yNow=top+(y-top)*Math.min(1,e)*q; // 段は一番上の位置から下へ広がり、畳まれるときは上へ戻る
m.position.set(Math.sin(t*2.1+i*.8)*fontSize*.05*i*fit,yNow,-i*.1);
m.scale.set(fit*(1-.012*i),fit*SY[i],1);
m.fillOpacity=ALPHA[i]*smooth(e*3)*smooth(q*3);
y-=h0*SY[i]*.8*fit;
}
}
function dispose(){for(const m of copies){scene.remove(m);m.dispose();}}
update(0);
return {update,dispose};
文字の奥に青・橙・灰色の層が重なって、立体的に押し出されて見えます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 文字の奥へ向けて、青・橙・灰色の層を少しずつずらして重ね、押し出されたように見せる。層は登場で伸び、退場で縮む
const {scene,createText,text,width,height,fontSize,duration}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const life=Math.max(.05,duration),enter=Math.min(.8,life*.28),exit=Math.min(.5,life*.18);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrap=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.84,n=Math.ceil(longest/limit-.02);return {at:n>1?longest/n*1.12:limit,lines:rows.length*Math.max(1,n)};})();
const wrapAt=wrap.at,lines=wrap.lines;
const COLORS=['#8a8f98','#f08a4b','#35b0f0','#8a8f98','#f08a4b','#35b0f0']; // 手前から奥へ
const K=lines>1?4:6;
const make=extra=>createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.2,anchorX:'center',anchorY:'middle',outlineWidth:0,...extra});
const back=[];
for(let i=K;i>=1;i--){const m=make({color:COLORS[i-1],colorRanges:null});m.fillOpacity=0;scene.add(m);back.push({m,i});}
const front=make({color:'#f8e6d8'});front.fillOpacity=0;scene.add(front);
const DX=fontSize*.05,DY=fontSize*.045;
let fit=1,measured=false;
function measure(){
const info=front.textRenderInfo;if(!info)return;
const b=info.blockBounds,w=b[2]-b[0],h=b[3]-b[1];
fit=Math.min(1,height*.7/(h+K*DY),width*.88/(w+K*DX));measured=true;
}
function update(t){
if(!measured)measure();
const grow=spring(clamp(t/enter)),q=smooth(clamp((life-t)/exit)),tq=Math.floor(t*10);
const ox=-K*DX*fit/2,oy=K*DY*fit/2; // 層の広がりも含めて中央にそろえる
for(const {m,i} of back){
const k=grow*q*Math.min(1,Math.max(0,(t-i*.04)/.3+.2));
const jx=(hash(i,tq)-.5)*fontSize*.012,jy=(hash(i,tq+9)-.5)*fontSize*.012; // 10コマ/秒の手描きのゆれ
m.position.set(ox+(i*DX*k+jx)*fit,oy-(i*DY*k+jy)*fit,-i*.1);
m.scale.setScalar(fit);m.fillOpacity=smooth(k*2)*.95;
}
front.position.set(ox,oy,0);front.scale.setScalar(fit);
const f=smooth(t/(enter*.5))*q;front.fillOpacity=f;
}
function dispose(){for(const {m} of back){scene.remove(m);m.dispose();}scene.remove(front);front.dispose();}
update(0);
return {update,dispose};
歌詞が語ごとに上から勢いよく落ちて、衝撃で画面を揺らしながら並びます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語ごとに大きな状態から加速して落ち、着地でつぶれて、画面全体が揺れる。退場は語ごとに左右へ蹴り出される
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。語が多すぎるときはまとめ、1語しかないときは半分に割る)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
if(merged.length&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))merged[merged.length-1].s+=p.s;else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>8){const c=Math.ceil(list.length/8),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2);list=[{s:a.slice(0,h).join(''),start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+a.slice(0,h).join('').length,row:list[0].row}];}
return list;
})();
const L=[];
for(const w of wordList){
const mesh=createText(w.s,{textStart:w.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:w.row,w:em*Math.max(.3,Array.from(w.s).length),mesh,meshes:[mesh],value:w.s,start:w.start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});
}
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.3,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const nWords=vis.length,spread=Math.min(life*.25,(nWords-1)*.3); // 語の登場時刻:歌詞の長さの1/4までに均等にばらまく
vis.forEach(l=>{l.t0=nWords>1?spread*l.i/(nWords-1):0;});
function pose(l,t){
if(l.i===0){ // 着地するたびに画面全体が揺れる
let sx=0,sy=0;
for(const w of vis){const d=t-w.t0-.28;if(d>0&&d<.45){const a=Math.exp(-9*d);sx+=Math.sin(d*85+w.i)*a*em*.16;sy+=Math.cos(d*70+w.i*2)*a*em*.12;}}
root.position.set(sx,sy,0);
}
const lt=t-l.t0,k=clamp(lt/.28),bt=Math.max(0,lt-.28),q=smooth(clamp((life-t)/outro));
const grow=1+(1-k*k)*1.2,fall=(1-k*k)*H*.3/fit; // 手前の大きな状態から、加速して落ちる
const sq=k>=1?Math.exp(-9*bt)*Math.cos(30*bt):0; // 着地でつぶれて戻る
const out=1-q,side=l.i%2?1:-1; // 退場は語ごとに左右へ蹴り出される
place(l,l.x+side*out*W*.7/fit,l.y+fall-out*out*H*.3/fit,side*out*.8,grow*(1+.25*sq),grow*(1-.3*sq),smooth(lt/.05)*q);
}
update(0);
return {update,dispose};
語が縦向きに上から降りて、直角にくるりと曲がり、横向きの位置に収まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語は縦向き(90°回転)で上から降り、直角にくるりと曲がって横向きになりながら定位置へ滑り込む。交互に曲がる向きが逆
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。語が多すぎるときはまとめ、1語しかないときは半分に割る)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
if(merged.length&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))merged[merged.length-1].s+=p.s;else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>8){const c=Math.ceil(list.length/8),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2);list=[{s:a.slice(0,h).join(''),start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+a.slice(0,h).join('').length,row:list[0].row}];}
return list;
})();
const L=[];
for(const w of wordList){
const mesh=createText(w.s,{textStart:w.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:w.row,w:em*Math.max(.3,Array.from(w.s).length),mesh,meshes:[mesh],value:w.s,start:w.start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});
}
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.3,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const nWords=vis.length,spread=Math.min(life*.25,(nWords-1)*.3); // 語の登場時刻:歌詞の長さの1/4までに均等にばらまく
vis.forEach(l=>{l.t0=nWords>1?spread*l.i/(nWords-1):0;});
function pose(l,t){
const lt=t-l.t0,q=smooth(clamp((life-t)/outro)),sgn=l.i%2?1:-1;
const k1=clamp(lt/.2),k2=clamp((lt-.2)/.25); // ①縦向きに降りる ②直角に曲がって横向きに収まる
const turn=spring(k2),e1=k1*k1;
const rot=(1-turn)*(Math.PI/2)*sgn,x=l.x+(1-turn)*sgn*em*3,y=l.y+(1-e1)*H*.3/fit;
const out=1-q;
place(l,x+out*sgn*W*.6/fit,y+out*H*.2/fit,rot+out*sgn*1.2,1,1,smooth(lt/.05)*q);
}
update(0);
return {update,dispose};
語が向かい側の語と位置を入れ替える動きを、外側から順に続けて並びます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 最初は語の並びが逆。外側の2語から順に、弧を描いて互いの位置と入れ替わり、正しい順になる
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。語が多すぎるときはまとめ、1語しかないときは半分に割る)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
if(merged.length&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))merged[merged.length-1].s+=p.s;else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>8){const c=Math.ceil(list.length/8),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2);list=[{s:a.slice(0,h).join(''),start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+a.slice(0,h).join('').length,row:list[0].row}];}
return list;
})();
const L=[];
for(const w of wordList){
const mesh=createText(w.s,{textStart:w.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:w.row,w:em*Math.max(.3,Array.from(w.s).length),mesh,meshes:[mesh],value:w.s,start:w.start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});
}
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.3,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const nWords=vis.length,spread=Math.min(life*.25,(nWords-1)*.3); // 語の登場時刻:歌詞の長さの1/4までに均等にばらまく
vis.forEach(l=>{l.t0=nWords>1?spread*l.i/(nWords-1):0;});
const nW=vis.length;
vis.forEach(l=>{l.tp=nW>1?Math.min(life*.25,(nW-1)*.3)*Math.min(l.i,nW-1-l.i)/Math.max(1,Math.floor((nW-1)/2)):0;}); // 入れ替わる2語は同時に動く
function pose(l,t){
const k=smooth(clamp((t-l.tp)/.4)),q=smooth(clamp((life-t)/outro));
const mate=vis[nW-1-l.i]; // いまの位置は、向かい側の語の位置
const arc=Math.sin(Math.PI*k)*em*1.1*(l.i<nW-1-l.i?1:-1);
const x=mix(mate.x,l.x,k),y=mix(mate.y,l.y,k)+arc;
const out=1-q;
place(l,x,y-out*out*H*.25/fit,Math.sin(Math.PI*k)*.25*(l.i%2?1:-1)+out*(l.r1-.5)*1.4,1,1,smooth(t/.12)*q);
}
update(0);
return {update,dispose};
画面が1語ずつに近づくように拡大して、その語が中から現れて並びます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語ごとにカメラが寄って(拡大して)その語が中から現れ、また引く。全部の語が並んだ形に収まる
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。語が多すぎるときはまとめ、1語しかないときは半分に割る)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
if(merged.length&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))merged[merged.length-1].s+=p.s;else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>8){const c=Math.ceil(list.length/8),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2);list=[{s:a.slice(0,h).join(''),start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+a.slice(0,h).join('').length,row:list[0].row}];}
return list;
})();
const L=[];
for(const w of wordList){
const mesh=createText(w.s,{textStart:w.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:w.row,w:em*Math.max(.3,Array.from(w.s).length),mesh,meshes:[mesh],value:w.s,start:w.start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});
}
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.3,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const nWords=vis.length,spread=Math.min(life*.25,(nWords-1)*.3); // 語の登場時刻:歌詞の長さの1/4までに均等にばらまく
vis.forEach(l=>{l.t0=nWords>1?spread*l.i/(nWords-1):0;});
const focus={i:-1,b:0};
function pose(l,t){
if(l.i===0){
let z=1,fx=0,fy=0,b0=0,fi=-1;
for(const w of vis){
const d=(t-w.t0)/.4;
if(d>0&&d<1){
const b=Math.sin(Math.PI*d),zmax=Math.max(1,Math.min(3,W*.8/Math.max(em,w.w*fit),H*.5/(em*fit))); // 語が画面に収まる大きさまで
z=1+(zmax-1)*b;fx=w.x;fy=w.y;b0=b;fi=w.i;
}
}
focus.i=fi;focus.b=b0;
root.scale.setScalar(fit*z);root.position.set(-fx*fit*z*b0,-fy*fit*z*b0,0); // 近づくあいだ、その語が中央へ
}
const lt=t-l.t0,k=clamp(lt/.35),q=smooth(clamp((life-t)/outro));
const s=.35+.65*spring(k); // 近づいたとき、その語が中から現れる
const others=l.i===focus.i?1:1-smooth(focus.b*1.6); // 近づいている間、ほかの語は消える
place(l,l.x,l.y,0,s,s,smooth(lt/.1)*q*others);
}
update(0);
return {update,dispose};
語が波打つ流れに乗って右から流れ込み、減速して所定の位置にそろいます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語ごとに波形の軌道で右から流れ込み、減速して定位置へ。最後に小さく震えてそろう。退場は左へ流れ去る
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。語が多すぎるときはまとめ、1語しかないときは半分に割る)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
if(merged.length&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))merged[merged.length-1].s+=p.s;else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>8){const c=Math.ceil(list.length/8),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2);list=[{s:a.slice(0,h).join(''),start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+a.slice(0,h).join('').length,row:list[0].row}];}
return list;
})();
const L=[];
for(const w of wordList){
const mesh=createText(w.s,{textStart:w.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:w.row,w:em*Math.max(.3,Array.from(w.s).length),mesh,meshes:[mesh],value:w.s,start:w.start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});
}
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.3,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const nWords=vis.length,spread=Math.min(life*.25,(nWords-1)*.3); // 語の登場時刻:歌詞の長さの1/4までに均等にばらまく
vis.forEach(l=>{l.t0=nWords>1?spread*l.i/(nWords-1):0;});
function pose(l,t){
const lt=t-l.t0,k=clamp(lt/.5),e=1-Math.pow(1-k,3),q=smooth(clamp((life-t)/outro));
const x=l.x+(1-e)*W*.75/fit,y=l.y+Math.sin((1-e)*7+l.i*.9)*em*1.0*(1-e); // 波に乗って右から流れ込み、減速して整列
const snap=k>=1?Math.exp(-8*(lt-.5))*Math.sin((lt-.5)*40)*em*.08:0;
const out=1-q;
place(l,x-out*W*.8/fit+snap,y,Math.cos((1-e)*7+l.i*.9)*.2*(1-e),1,1,smooth(lt/.08)*q);
}
update(0);
return {update,dispose};
語が落ちた側へ画面が傾いて、シーソーのように揺れながら水平に戻ります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語が落ちるたびに、落ちた側へ全体が傾いて減衰しながら揺れる。語の位置の左右で傾く向きが決まる
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。語が多すぎるときはまとめ、1語しかないときは半分に割る)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
if(merged.length&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))merged[merged.length-1].s+=p.s;else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>8){const c=Math.ceil(list.length/8),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2);list=[{s:a.slice(0,h).join(''),start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+a.slice(0,h).join('').length,row:list[0].row}];}
return list;
})();
const L=[];
for(const w of wordList){
const mesh=createText(w.s,{textStart:w.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:w.row,w:em*Math.max(.3,Array.from(w.s).length),mesh,meshes:[mesh],value:w.s,start:w.start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});
}
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.3,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const nWords=vis.length,spread=Math.min(life*.25,(nWords-1)*.3); // 語の登場時刻:歌詞の長さの1/4までに均等にばらまく
vis.forEach(l=>{l.t0=nWords>1?spread*l.i/(nWords-1):0;});
function pose(l,t){
if(l.i===0){ // 語が落ちた側へ板が傾いて、揺れながら水平に戻る
let a=0;
for(const w of vis){const d=t-w.t0-.3;if(d>0)a+=(w.x>=0?-1:1)*Math.exp(-4.5*d)*Math.sin(15*d)*.1;}
root.rotation.z=a;
}
const lt=t-l.t0,k=clamp(lt/.3),q=smooth(clamp((life-t)/outro)),out=1-q;
place(l,l.x,l.y+(1-k*k)*H*.45/fit-out*out*H*.3/fit,out*(l.x>=0?-1:1)*.6,1,1,smooth(lt/.05)*q);
}
update(0);
return {update,dispose};
語が一文字ずつ打たれてから、全体がドンと大きく弾んで落ち着きます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 1語ずつ文字単位でタイプする(カーソルがまたたく)。打ち終わると全体が1.5倍からばねで弾んで揺れる
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。語が多すぎるときはまとめ、1語しかないときは半分に割る)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
if(merged.length&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))merged[merged.length-1].s+=p.s;else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>8){const c=Math.ceil(list.length/8),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2);list=[{s:a.slice(0,h).join(''),start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+a.slice(0,h).join('').length,row:list[0].row}];}
return list;
})();
const L=[];
for(const w of wordList){
const mesh=createText(w.s,{textStart:w.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:w.row,w:em*Math.max(.3,Array.from(w.s).length),mesh,meshes:[mesh],value:w.s,start:w.start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});
}
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.3,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const nWords=vis.length,spread=Math.min(life*.25,(nWords-1)*.3); // 語の登場時刻:歌詞の長さの1/4までに均等にばらまく
vis.forEach(l=>{l.t0=nWords>1?spread*l.i/(nWords-1):0;});
// 1語ずつ打ってから、全体がドンと拡大して弾む。打つ速さは1文字あたり約0.07秒
const nT=vis.length;let clock=0;
vis.forEach(l=>{l.chars=Array.from(l.value).length;l.t0=clock;clock+=l.chars*.07+.05;});
const tSlam=clock+.05;
const bar=new THREE.Mesh(new THREE.PlaneGeometry(1,1),new THREE.MeshBasicMaterial({color:'#ffffff',transparent:true,depthTest:false,depthWrite:false}));bar.renderOrder=5;root.add(bar);
let barDone=false;
function pose(l,t){
const q=smooth(clamp((life-t)/outro)),k=clamp((t-l.t0)/(l.chars*.07)),b=l.info&&l.info.blockBounds;
if(l.i===0){
const d=t-tSlam,s=d<0?1:1+.5*Math.exp(-7*d)*Math.cos(18*d),sh=d>0&&d<.5?Math.sin(d*90)*Math.exp(-9*d)*em*.15:0;
root.scale.setScalar(fit*s);root.position.set(sh,0,0);
const cur=vis.find(w=>t>=w.t0&&t<w.t0+w.chars*.07+.05);
bar.visible=!!cur&&t<tSlam&&Math.floor(t*8)%2===0;
if(cur&&cur.info){const bb=cur.info.blockBounds,kk=clamp((t-cur.t0)/(cur.chars*.07));bar.position.set(cur.x+bb[0]+(bb[2]-bb[0])*kk+em*.06,cur.y,2);bar.scale.set(em*.08,em*1.0,1);}
}
if(b&&b.length===4&&b.every(Number.isFinite)){
const x1=b[0]+(b[2]-b[0])*(Math.floor(k*l.chars)/l.chars); // 文字単位で右へ広げる
l.mesh.clipRect=k>=1?null:[b[0]-2,b[1]-2,x1+(k>0?1:-4),b[3]+2];
}
place(l,l.x,l.y,0,1,1,(t>=l.t0?1:0)*q);
}
update(0);
return {update,dispose};
画面いっぱいの語が1つずつカットで切り替わり、最後に全部が並んで収まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語を画面いっぱいの大きさで0.3秒ずつ切り替え(位置は少しずつ変える)、最後に定位置へ全部が並ぶ
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。語が多すぎるときはまとめ、1語しかないときは半分に割る)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
if(merged.length&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))merged[merged.length-1].s+=p.s;else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>8){const c=Math.ceil(list.length/8),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2);list=[{s:a.slice(0,h).join(''),start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+a.slice(0,h).join('').length,row:list[0].row}];}
return list;
})();
const L=[];
for(const w of wordList){
const mesh=createText(w.s,{textStart:w.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:w.row,w:em*Math.max(.3,Array.from(w.s).length),mesh,meshes:[mesh],value:w.s,start:w.start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});
}
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.3,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const nWords=vis.length,spread=Math.min(life*.25,(nWords-1)*.3); // 語の登場時刻:歌詞の長さの1/4までに均等にばらまく
vis.forEach(l=>{l.t0=nWords>1?spread*l.i/(nWords-1):0;});
// 語を画面いっぱいの大きさで1つずつカット切り替えし、最後に全部が並ぶ(途中は他の語を隠す)
const nC=vis.length,cutLen=.3,tAll=nC*cutLen;
function pose(l,t){
const q=smooth(clamp((life-t)/outro)),idx=Math.floor(t/cutLen);
const mine=idx===l.i,done=t>=tAll;
const big=Math.min(W*.82/Math.max(em,l.w),H*.5/em,5);
const pos=[[0,0],[-W*.12,H*.1],[W*.1,-H*.1],[0,H*.12],[0,-H*.12]][l.i%5];
if(!done){
const on=mine?1:0,j=((t/cutLen)%1);
place(l,pos[0]/fit,pos[1]/fit,mine?(l.i%2?-.06:.06):0,big*(1+.06*j),big*(1+.06*j),on*q);
}else{
const k=spring(clamp((t-tAll-l.i*.05)/.3));
place(l,l.x,l.y,0,.6+.4*k,.6+.4*k,smooth((t-tAll-l.i*.05)/.06)*q);
}
}
update(0);
return {update,dispose};
語が左右から行の真ん中へ飛び込んでぶつかり、弾んで定位置へ収まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語は左右交互に行の真ん中へ飛び込み、ぶつかった瞬間にふくらんで、ばねで定位置へ。退場は左右へ散る
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。語が多すぎるときはまとめ、1語しかないときは半分に割る)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
if(merged.length&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))merged[merged.length-1].s+=p.s;else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>8){const c=Math.ceil(list.length/8),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2);list=[{s:a.slice(0,h).join(''),start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+a.slice(0,h).join('').length,row:list[0].row}];}
return list;
})();
const L=[];
for(const w of wordList){
const mesh=createText(w.s,{textStart:w.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:w.row,w:em*Math.max(.3,Array.from(w.s).length),mesh,meshes:[mesh],value:w.s,start:w.start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});
}
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.3,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const nWords=vis.length,spread=Math.min(life*.25,(nWords-1)*.3); // 語の登場時刻:歌詞の長さの1/4までに均等にばらまく
vis.forEach(l=>{l.t0=nWords>1?spread*l.i/(nWords-1):0;});
function pose(l,t){
const lt=t-l.t0,q=smooth(clamp((life-t)/outro)),sgn=l.i%2?1:-1;
const k1=clamp(lt/.16),k2=clamp((lt-.16)/.34); // ①左右から行の真ん中へ飛び込む ②ぶつかって弾み、定位置へ
const e1=k1*k1,e2=spring(k2);
const x=k2>0?mix(0,l.x,e2):mix(sgn*W*.34/fit,0,e1);
const hit=k2>0&&k2<.35?1+.35*Math.sin(Math.PI*k2/.35):1; // ぶつかった瞬間にふくらむ
const out=1-q;
place(l,x+out*sgn*W*.5/fit,l.y,sgn*out*.5,hit,hit,smooth(lt/.04)*q);
}
update(0);
return {update,dispose};
縦に垂れ下がっていた語が、左端を支点に1語ずつ振れて横向きに起き上がります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 語は左端を支点に、画面の中央側へ垂れた状態(上半分は下向き、下半分は上向き)から横向きへばねで振れる(針が起きるように)。退場は垂れ下がって消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
// 歌詞を語に区切る(日本語は Intl.Segmenter。句読点は前の語につける。語が多すぎるときはまとめ、1語しかないときは半分に割る)
const wordList=(()=>{
const seg=(typeof Intl!=='undefined'&&Intl.Segmenter)?new Intl.Segmenter('ja',{granularity:'word'}):null;
const out=[];let off=0;
String(text||'').replace(/\r/g,'').split('\n').forEach((line,row)=>{
const parts=[];
if(seg){for(const p of seg.segment(line)){if(p.segment.trim())parts.push({s:p.segment,start:p.index});}}
else{for(let i=0;i<line.length;i+=2)parts.push({s:line.slice(i,i+2),start:i});}
const merged=[];
for(const p of parts){
if(merged.length&&/^[、。,.!?!?…「」『』()()・~〜ー]+$/.test(p.s))merged[merged.length-1].s+=p.s;else merged.push({...p});
}
for(const m of merged)out.push({s:m.s,start:off+m.start,row});
off+=line.length+1;
});
let list=out;
if(list.length>8){const c=Math.ceil(list.length/8),m=[];for(let i=0;i<list.length;i+=c){const g=list.slice(i,i+c);m.push({s:g.map(x=>x.s).join(''),start:g[0].start,row:g[0].row});}list=m;}
if(list.length===1&&Array.from(list[0].s).length>=2){const a=Array.from(list[0].s),h=Math.ceil(a.length/2);list=[{s:a.slice(0,h).join(''),start:list[0].start,row:list[0].row},{s:a.slice(h).join(''),start:list[0].start+a.slice(0,h).join('').length,row:list[0].row}];}
return list;
})();
const L=[];
for(const w of wordList){
const mesh=createText(w.s,{textStart:w.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:w.row,w:em*Math.max(.3,Array.from(w.s).length),mesh,meshes:[mesh],value:w.s,start:w.start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});
}
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.3,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const nWords=vis.length,spread=Math.min(life*.25,(nWords-1)*.3); // 語の登場時刻:歌詞の長さの1/4までに均等にばらまく
vis.forEach(l=>{l.t0=nWords>1?spread*l.i/(nWords-1):0;});
// 縦に垂れ下がっていた語が、左端を支点に1語ずつ振れて横向きになる(時計の針が起きるように)
function pose(l,t){
const lt=t-l.t0,q=smooth(clamp((life-t)/outro)),k=spring(clamp(lt/.45));
const px=l.x-l.w/2,py=l.y; // 左端が支点
const a=-(l.y>=0?1:-1)*(1-k)*Math.PI/2; // 上半分の語は下向き(-90°)、下半分の語は上向き(+90°)に垂れて、横向き(0°)へ
const half=l.w/2,x=px+Math.cos(a)*half,y=py+Math.sin(a)*half;
const out=1-q;
place(l,x,y-out*out*H*.2/fit,a+out*.6,1,1,smooth(lt/.06)*q);
}
update(0);
return {update,dispose};
文字が一つずつキーキャップに乗って、カチッと押されて現れます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 文字を1つずつキーキャップに乗せ、先頭から順に押し込んでから文字を出す。強調した言葉のキーはアクセント色。退場は全部のキーが同時に押されて沈む
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0])+PAD;}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),hardN=Math.max(1,hard.filter(Boolean).length);let n=1,bestScale=0;for(let k=1;k<=8;k++){const sc=Math.min(1,W*.84/Math.min(total,total/k*1.12+gap),H*.66/(hardN*k*KH*1.12));if(sc>bestScale+.001){bestScale=sc;n=k;}}const cap=n>1?total/n*1.12+gap:limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=KH*1.12;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of vis){if(l.key){root.remove(l.key);l.key.geometry.dispose();l.keyMat.dispose();}}for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const PAD=em*.5,KH=em*1.45;
const KEYVERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const KEYFRAG=`precision highp float;
uniform vec3 u_color; // keycap colour
uniform vec3 u_glowColor; // light leaking from under the key
uniform float u_press; // 0 = up, 1 = pressed down
uniform float u_alpha;
uniform float u_glow;
uniform vec2 u_half; // half size of the key body in px
uniform vec2 u_size; // size of this quad in px (body + room for shadow and glow)
varying vec2 vUv;
float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}
vec4 over(vec4 dst,vec3 c,float a){return vec4(c*a+dst.rgb*(1.-a),a+dst.a*(1.-a));}
void main(){
vec2 p=(vUv-.5)*u_size;
float h=u_half.y,r=h*.3;
float lift=h*.2*(1.-u_press);
vec4 acc=vec4(0.);
vec2 room=u_size*.5-abs(p);
float win=smoothstep(0.,h*.7,min(room.x,room.y)); // shadow and glow reach 0 before the edge of the quad
float ss=sdRB(p-vec2(0.,-h*.12),u_half,r);
acc=over(acc,vec3(0.),.5*win*(1.-smoothstep(-h*.1,h*.5,ss))); // soft shadow
float sb=sdRB(p,u_half,r);
acc=over(acc,u_glowColor,u_glow*win*exp(-max(sb,0.)/(h*.45))*.6); // underglow
acc=over(acc,u_color*.45,1.-smoothstep(-1.,1.,sb)); // side wall
vec2 tp=p-vec2(0.,lift);
vec2 th=u_half-vec2(h*.16);
float st=sdRB(tp,th,r*.8);
float ty=smoothstep(-th.y,th.y,tp.y);
vec3 top=u_color*(.85+.35*ty); // lighter toward the top edge
top=mix(top,vec3(1.),.18*(1.-smoothstep(0.,h*.09,-st))); // bright rim
top*=1.-.18*u_press;
acc=over(acc,top,1.-smoothstep(-1.,1.,st));
gl_FragColor=vec4(acc.rgb/max(acc.a,1e-4),acc.a*u_alpha);
}`;
const base=new THREE.Color('#2a2f3a'),hot=new THREE.Color(api.accent);
function emphasized(start){let at=0;for(const r of api.runs||[]){const n=String(r.text||'').length;if(start<at+n)return !!r.emphasis;at+=n;}return false;}
for(const l of vis){
const color=emphasized(l.start)?base.clone().multiplyScalar(.5).add(hot.clone().multiplyScalar(.55)):base.clone();
l.keyMat=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:KEYVERT,fragmentShader:KEYFRAG,
uniforms:{u_color:{value:color},u_glowColor:{value:hot.clone()},u_press:{value:0},u_alpha:{value:0},u_glow:{value:0},u_half:{value:new THREE.Vector2(1,1)},u_size:{value:new THREE.Vector2(1,1)}}});
l.key=new THREE.Mesh(new THREE.PlaneGeometry(1,1),l.keyMat);l.key.renderOrder=-1;l.key.visible=false;root.add(l.key);
}
function pose(l,t,u,v){
const tp=.18+l.o*intro*.6,e=spring(clamp((t-(tp-.2))/.2)),x=clamp((t-tp)/.2);
const dip=t>=tp?(x<.35?smooth(x/.35):1-smooth((x-.35)/.65)):0;
const q=smooth(clamp((life-t)/outro)),exitDip=smooth(clamp((t-(life-outro))/(outro*.4)));
const press=Math.max(dip,exitDip),a=smooth(clamp((t-(tp-.2))/.1))*smooth(q*2),s=Math.max(.01,(.55+.45*e)*(.75+.25*q));
const kw=Math.max(KH*.9,l.w-PAD+PAD*.8),kh=KH,pad=kh*.6*s,y=l.y+(1-e)*em*.5-(1-q)*em*.4;
l.key.visible=a>.002;l.key.position.set(l.x,y,-1);l.key.scale.set(kw*s+2*pad,kh*s+2*pad,1);
const m=l.keyMat.uniforms;m.u_half.value.set(kw*s/2,kh*s/2);m.u_size.value.set(kw*s+2*pad,kh*s+2*pad);
m.u_press.value=press;m.u_alpha.value=a;m.u_glow.value=.5*Math.max(0,Math.sin(Math.PI*clamp((t-tp)/.5)))*(t>=tp?1:0);
const lift=kh*s*.5*.2*(1-press);
place(l,l.x,y+lift,0,(1-.05*press)*s,(1-.05*press)*s,smooth((t-tp)/.08)*smooth(q*2));
}
update(0);
return {update,dispose};
文字を乗せた大きなキーが、カチッと沈んで光を漏らします。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 歌詞全体を1つの大きなキー(スペースキーのようなもの)に乗せ、沈み込んでから戻る。キーの下からアクセント色の光が漏れて脈打つ
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const life=Math.max(.05,duration),enter=Math.min(.9,life*.3),exit=Math.min(.55,life*.2),press0=Math.min(.55,life*.18);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.7,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const KEYVERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const KEYFRAG=`precision highp float;
uniform vec3 u_color; // keycap colour
uniform vec3 u_glowColor; // light leaking from under the key
uniform float u_press; // 0 = up, 1 = pressed down
uniform float u_alpha;
uniform float u_glow;
uniform vec2 u_half; // half size of the key body in px
uniform vec2 u_size; // size of this quad in px (body + room for shadow and glow)
varying vec2 vUv;
float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}
vec4 over(vec4 dst,vec3 c,float a){return vec4(c*a+dst.rgb*(1.-a),a+dst.a*(1.-a));}
void main(){
vec2 p=(vUv-.5)*u_size;
float h=u_half.y,r=h*.3;
float lift=h*.2*(1.-u_press);
vec4 acc=vec4(0.);
vec2 room=u_size*.5-abs(p);
float win=smoothstep(0.,h*.7,min(room.x,room.y)); // shadow and glow reach 0 before the edge of the quad
float ss=sdRB(p-vec2(0.,-h*.12),u_half,r);
acc=over(acc,vec3(0.),.5*win*(1.-smoothstep(-h*.1,h*.5,ss))); // soft shadow
float sb=sdRB(p,u_half,r);
acc=over(acc,u_glowColor,u_glow*win*exp(-max(sb,0.)/(h*.45))*.6); // underglow
acc=over(acc,u_color*.45,1.-smoothstep(-1.,1.,sb)); // side wall
vec2 tp=p-vec2(0.,lift);
vec2 th=u_half-vec2(h*.16);
float st=sdRB(tp,th,r*.8);
float ty=smoothstep(-th.y,th.y,tp.y);
vec3 top=u_color*(.85+.35*ty); // lighter toward the top edge
top=mix(top,vec3(1.),.18*(1.-smoothstep(0.,h*.09,-st))); // bright rim
top*=1.-.18*u_press;
acc=over(acc,top,1.-smoothstep(-1.,1.,st));
gl_FragColor=vec4(acc.rgb/max(acc.a,1e-4),acc.a*u_alpha);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:KEYVERT,fragmentShader:KEYFRAG,
uniforms:{u_color:{value:new THREE.Color('#2a2f3a')},u_glowColor:{value:new THREE.Color(accent)},u_press:{value:0},u_alpha:{value:0},u_glow:{value:0},u_half:{value:new THREE.Vector2(1,1)},u_size:{value:new THREE.Vector2(1,1)}}});
const key=new THREE.Mesh(new THREE.PlaneGeometry(1,1),material);key.renderOrder=-1;key.position.z=-1;scene.add(key);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
let kw=width*.6,kh=height*.28,scale=1,fitted=false;
function measure(){
const info=label.textRenderInfo;if(!info)return;
const b=info.blockBounds,w=Math.max(1,b[2]-b[0]),h=Math.max(1,b[3]-b[1]);
scale=Math.min(1,(width*.86-fontSize*1.3)/w,(height*.6-fontSize)/h);
kw=w*scale+fontSize*1.3;kh=h*scale+fontSize;label.scale.setScalar(scale);fitted=true;
}
function update(t){
if(!fitted)measure();
const e=spring(clamp(t/enter)),q=smooth(clamp((life-t)/exit));
const x=clamp((t-press0)/.22),dip=x<.35?smooth(x/.35):1-smooth((x-.35)/.65);
const exitDip=smooth(clamp((t-(life-exit))/(exit*.4))),press=Math.max(dip,exitDip);
const a=smooth(t/(enter*.5))*smooth(q*2),s=Math.max(.01,(.7+.3*e)*(.8+.2*q)),pad=kh*.75*s;
const y=(1-e)*height*.18-(1-q)*height*.1;
key.position.set(0,y,-1);key.scale.set(kw*s+2*pad,kh*s+2*pad,1);
const m=material.uniforms;m.u_half.value.set(kw*s/2,kh*s/2);m.u_size.value.set(kw*s+2*pad,kh*s+2*pad);
m.u_press.value=press;m.u_alpha.value=a;m.u_glow.value=a*(.55+.35*Math.sin(t*3.2)+.5*press);
label.position.set(0,y+kh*s*.5*.2*(1-press),0);
label.scale.setScalar(scale*s*(1-.04*press));
const f=smooth((t-press0*.4)/.15)*smooth(q*2);label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){key.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
虹色に光るキーボードの上で、キーがぱちぱち打たれて光ります。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 光るキーボードの上で、キーがぱちぱち打たれて虹色に光る。キーは一つずつ別の拍で押し込まれる(時間とマスの番号だけで決まる)
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash21(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}
float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec2 plate=vec2(.66,.27);
vec2 q=p/plate*.5+.5; // 0..1 across the keyboard
float row=floor(q.y*4.);
float shift=mod(row*.37,1.); // each row is shifted, like a real keyboard
vec2 g=vec2(q.x*11.+shift,q.y*4.);
vec2 id=floor(g),f=fract(g)-.5;
float h=hash21(id+row*7.3);
float cyc=u_time*1.5+h*10.,ev=floor(cyc),within=fract(cyc);
float struck=step(.8,hash21(id+ev*1.37)); // about one key in five is struck each beat
float press=struck*(within<.2?smoothstep(0.,.1,within):1.-smoothstep(.2,.65,within));
vec3 rgb=pal(q.x*.9+q.y*.25-u_time*.25);
float sd=sdRB(f-vec2(0.,-.04*press),vec2(.40),.12);
float keyMask=1.-smoothstep(-.025,.025,sd);
vec3 top=mix(vec3(.09,.10,.13),rgb*.9,.12+.85*press);
float rim=1.-smoothstep(0.,.09,-sd);
top+=rgb*rim*(.55+.6*press);
float plateMask=1.-smoothstep(-.012,.012,sdRB(p,plate,.045));
vec3 under=mix(vec3(.03,.04,.06),rgb*.35,1.-keyMask); // light leaks between the keys
vec3 col=mix(under,top,keyMask);
gl_FragColor=vec4(mix(col,u_accent,.04),plateMask*(.55+.4*keyMask)*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'5%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.4/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
文字が白と色つきの箱に一文字ずつ収まって、ぽんぽんと並んで現れます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 文字を1つずつ、白と強調色の箱に入れる。箱は少しずつ傾き、上下にずれて、ばねで弾みながら並ぶ。退場は箱がしぼんで消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0])+PAD;}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),hardN=Math.max(1,hard.filter(Boolean).length);let n=1,bestScale=0;for(let k=1;k<=8;k++){const sc=Math.min(1,W*.84/Math.min(total,total/k*1.12+gap),H*.66/(hardN*k*KH*1.12));if(sc>bestScale+.001){bestScale=sc;n=k;}}const cap=n>1?total/n*1.12+gap:limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=KH*1.12;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of vis){if(l.key){root.remove(l.key);l.key.geometry.dispose();l.keyMat.dispose();}}for(const m of extra){root.remove(m);m.geometry.dispose();}for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const PAD=em*.3,KH=em*1.36,extra=[];
const WHITE=new THREE.Color('#ffffff'),HOT=new THREE.Color(api.accent);
for(const l of vis){
l.mesh.colorRanges=null;l.mesh.color='#15171c';l.mesh.outlineWidth=0; // 箱の上の文字は濃い色
l.keyMat=new THREE.MeshBasicMaterial({color:(l.i%2)?HOT:WHITE,transparent:true,depthTest:false,depthWrite:false}); // 白と強調色を交互に
l.key=new THREE.Mesh(new THREE.PlaneGeometry(1,1),l.keyMat);l.key.renderOrder=-1;l.key.visible=false;root.add(l.key);
}
function pose(l,t,u,v){
const tp=.1+l.o*intro*.6,e=spring(clamp((t-tp)/.34)),q=smooth(clamp((life-t-(1-l.o)*.08)/outro));
const tilt=(hash(l.i,3)-.5)*.08*(1-.5*(1-e)),rise=(hash(l.i,5)-.5)*em*.14; // 箱ごとに少し傾き、少し上下にずれる
const s=Math.max(.01,e)*(.8+.2*q),a=smooth(clamp((t-tp)/.08))*smooth(q*2);
const kw=Math.max(KH*.72,l.w-PAD+PAD*.9);
l.key.visible=a>.002;l.key.position.set(l.x,l.y+rise,-1);l.key.rotation.z=tilt;
l.key.scale.set(kw*s,KH*s*(.35+.65*Math.min(1,e)),1);l.keyMat.opacity=a;
place(l,l.x,l.y+rise,tilt,s,s,a*smooth((t-tp-.06)/.1));
}
update(0);
return {update,dispose};
中央の円に歌詞が出て、そのまわりを同じ歌詞が輪になってぐるぐる回ります。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 中央の円に歌詞を出し、そのまわりを同じ歌詞が輪になってぐるぐる回る。輪は外側へ向けて文字の上が向く
const {THREE,scene,createText,text,width,height,fontSize,duration}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const life=Math.max(.05,duration),enter=Math.min(.8,life*.28),exit=Math.min(.5,life*.18);
const R=height*.335,g0=height*.125,circ=2*Math.PI*R;
// 輪に並べる文字:歌詞に「・」を挟んで、輪がちょうど埋まる回数だけ繰り返す(長い歌詞は1周で、字を小さくする)
const chars=Array.from(String(text||'')).filter(c=>c.trim());
const unit=chars.concat(['・']),slots=Math.max(8,Math.floor(circ/(g0*1.02)));
let ring=[];
if(unit.length<=slots){const r=Math.max(1,Math.floor(slots/unit.length));for(let k=0;k<r;k++)ring.push(...unit);}else ring=chars.slice();
const n=ring.length,g=Math.min(g0,circ/(n*1.04));
const group=new THREE.Group();scene.add(group);
const flat=(geometry,color,opacity,z)=>{const m=new THREE.Mesh(geometry,new THREE.MeshBasicMaterial({color,transparent:true,opacity,depthTest:false,depthWrite:false}));m.position.z=z;m.renderOrder=-2;group.add(m);return m;};
const rd=R-g*.95,outer=R+g*.85;
const band=flat(new THREE.RingGeometry(rd,outer,96),'#0b0b10',.86,-3);
const lineA=flat(new THREE.RingGeometry(outer+3,outer+4.2,96),'#ffffff',.55,-3);
const lineB=flat(new THREE.RingGeometry(rd-4.2,rd-3,96),'#ffffff',.55,-3);
const disc=flat(new THREE.CircleGeometry(rd-6,64),'#f7f5f0',1,-2);
const letters=ring.map(c=>{const m=createText(c,{fontSize:g,anchorX:'center',anchorY:'middle',color:'#f4f4f6',colorRanges:null,outlineWidth:0});m.fillOpacity=0;group.add(m);return m;});
// 中央の円の中の歌詞(円の幅に収まるよう折り返す)
const fs=fontSize*.7,innerW=rd*1.7;
const longest=Math.max(1,...String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fs));
const rows=Math.ceil(longest/(rd*2.1)-.02),wrapIn=rows>1?longest/rows*1.12:rd*2.1; // 円の直径ぶんまでは1行、それを超えたら行の長さをそろえて折り返す
const label=createText(text,{fontSize:fs,overflowWrap:'break-word',maxWidth:wrapIn,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',color:'#1a1b20',colorRanges:null,outlineWidth:0});
label.fillOpacity=0;label.position.z=1;group.add(label);
const parts=[[band,.86],[lineA,.55],[lineB,.55],[disc,1]];
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,rd*1.6/Math.max(1,b[2]-b[0]),rd*1.3/Math.max(1,b[3]-b[1])));fitted=true;}
const e=spring(clamp(t/enter)),q=smooth(clamp((life-t)/exit)),s=Math.max(.01,e*(.6+.4*q));
group.scale.setScalar(s);group.rotation.z=(1-smooth(t/enter))*-.6;
const spin=t*.4,fade=smooth(t/(enter*.6))*smooth(q*2);
for(const [m,base] of parts)m.material.opacity=base*fade;
letters.forEach((m,i)=>{
const a=Math.PI/2-spin-Math.PI*2*i/n; // 時計回りに並べる(歌詞の順に読める)
m.position.set(Math.cos(a)*R,Math.sin(a)*R,0);m.rotation.z=a-Math.PI/2;
m.fillOpacity=smooth((t-i*.015)/.2)*smooth(q*2);
});
label.fillOpacity=smooth((t-.15)/.25)*smooth(q*2);
}
function dispose(){for(const m of letters){group.remove(m);m.dispose();}label.dispose();for(const [m] of parts){m.geometry.dispose();m.material.dispose();}scene.remove(group);}
update(0);
return {update,dispose};
文字が一枚ずつカードに載って落ちてきて、中心線を引いてテープで留めます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 文字を1枚ずつカードに載せて落とし、中心の線(両端に矢印)を引いてから、角をテープで留める。退場はテープが外れてカードが沈む
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0])+PAD;}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),hardN=Math.max(1,hard.filter(Boolean).length);let n=1,bestScale=0;for(let k=1;k<=8;k++){const sc=Math.min(1,W*.84/Math.min(total,total/k*1.12+gap),H*.66/(hardN*k*KH*1.12));if(sc>bestScale+.001){bestScale=sc;n=k;}}const cap=n>1?total/n*1.12+gap:limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=KH*1.12;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of vis){if(l.key){root.remove(l.key);l.key.geometry.dispose();l.keyMat.dispose();}}for(const m of extra){root.remove(m);m.geometry.dispose();}for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const PAD=em*.45,KH=em*1.55,extra=[];
const CARDVERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const CARDFRAG=`precision highp float;
uniform vec2 u_half; // カードの半分の大きさ(px)
uniform vec2 u_size; // この板の大きさ(px)
uniform float u_alpha;
varying vec2 vUv;
float sdRB(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}
void main(){
vec2 p=(vUv-.5)*u_size;
float d=sdRB(p,u_half,min(u_half.x,u_half.y)*.16);
float fill=1.-smoothstep(-1.,1.,d);
float edge=1.-smoothstep(0.,1.4,abs(d+2.)); // 縁の線
vec3 col=mix(vec3(.965,.95,.92),vec3(.09,.09,.11),edge);
gl_FragColor=vec4(col,fill*u_alpha);
}`;
for(const l of vis){
l.mesh.colorRanges=null;l.mesh.color='#16171b';l.mesh.outlineWidth=0;
l.keyMat=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:CARDVERT,fragmentShader:CARDFRAG,
uniforms:{u_half:{value:new THREE.Vector2(1,1)},u_size:{value:new THREE.Vector2(1,1)},u_alpha:{value:0}}});
l.key=new THREE.Mesh(new THREE.PlaneGeometry(1,1),l.keyMat);l.key.renderOrder=-1;l.key.visible=false;root.add(l.key);
}
// 中心線(両端に矢印)と、角を留める2枚のテープ。並びが決まってから作る
const inkMat=new THREE.MeshBasicMaterial({color:'#16171b',transparent:true,depthTest:false,depthWrite:false});
const tapeMat=new THREE.MeshBasicMaterial({color:'#8c8c91',transparent:true,opacity:.86,depthTest:false,depthWrite:false});
const arrowShape=new THREE.Shape();arrowShape.moveTo(0,0);arrowShape.lineTo(-1,.5);arrowShape.lineTo(-1,-.5);arrowShape.closePath();
let rows=[],decorBuilt=false,rulers=[],arrows=[],tapes=[];
function addExtra(mesh){mesh.renderOrder=-1;mesh.visible=false;root.add(mesh);extra.push(mesh);return mesh;}
function onLaid(){
const byY=new Map();
for(const l of vis){const k=Math.round(l.y*10);const r=byY.get(k)||{y:l.y,min:Infinity,max:-Infinity};r.min=Math.min(r.min,l.x-Math.max(KH*.9,l.w)/2);r.max=Math.max(r.max,l.x+Math.max(KH*.9,l.w)/2);byY.set(k,r);}
rows=[...byY.values()].sort((a,b)=>b.y-a.y);
while(rulers.length<rows.length){ // 行の数が変わっても足りるように、必要なぶんだけ作る
rulers.push(addExtra(new THREE.Mesh(new THREE.PlaneGeometry(1,Math.max(1.6,em*.045)),inkMat)));
const a=addExtra(new THREE.Mesh(new THREE.ShapeGeometry(arrowShape),inkMat)),b=addExtra(new THREE.Mesh(new THREE.ShapeGeometry(arrowShape),inkMat));
a.scale.setScalar(em*.28);b.scale.set(-em*.28,em*.28,1);arrows.push([a,b]); // 左は右向き、右は左向き
}
if(!decorBuilt){decorBuilt=true;for(let k=0;k<2;k++)tapes.push(addExtra(new THREE.Mesh(new THREE.PlaneGeometry(em*1.5,em*.5),tapeMat)));}
}
function decor(t){
if(!decorBuilt||!rows.length)return;
const lp=smooth(clamp((t-intro*.5)/.4)),tp=spring(clamp((t-intro*.8)/.3)),q=smooth(clamp((life-t)/outro));
for(let i=rows.length;i<rulers.length;i++){rulers[i].visible=false;arrows[i][0].visible=arrows[i][1].visible=false;}
rows.forEach((r,i)=>{
const w=(r.max-r.min)+em*1.4,mid=(r.max+r.min)/2,k=lp*q;
rulers[i].visible=k>.01;rulers[i].position.set(mid,r.y,-.5);rulers[i].scale.set(Math.max(.001,w*k),1,1);
arrows[i][0].visible=arrows[i][1].visible=k>.05;
arrows[i][0].position.set(mid-w*k/2,r.y,-.5);arrows[i][1].position.set(mid+w*k/2,r.y,-.5);
});
const top=rows[0],bottom=rows[rows.length-1],s=Math.max(.01,tp)*q;
const spots=[[top.max+em*.15,top.y+KH*.5,-.6],[bottom.min-em*.15,bottom.y-KH*.5,-.6]];
tapes.forEach((m,i)=>{m.visible=s>.01;m.position.set(spots[i][0],spots[i][1],-.4);m.rotation.z=i?-.62:-.62;m.scale.set(s*(1+.3*(1-Math.min(1,tp))),s,1);});
inkMat.opacity=q;tapeMat.opacity=.86*q;
}
function pose(l,t,u,v){
if(l.i===0)decor(t);
const tp=.1+l.o*intro*.55,e=spring(clamp((t-tp)/.36)),q=smooth(clamp((life-t-(1-l.o)*.08)/outro));
const rot=(hash(l.i,7)-.5)*.12+(1-Math.min(1,e))*(hash(l.i,2)>.5?.35:-.35);
const drop=(1-Math.min(1,e))*em*1.1+(1-q)*em*.5,a=smooth(clamp((t-tp)/.07))*smooth(q*2);
const kw=Math.max(KH*.9,l.w-PAD+PAD*.9),s=Math.max(.01,.85+.15*e)*(.85+.15*q);
l.key.visible=a>.002;l.key.position.set(l.x,l.y+drop,-1);l.key.rotation.z=rot;l.key.scale.set(kw*s+4,KH*s+4,1);
const m=l.keyMat.uniforms;m.u_half.value.set(kw*s/2,KH*s/2);m.u_size.value.set(kw*s+4,KH*s+4);m.u_alpha.value=a;
place(l,l.x,l.y+drop,rot,s,s,a*smooth((t-tp-.04)/.08));
}
update(0);
return {update,dispose};
歌詞が【 】で囲まれ、横に切れてずれながら、色違いの影とローマ字の読みを添えます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 歌詞を【 】のカッコで囲み、横に切れてずれるグリッチと色違いの影を、登場と一定の間隔で走らせる。かなの上には読みのローマ字を添える
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const life=Math.max(.05,duration),enter=Math.min(.7,life*.25),exit=Math.min(.5,life*.18);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrap=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.8,n=Math.ceil(longest/limit-.02);return {at:n>1?longest/n*1.12:limit,lines:rows.length*Math.max(1,n)};})();
const wrapAt=wrap.at,lines=wrap.lines;
// かな→ローマ字(ヘボン式)。漢字・っ・ー・小さい文字は読みを付けない(拗音は直前の文字にまとめる)
const KANA='あA いI うU えE おO かKA きKI くKU けKE こKO さSA しSHI すSU せSE そSO たTA ちCHI つTSU てTE とTO なNA にNI ぬNU ねNE のNO はHA ひHI ふFU へHE ほHO まMA みMI むMU めME もMO やYA ゆYU よYO らRA りRI るRU れRE ろRO わWA をWO んN がGA ぎGI ぐGU げGE ごGO ざZA じJI ずZU ぜZE ぞZO だDA ぢJI づZU でDE どDO ばBA びBI ぶBU べBE ぼBO ぱPA ぴPI ぷPU ぺPE ぽPO ゔVU';
const ROM={};for(const p of KANA.split(' '))ROM[p[0]]=p.slice(1);
const SMALL={'ゃ':'A','ゅ':'U','ょ':'O'};
const toHira=c=>{const k=c.codePointAt(0);return k>=0x30A1&&k<=0x30F6?String.fromCodePoint(k-0x60):c};
function romajiList(chars){
return chars.map((raw,i)=>{
const c=toHira(raw),next=chars[i+1]?toHira(chars[i+1]):'';
let r=ROM[c];if(!r)return '';
if(SMALL[next])r=(r==='SHI'?'SH':r==='CHI'?'CH':r==='JI'?'J':r.slice(0,-1)+'Y')+SMALL[next];
return r;
});
}
const BANDS=4,group=new THREE.Group();scene.add(group);
const make=extra=>createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.3,anchorX:'center',anchorY:'middle',outlineWidth:0,...extra});
const shadow=make({color:'#ff8a3d',colorRanges:null});shadow.fillOpacity=0;group.add(shadow); // 色違いの影
const bands=[];for(let k=0;k<BANDS;k++){const m=make({});m.fillOpacity=0;group.add(m);bands.push(m);} // 横に切った帯
const white=new THREE.MeshBasicMaterial({color:'#ffffff',transparent:true,depthTest:false,depthWrite:false});
const orange=new THREE.MeshBasicMaterial({color:'#ff8a3d',transparent:true,depthTest:false,depthWrite:false});
// 縦長のカッコ(外側へふくらむ)。left なら「(」の形
function bracket(h,bulge,thick,left){
const s=new THREE.Shape(),n=20,d=left?-1:1,outer=u=>d*bulge*(1-u*u);
for(let i=0;i<=n;i++){const u=1-2*i/n;i?s.lineTo(outer(u),u*h/2):s.moveTo(outer(u),u*h/2);}
for(let i=n;i>=0;i--){const u=1-2*i/n;s.lineTo(outer(u)-d*thick,u*h/2);}
return new THREE.ShapeGeometry(s);
}
const parts=[]; // {mesh, side}
const ruby=[];
let fit=1,built=false,bw=1,bh=1;
function build(){
const info=bands[0].textRenderInfo;if(!info)return;
const b=info.blockBounds;bw=b[2]-b[0];bh=b[3]-b[1];
bands.forEach((m,k)=>{m.clipRect=[b[0]-4,b[3]-bh*(k+1)/BANDS-(k<BANDS-1?.4:4),b[2]+4,b[3]-bh*k/BANDS+(k?.4:4)];});
const h=bh+fontSize*.55,bulge=fontSize*.24,thick=fontSize*.24,gap=fontSize*.4;
for(const left of [true,false]){
const side=left?-1:1,g=bracket(h,bulge,thick,left);
const w=new THREE.Mesh(g,white),o=new THREE.Mesh(g,orange);
w.renderOrder=-1;o.renderOrder=-2;
const x=side*(bw/2+gap+bulge/2);
group.add(o,w);parts.push({w,o,x,side});
}
const chars=Array.from(String(text||'')),carets=info.caretPositions,reads=romajiList(chars);
if(carets&&carets.length===String(text||'').length*4&&chars.length===String(text||'').length){
let order=0;
chars.forEach((c,i)=>{
if(!reads[i])return;
const m=createText(reads[i],{fontSize:fontSize*.28,letterSpacing:.12,anchorX:'center',anchorY:'bottom',color:'#e9b7c1',colorRanges:null,outlineWidth:0});
m.fillOpacity=0;group.add(m);
ruby.push({m,x:(carets[i*4]+carets[i*4+1])/2,y:Math.max(carets[i*4+2],carets[i*4+3])+fontSize*.06,order:order++});
});
}
const total=bw+2*(gap+bulge+thick)+fontSize*.2,tall=h+fontSize*.5;
fit=Math.min(1,width*.9/total,height*.7/tall);group.scale.setScalar(fit);built=true;
}
function update(t){
if(!built)build();
const e=spring(clamp(t/enter)),q=smooth(clamp((life-t)/exit));
// グリッチの強さ:登場でいちばん強く、そのあと約1.3秒ごとに短く走る。終わりぎわにも走る
const beat=(t%1.3)<.13?1:0,entering=1-smooth(t/(enter*1.1)),leaving=1-smooth(clamp((life-t)/(exit*.8)));
const power=Math.max(entering,beat*.7,leaving*.8),tq=Math.floor(t*18),on=power>.02;
bands.forEach((m,k)=>{
const dx=on?(hash(k,tq)-.5)*fontSize*.5*power:0;
m.position.set(dx,0,0);m.fillOpacity=smooth(t/(enter*.5))*smooth(q*2);
});
const sh=on?power:0;
shadow.position.set(fontSize*.09*sh+(hash(tq,5)-.5)*fontSize*.05*power,-fontSize*.08*sh,-.5);
shadow.fillOpacity=sh*.95*smooth(q*2);
for(const p of parts){
const slide=(1-Math.min(1,e))*fontSize*1.2*p.side+(1-q)*-p.side*fontSize*.5; // 外から寄ってきて、退場でも内へ閉じる
p.w.position.set(p.x+slide,0,0);p.o.position.set(p.x+slide+fontSize*.08*sh,-fontSize*.07*sh,0);
p.w.visible=e>.01;p.o.visible=sh>.05;
}
white.opacity=smooth(e*2)*smooth(q*2);orange.opacity=sh*.9*smooth(q*2);
const per=Math.min(.08,(enter*.9)/Math.max(1,ruby.length));
for(const r of ruby){
const k=spring(clamp((t-.2-r.order*per)/.3));
r.m.position.set(r.x,r.y+(1-Math.min(1,k))*-fontSize*.2,0);r.m.fillOpacity=smooth(k*2)*smooth(q*2);
}
}
function dispose(){
for(const m of [shadow,...bands]){group.remove(m);m.dispose();}
for(const r of ruby){group.remove(r.m);r.m.dispose();}
for(const p of parts){group.remove(p.w,p.o);p.w.geometry.dispose();}
white.dispose();orange.dispose();scene.remove(group);
}
update(0);
return {update,dispose};
ひらがなやカタカナの上に、読みのローマ字が小さく添えられて現れます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// ひらがな・カタカナの上に、読みのローマ字を小さく添える。ローマ字は左から順に浮かんで現れる(漢字には付けない)
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const life=Math.max(.05,duration),enter=Math.min(.7,life*.25),exit=Math.min(.5,life*.18);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrap=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.86,n=Math.ceil(longest/limit-.02);return {at:n>1?longest/n*1.12:limit,lines:rows.length*Math.max(1,n)};})();
const wrapAt=wrap.at,lines=wrap.lines;
// かな→ローマ字(ヘボン式)。漢字・っ・ー・小さい文字は読みを付けない(拗音は直前の文字にまとめる)
const KANA='あA いI うU えE おO かKA きKI くKU けKE こKO さSA しSHI すSU せSE そSO たTA ちCHI つTSU てTE とTO なNA にNI ぬNU ねNE のNO はHA ひHI ふFU へHE ほHO まMA みMI むMU めME もMO やYA ゆYU よYO らRA りRI るRU れRE ろRO わWA をWO んN がGA ぎGI ぐGU げGE ごGO ざZA じJI ずZU ぜZE ぞZO だDA ぢJI づZU でDE どDO ばBA びBI ぶBU べBE ぼBO ぱPA ぴPI ぷPU ぺPE ぽPO ゔVU';
const ROM={};for(const p of KANA.split(' '))ROM[p[0]]=p.slice(1);
const SMALL={'ゃ':'A','ゅ':'U','ょ':'O'};
const toHira=c=>{const k=c.codePointAt(0);return k>=0x30A1&&k<=0x30F6?String.fromCodePoint(k-0x60):c};
function romajiList(chars){
return chars.map((raw,i)=>{
const c=toHira(raw),next=chars[i+1]?toHira(chars[i+1]):'';
let r=ROM[c];if(!r)return '';
if(SMALL[next])r=(r==='SHI'?'SH':r==='CHI'?'CH':r==='JI'?'J':r.slice(0,-1)+'Y')+SMALL[next];
return r;
});
}
const group=new THREE.Group();scene.add(group);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.5,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
label.fillOpacity=0;label.outlineOpacity=0;group.add(label);
const ruby=[]; // {m, x, y, order}
let fit=1,built=false;
function build(){
const info=label.textRenderInfo;if(!info)return;
const chars=Array.from(String(text||'')),carets=info.caretPositions,reads=romajiList(chars);
const b=info.blockBounds,w=b[2]-b[0],h=b[3]-b[1];
if(carets&&carets.length===String(text||'').length*4&&chars.length===String(text||'').length){ // 1文字ずつの位置が取れたときだけルビを付ける
let order=0;
chars.forEach((c,i)=>{
if(!reads[i])return;
const m=createText(reads[i],{fontSize:fontSize*.3,letterSpacing:.1,anchorX:'center',anchorY:'bottom',color:accent,colorRanges:null,outlineWidth:0});
m.fillOpacity=0;group.add(m);
ruby.push({m,x:(carets[i*4]+carets[i*4+1])/2,y:Math.max(carets[i*4+2],carets[i*4+3])+fontSize*.04,order:order++});
});
}
fit=Math.min(1,height*.72/(h+fontSize*.4),width*.9/w);group.scale.setScalar(fit);built=true;
}
function update(t){
if(!built)build();
const e=spring(clamp(t/enter)),q=smooth(clamp((life-t)/exit));
label.position.y=(1-Math.min(1,e))*-fontSize*.3;
const f=smooth(t/(enter*.5))*smooth(q*2);label.fillOpacity=f;label.outlineOpacity=f;
const per=Math.min(.09,(enter*.9)/Math.max(1,ruby.length));
for(const r of ruby){
const k=spring(clamp((t-.15-r.order*per)/.3));
r.m.position.set(r.x,r.y+(1-Math.min(1,k))*-fontSize*.25-(1-q)*fontSize*.1,0);
r.m.fillOpacity=smooth(k*2)*smooth(q*2);
}
}
function dispose(){for(const r of ruby){group.remove(r.m);r.m.dispose();}label.dispose();scene.remove(group);}
update(0);
return {update,dispose};
四角い枠が奥から手前へ流れるように次々と広がる、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 四角い等高線を対数の間隔で手前へ流す背景(文字は描かない)。外周2.5%には模様を出さない。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float a=.12*sin(u_time*.35); // ゆっくり傾く
p=mat2(cos(a),-sin(a),sin(a),cos(a))*p;
float d=max(abs(p.x),abs(p.y))+.0001; // 正方形の等高線
float L=log(d)*3.4-u_time*.55; // 対数の間隔で手前へ流れる
float w=.5-abs(fract(L)-.5);
float line=1.-smoothstep(.0,.07+.1*d,w);
float near=smoothstep(.02,.35,d);
vec3 col=mix(u_accent,vec3(1.),.35+.25*sin(L*3.1));
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,line*near*.62*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
同心円が中心から外へ脈打つように広がる、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 輪ごとに太さの違う同心円が中心から外へ広がる背景(文字は描かない)。文字モーションの下に重ねて使う
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float d=length(p);
float L=d*10.-u_time*1.1; // 中心から外へ広がる
float f=fract(L);
float ring=smoothstep(.0,.06,f)*(1.-smoothstep(.12+.1*sin(floor(L)*1.7),.2+.1*sin(floor(L)*1.7),f)); // 輪ごとに太さが違う
float pulse=.55+.45*sin(u_time*2.2-d*8.);
vec3 col=mix(u_accent,vec3(1.),mod(floor(L),2.)*.6);
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,ring*pulse*.6*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
中心から外へ放射状の線が走る、文字なしの集中線の背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 放射状の線を12コマ/秒でちらつかせる集中線の背景(文字は描かない)。中心付近は空ける
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash11(float x){return fract(sin(x*127.1)*43758.5453);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float r=length(p),a=atan(p.y,p.x)/6.28318530718+.5;
float n=96.,id=floor(a*n),f=fract(a*n);
float tq=floor(u_time*12.); // 12コマ/秒でちらつく
float h=hash11(id+tq*7.3);
float start=.22+.2*hash11(id*3.1+tq); // 線の始まる半径
float width=.08+.25*hash11(id*1.7+tq*2.);
float line=step(.5-width*.5,f)*step(f,.5+width*.5)*step(start,r)*step(.35,h);
float tip=smoothstep(start,start+.15,r);
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(mix(vec3(1.),u_accent,.25),line*tip*.7*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
太い縞と細い縞が逆向きに斜めへ流れる、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 太い縞と細い縞を別の速さと向きで流す背景(文字は描かない)
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float u=(p.x+p.y)*6.5-u_time*.9;
float v=(p.x-p.y)*3.2+u_time*.5;
float s1=1.-smoothstep(.0,.03,abs(fract(u)-.5)-.07); // 太い縞(周期の約14%)
float s2=1.-smoothstep(.0,.04,abs(fract(v)-.5)-.035); // 細い縞(逆向きに流れる)
float k=s1*.7+s2*.45;
vec3 col=mix(u_accent,vec3(1.),.4*step(.5,fract(u*.5)));
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,clamp(k,0.,1.)*.55*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
細かなドットが中心から外へ走る波で大きさを変える、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 18×18の網点の半径を、中心からの波で変える背景(文字は描かない)
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec2 g=p*18.,id=floor(g),f=fract(g)-.5;
float d=length((id+.5)/18.);
float wave=.5+.5*sin(d*14.-u_time*2.4); // 中心から外へ走る波
float radius=.08+.34*wave;
float dotv=1.-smoothstep(radius-.06,radius,length(f));
vec3 col=mix(u_accent,vec3(1.),wave*.5);
float edge=smoothstep(.025,.05,min(vUv.x,1.-vUv.x))*smoothstep(.025,.05,min(vUv.y,1.-vUv.y));
gl_FragColor=vec4(col,dotv*(.25+.4*wave)*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
方眼の上を走査線が下へ走り、四隅にカギ括弧が付く、文字なしの背景です。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 方眼(2マスおきに太線)と、上から下へ走る走査線、四隅のカギ括弧の背景(文字は描かない)
const {THREE,scene,width,height,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.7,duration*.25),exit=Math.min(.6,duration*.2);
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec2 g=p*10.;
vec2 fr=abs(fract(g)-.5);
float minor=1.-smoothstep(.0,.035,min(.5-fr.x,.5-fr.y)); // 方眼の細い線
vec2 fr2=abs(fract(g*.5)-.5);
float major=1.-smoothstep(.0,.025,min(.5-fr2.x,.5-fr2.y)); // 2マスおきの太い線
float grid=max(minor*.35,major*.6);
float scan=smoothstep(.05,.0,abs(vUv.y-fract(u_time*.2))); // 上から下へ走る走査線
vec2 q=min(vUv,1.-vUv); // 各辺からの距離
float inCorner=step(.05,q.x)*step(q.x,.18)*step(.05,q.y)*step(q.y,.18);
float corner=inCorner*(1.-step(.065,q.x)*step(.065,q.y)); // 四隅のカギ括弧
float edge=smoothstep(.025,.05,q.x)*smoothstep(.025,.05,q.y);
vec3 col=mix(u_accent,vec3(1.),.3);
gl_FragColor=vec4(col,clamp(grid*.5+scan*.7+corner*.9,0.,1.)*.55*edge*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-5;scene.add(quad);
function update(t){material.uniforms.u_time.value=t;material.uniforms.u_fade.value=smooth(t/enter)*smooth((duration-t)/exit);}
function dispose(){quad.geometry.dispose();material.dispose();scene.remove(quad);}
update(0);
return {update,dispose};
時計の針が回るように、粒が時計回りに順にはじき飛ばされていきます。
// 時計の針が一周するあいだに、針に触れた粒から外へはじき飛ばされる
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float turn = atan(c.x, c.y) / 6.2832 + .5; // 0〜1 = 真上から時計回りに一周
float delay = turn * .4 + ph(1.) * .06;
float e = smoothstep(delay, delay + .25, amount);
vec2 away = normalize(c + vec2(.0001)) * e * e * (.6 + ph(2.) * .8) * uSize.y; // 中心から外へ
l = rot((ph(3.) - .5) * 8. * e) * l * (1. - .3 * e);
gKind = 2.; // 粒の見た目: 2 = 丸い点
gAlpha = 1. - .3 * e;
gLocal = 1.;
return vec3(c + l + away, (ph(4.) - .5) * .3 * e);
}
左の列から順に、タイルがドミノのように傾いて倒れていきます。
メソッド工房の「画像・立体・物理」のサンプルから選びます。
// 左の列から順に、タイルがドミノのように奥へ倒れる。強さ(amount)が戻ると元の位置へ帰る
const {createImageTiles,size,smooth}=api;
const tiles=createImageTiles();
const half=size.width*.5;
return {update(frame){
const t=Math.min(1,frame.amount/.8);
for(const piece of tiles){
const x=piece.home.x,y=piece.home.y,col=(x+half)/(2*half); // 0 = 左端、1 = 右端
const k=smooth(Math.max(0,Math.min(1,t*1.4-col*1.1))); // 左の列ほど早く倒れる
piece.mesh.position.set(x+k*piece.width*.2,y-k*half*.06,-k*half*.15);
piece.mesh.rotation.set(k*.35*Math.sin(y/half*3),k*1.3,0);
piece.mesh.scale.setScalar(1-.12*k);
}
}};
横たわった文字が、手前へめくれるように起き上がって止まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 寝ていた文字が手前へ起き上がる(x軸まわりの回転)。わずかに行き過ぎてから止まり、退場は倒れながら消える
const {createText,text,width,fontSize,duration}=api;
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,outlineWidth:'2%',outlineColor:'#101819'});
const clamp=x=>Math.max(0,Math.min(1,x));
const enter=Math.min(.75,duration*.28),exit=Math.min(.4,duration*.15);
return {update(t){
const u=clamp(t/enter),b=clamp((duration-t)/exit);
const g=1-Math.exp(-5*u)*Math.cos(9*u); // 0 から1を少し行き過ぎて、1へ落ち着く
label.rotation.x=(1-g)*1.45+(1-b)*1.2;
label.position.set(0,-(1-g)*40*(width/480),0);
const fade=clamp(t/(enter*.35))*b;
label.fillOpacity=fade;label.outlineOpacity=fade;
}};
画面じゅうに散らばった文字が、くるくる回りながら集まって並びます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 散らばった文字が、ばねで元の位置へ集まる。退場は、また別の場所へ散らばって消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const e=spring(u),k=1-e,q=1-smooth(v);
const x1=(l.r1-.5)*W*.95/fit,y1=(l.r2-.5)*H*.8/fit,x2=(l.r3-.5)*W*.95/fit,y2=(l.r4-.5)*H*.8/fit;
let x=mix(l.x,x1,k),y=mix(l.y,y1,k);x=mix(x,x2,q);y=mix(y,y2,q);
const rot=(l.r3-.5)*5*k+(l.r1-.5)*6*q+Math.sin(t*2.4+l.r2*6)*.025;
const s=1+(l.r4-.4)*1.2*k-.5*q*q;
place(l,x,y,rot,s,s,smooth(u*2)*smooth(v*2));
}
update(0);
return {update,dispose};
文字がぷるんと現れて、最後は下へどろりと溶けて垂れていきます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 横に太って縦に潰れた状態からぷるんと立つ。退場は縦に伸びながら下へ垂れて消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const e=spring(u),q=1-smooth(v),sag=Math.sin(t*3+l.r1*6.283)*.03;
const sx=(1+.9*(1-e))*(1-.45*q)*(1-sag),sy=(.15+.85*e)*(1+2.6*q*q)+sag*.8;
const drop=q*q*em*(1.2+l.r2*1.6)+Math.max(0,sy-1)*em*.5;
place(l,l.x+Math.sin(q*5+l.r3*6)*em*.05*q,l.y+(1-e)*em*.35-drop,Math.sin(q*3+l.r4*6)*.08*q,sx,Math.max(.05,sy),smooth(u*2.5)*(1-smooth(q*1.15)));
}
update(0);
return {update,dispose};
手描きアニメのように、文字が小刻みにぶるぶる震えながら現れます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 10コマ/秒の手描きのゆれ。登場と退場では、ゆれが大きく荒れる
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const tq=Math.floor(t*10),a=hash(l.i,tq),b=hash(l.i+7,tq),c=hash(l.i+13,tq);
const loose=(1-smooth(u))+(1-smooth(v)),j=em*(.07+.26*loose);
place(l,l.x+(a-.5)*j*2,l.y+(b-.5)*j*2,(c-.5)*(.12+.6*loose),1+(a-.5)*.16+.4*loose*(b-.5),1+(b-.5)*.1+.3*loose*(c-.5),smooth(u*3)*smooth(v*3));
}
update(0);
return {update,dispose};
文字が互いに入れ替わりながら弧を描いて動き、正しい並びに揃います。
メソッド工房の「文字モーション」のサンプルから選びます。
// 文字が互いの位置から弧を描いて元の位置へ。退場ではまた別の文字の位置へ入れ替わって消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const order=vis.map(l=>l.i).sort((a,b)=>vis[a].r1-vis[b].r1||a-b),order2=vis.map(l=>l.i).sort((a,b)=>vis[a].r3-vis[b].r3||a-b);
function pose(l,t,u,v){
const e=smooth(u),q=1-smooth(v),s1=vis[order[l.i]],s2=vis[order2[l.i]];
const arc=(l.r2-.5)*em*3.2,bend=Math.sin(Math.PI*e)*arc,bend2=Math.sin(Math.PI*q)*arc;
let x=mix(s1.x,l.x,e),y=mix(s1.y,l.y,e)+bend;
x=mix(x,s2.x,q);y=mix(y,s2.y,q)+bend2;
place(l,x,y,Math.sin(Math.PI*e)*(l.r4-.5)*1.4+Math.sin(Math.PI*q)*(l.r1-.5)*1.4,1,1,smooth(u*3)*smooth(v*3));
}
update(0);
return {update,dispose};
文字がひと山に重なった状態から、ぱっと弾けて元の位置に広がります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 全部の文字が重なった山から、ばねで弾けて並ぶ。退場は山へ吸い込まれて消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const e=spring(u),q=1-smooth(v),px=(l.r1-.5)*em*.9,py=(l.r2-.5)*em*.6;
let x=mix(px,l.x,e),y=mix(py,l.y,e);x=mix(x,px,q);y=mix(y,py,q);
const rot=(l.r3-.5)*2.8*(1-e)+(l.r3-.5)*3.2*q,s=.55+.45*e-.4*q;
place(l,x,y,rot,s,s,smooth(u*4)*smooth(v*4));
}
update(0);
return {update,dispose};
文字が渦を巻きながら外から中へ入ってきて、元の位置に収まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 渦を巻いて外側から元の位置へ入る。退場は逆向きに巻き上げられて消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const k=1-smooth(u),q=1-smooth(v),m=k+q,spin=m*6.283*1.5*(l.r4>.5?1:-1),a=l.r2*6.283+spin,R=m*W*.4/fit*(.4+.6*l.r1);
place(l,l.x+Math.cos(a)*R,l.y+Math.sin(a)*R*.7,spin*.6,1-.45*Math.min(1,m),1-.45*Math.min(1,m),smooth(u*2.2)*smooth(v*2.2));
}
update(0);
return {update,dispose};
糸くずのように絡まって揺れていた文字が、ほどけて一列に整います。
メソッド工房の「文字モーション」のサンプルから選びます。
// リサジュー曲線で絡まった状態から、振れ幅を減らしてほどける。退場は再び絡まって消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const m=(1-smooth(u))+(1-smooth(v)),A=em*2.3*m*(.5+l.r1);
place(l,l.x+Math.sin(t*(2.2+l.r2*2)+l.r3*6.283)*A,l.y+Math.cos(t*(1.8+l.r4*2.2)+l.r1*6.283)*A*.6,Math.sin(t*3+l.r2*6)*.9*Math.min(1,m),1+.25*Math.min(1,m)*Math.sin(t*4+l.r4*6),1,smooth(u*2.5)*smooth(v*2.5));
}
update(0);
return {update,dispose};
文字が細かいかけらに割れていて、ひとつずつ飛んできて組み上がります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 文字を2×3のかけらに割り、かけらごとに時間差で飛んできて組み上がる。退場は割れて落ちる
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const budget=Math.floor(150/Math.max(1,vis.length)),rowsN=budget>=6?3:budget>=2?2:1,colsN=budget>=4?2:1,per=rowsN*colsN;
for(const l of vis){l.tiles=[l.mesh];
for(let k=1;k<per;k++){const m=createText(l.value,{textStart:l.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});m.fillOpacity=0;m.outlineOpacity=0;root.add(m);l.meshes.push(m);l.tiles.push(m);}
l.tr=l.tiles.map((_,k)=>({a:hash(l.i,k*3+1),b:hash(l.i,k*3+2),c:hash(l.i,k*3+3)}));l.cell=l.tiles.map(()=>({x:0,y:0}));}
function onLaid(){
for(const l of vis){const b=l.info&&l.info.blockBounds;if(!b||b.length!==4||!b.every(Number.isFinite))continue;
const w=b[2]-b[0],h=b[3]-b[1],bx=(b[0]+b[2])/2,by=(b[1]+b[3])/2;
l.tiles.forEach((m,k)=>{if(per===1){m.clipRect=null;return;}
const cx=k%colsN,cy=Math.floor(k/colsN);
m.clipRect=[b[0]+w*cx/colsN-1,b[3]-h*(cy+1)/rowsN-1,b[0]+w*(cx+1)/colsN+1,b[3]-h*cy/rowsN+1];
l.cell[k]={x:b[0]+w*(cx+.5)/colsN-bx,y:b[3]-h*(cy+.5)/rowsN-by};});}
}
function pose(l,t,u,v){
l.tiles.forEach((m,k)=>{const r=l.tr[k],c=l.cell[k];
const e=spring(clamp((t-stag*l.o-r.c*.3)/(intro*.8))),q=1-smooth(clamp((life-t-stag*(1-l.o)-r.b*.15)/outro));
const fx=(r.a-.5)*W*.55/fit*(1-e)+(r.a-.5)*em*3*q,fy=(r.b-.5)*H*.7/fit*(1-e)-q*q*em*(2+r.c*3);
const rot=(r.b-.5)*3.4*(1-e)+(r.a-.5)*2.6*q,s=1-.35*(1-e)-.2*q,cs=Math.cos(rot),sn=Math.sin(rot);
m.position.set(l.x+c.x-(c.x*cs-c.y*sn)*s+fx,l.y+c.y-(c.x*sn+c.y*cs)*s+fy,0);
m.rotation.z=rot;m.scale.set(s,s,1);
const a=smooth(e*3)*(1-smooth(q*1.2));m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;});
}
update(0);
return {update,dispose};
文字が真ん中から弾け飛び、弧を描いて降りて、元の位置で小さく弾みます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 中心から放物線で飛んで元の位置に着地し、小さく弾んで止まる。退場は重力で下へ落ちて消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const tau=clamp((t-stag*l.o)/intro),land=stag*l.o+intro,bt=Math.max(0,t-land),q=1-smooth(v);
const apex=em*(1.6+3.2*l.r1),side=(l.r2-.5)*em*4;
let x=mix((l.r3-.5)*em,l.x,smooth(tau)),y=mix((l.r4-.5)*em*.5,l.y,tau)+4*apex*tau*(1-tau);
y+=Math.abs(Math.sin(bt*Math.PI*3.2))*em*.3*Math.exp(-5*bt)*(tau>=1?1:0);
x+=Math.sin(Math.PI*tau)*side;
const rot=(l.r3-.5)*7*(1-tau)+(l.r1-.5)*5*q*q,fall=q*q*H*.9/fit;
place(l,x+(l.r2-.5)*em*2*q,y-fall,rot,1,1,smooth(u*5)*(1-smooth(q*1.1)));
}
update(0);
return {update,dispose};
文字がぐにゃぐにゃと波打つように揺れながら現れて、ぴたりと止まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 左から右へ波が走り、文字が縦横に伸び縮みして傾く。波の大きさは登場で最大になり、だんだん静まる
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const A=(1-smooth(u))*1.9+(1-smooth(v))*1.7+.16,ph=l.i*.75-t*9.5;
place(l,l.x+Math.sin(ph*.7+1)*em*.12*A,l.y+Math.sin(ph)*em*.38*A,Math.sin(ph+.6)*.5*A,1+Math.sin(ph+1.4)*.4*A,1+Math.cos(ph)*.45*A,smooth(u*3)*smooth(v*3));
}
update(0);
return {update,dispose};
中心から外へ波紋のように、文字がぽんぽんと時間差で弾んで現れます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 中心に近い文字から順に、ばねで弾みながら四分の一回転して現れる。退場は中心から外へ順に縮んで消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
let dmax=1;
function onLaid(){dmax=Math.max(1,...vis.map(l=>Math.hypot(l.x,l.y*1.6)));}
function pose(l,t,u,v){
const d=Math.hypot(l.x,l.y*1.6)/dmax,lag=Math.min(life*.3,.9),span=intro*.62;
const e=clamp((t-d*lag)/span),q=clamp((life-t-(1-d)*Math.min(life*.18,.5))/outro);
const k=spring(e),turn=(1-smooth(e))*(l.r1>.5?1:-1)*Math.PI*.5,s=Math.max(.01,k)*(.3+.7*smooth(q));
place(l,l.x,l.y+(1-k)*em*.25,turn+(1-smooth(q))*(l.r2-.5)*1.2,s,s,smooth(e*4)*smooth(q*3));
}
update(0);
return {update,dispose};
上から落ちた文字がぺしゃりとつぶれて、弾みながら立ち上がります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 文字が上から加速して落ち、着地でつぶれて何度か弾んで止まる。退場は伸びながら上へ吸い上げられて消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const tau=Math.max(0,t-stag*l.o),tl=intro*.5,bt=Math.max(0,tau-tl),q=1-smooth(v),h=H*.7/fit*(.7+.3*l.r1);
let y,sx=1,sy=1;
if(tau<tl){const k=tau/tl;y=l.y+h*(1-k*k);sy=1+.28*k*k;sx=1-.1*k*k;}
else{const sq=Math.exp(-7.5*bt)*Math.cos(bt*34);y=l.y+Math.abs(Math.sin(bt*Math.PI*2.6))*em*.45*Math.exp(-4.2*bt);sy=1-.5*sq;sx=1+.34*sq;y+=(sy-1)*em*.5;}
y+=q*q*H*.8/fit;sy*=1+q*1.2;sx*=1-.4*q;
place(l,l.x,y,0,sx,sy,smooth(tau/(tl*.4))*smooth(v*4));
}
update(0);
return {update,dispose};
ぶれた分身が回りながら本体に重なって、くっきりと一つの文字になります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 色違いの分身を文字のまわりで回し、半径を縮めて本体へ重ねる。退場では分身が再び広がって消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const extra=Math.max(0,Math.min(3,Math.floor(150/Math.max(1,vis.length))-1));
for(const l of vis){l.ghosts=[];for(let k=0;k<extra;k++){const m=createText(l.value,{textStart:l.start,fontSize:em,anchorX:'center',anchorY:'middle',color:api.accent,colorRanges:null});m.fillOpacity=0;m.outlineOpacity=0;root.add(m);l.meshes.push(m);l.ghosts.push(m);}}
function put(m,x,y,rot,sx,sy,a,o){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=o;m.visible=a>.002;}
function pose(l,t,u,v){
const m=(1-smooth(u))+(1-smooth(v)),mm=Math.min(1,m),R=em*1.15*mm,a=smooth(u*3)*smooth(v*3);
put(l.mesh,l.x+Math.sin(t*9+l.r1*6)*em*.035*mm,l.y+Math.cos(t*8+l.r2*6)*em*.035*mm,Math.sin(t*5+l.r3*6)*.06*mm,1,1,a,a);
l.ghosts.forEach((g,k)=>{const ang=k/Math.max(1,l.ghosts.length)*6.283+t*(2.4+l.r3)+l.r4*6.283;
put(g,l.x+Math.cos(ang)*R,l.y+Math.sin(ang)*R*.7,Math.sin(ang)*.25*mm,1+.1*mm,1+.1*mm,.45*smooth(mm*2)*a,0);});
}
update(0);
return {update,dispose};
外側に離れていた文字が磁石のように引き寄せられて、かちっと収まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 文字が中心と反対側の遠くから、だんだん加速して元の位置へ吸い付き、小さく震えて止まる。退場は弾かれて飛び去る
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const dx=l.x,dy=l.y*1.3,len=Math.hypot(dx,dy),ux=len>1?dx/len:Math.cos(l.r1*6.283),uy=len>1?dy/len:Math.sin(l.r1*6.283);
const far=W*.42/fit*(.5+.5*l.r2),e=Math.pow(u,2.2),q=1-smooth(v),qq=q*q*q;
const bt=Math.max(0,t-(stag*l.o+intro)),shake=u>=1?Math.sin(bt*70+l.r3*6)*Math.exp(-14*bt)*em*.09:0;
const k=(1-e)*far+qq*far;
place(l,l.x+ux*k+shake,l.y+uy*k*.8-shake*.5,(l.r4-.5)*2.2*(1-e)+(l.r1-.5)*2*qq,1,1,smooth(u*5)*smooth(v*3));
}
update(0);
return {update,dispose};
文字が左右交互のレーンを滑ってきて、ぴたりと一列に揃います。
メソッド工房の「文字モーション」のサンプルから選びます。
// 文字を3段のレーンに分け、左右交互の方向から急加速・急減速で滑り込む。退場は別の向きへ滑り出る
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const q5=x=>{x=clamp(x);return x<.5?16*x*x*x*x*x:1-Math.pow(-2*x+2,5)/2};
function pose(l,t,u,v){
const e=q5(u),q=q5(1-v),side=l.i%2?1:-1,lane=(l.i%3)-1,far=W*.55/fit;
place(l,l.x+side*far*(1-e)-side*far*q,l.y+lane*em*1.25*(1-e)-lane*em*1.25*q,0,1,1,smooth(u*4)*smooth(v*4));
}
update(0);
return {update,dispose};
文字がカードのようにくるりとめくれて、表向きにぴたっと止まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 文字をカードに見立てて縦軸で半回転させる。裏返しの間は鏡文字になり、行き過ぎてから止まる。退場はもう一度めくれて消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const bk=x=>{x=clamp(x);const c=1.5;return 1+(c+1)*Math.pow(x-1,3)+c*Math.pow(x-1,2)};
const e=bk(u),q=1-smooth(v),dir=l.i%2?1:-1,ang=(1-e)*Math.PI+q*Math.PI*dir,c=Math.cos(ang);
place(l,l.x,l.y+Math.sin(Math.abs(ang))*em*.35*dir,0,Math.abs(c)<.03?.03*(c<0?-1:1):c,1+.12*Math.sin(Math.abs(ang)),smooth(u*5)*smooth(v*5));
}
update(0);
return {update,dispose};
文字が泡のようにゆらゆら浮かび上がり、最後はぷくっとふくらんで消えます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 下から横に揺れながら浮かんで元の位置へ。伸び縮みしながら小さく膨らむ。退場は上へ浮かびふくらんで消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const e=1-Math.pow(1-clamp(u),3),q=1-smooth(v),rise=(1-e)*H*.6/fit*(.6+.4*l.r1);
const sway=Math.sin(t*3+l.r2*6.283)*em*.45*(1-e)+Math.sin(t*5+l.r3*6)*em*.05,w=Math.sin(t*9+l.r4*6.283)*.1*(1-e*.7);
const sc=(.25+.75*e)*(1+.4*q*q);
place(l,l.x+sway,l.y-rise+q*q*H*.35/fit,sway/em*.15,sc*(1+w),sc*(1-w),smooth(u*3)*(1-smooth(q*1.1)));
}
update(0);
return {update,dispose};
横に引き伸ばされた文字が、ゴムのようにパチンと戻って弾みます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 左へ引っ張られて横に伸びた文字が、ばねで元の形に戻る(縮みすぎてから止まる)。退場は縦に引き伸ばされて消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
function pose(l,t,u,v){
const e=spring(u),q=1-smooth(v),s=(1-e)*2.4,sx0=Math.max(.2,1+s),qs=q*2.6;
const sx=sx0/Math.sqrt(1+qs),sy=1/Math.sqrt(sx0)*(1+qs);
place(l,l.x-s*em*.9,l.y+qs*em*.4,0,sx,sy,smooth(u*4)*smooth(v*4));
}
update(0);
return {update,dispose};
横に切れた文字の帯が左右にずれていて、すうっと重なって読める形に戻ります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 文字を3本の横帯に切り、帯ごとに左右へ大きくずらしてから揃える。退場はまた帯がずれて消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const budget=Math.floor(150/Math.max(1,vis.length)),rowsN=budget>=3?3:budget>=2?2:1,per=rowsN;
for(const l of vis){l.tiles=[l.mesh];
for(let k=1;k<per;k++){const m=createText(l.value,{textStart:l.start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});m.fillOpacity=0;m.outlineOpacity=0;root.add(m);l.meshes.push(m);l.tiles.push(m);}
l.tr=l.tiles.map((_,k)=>({a:hash(l.i,k*3+1),b:hash(l.i,k*3+2)}));}
function onLaid(){
for(const l of vis){const b=l.info&&l.info.blockBounds;if(!b||b.length!==4||!b.every(Number.isFinite))continue;
const h=b[3]-b[1];
l.tiles.forEach((m,k)=>{if(per===1){m.clipRect=null;return;}m.clipRect=[b[0]-1,b[3]-h*(k+1)/rowsN-1,b[2]+1,b[3]-h*k/rowsN+1];});}
}
function pose(l,t,u,v){
l.tiles.forEach((m,k)=>{const r=l.tr[k],dir=k%2?1:-1;
const e=spring(clamp((t-stag*l.o-k*.06)/(intro*.85))),q=1-smooth(clamp((life-t-stag*(1-l.o)-k*.04)/outro));
m.position.set(l.x+dir*(W*.4/fit*(.4+.6*r.a)*(1-e)+em*2.4*q*(.4+.6*r.b)),l.y,0);m.rotation.z=0;m.scale.set(1,1,1);
const a=smooth(e*4)*(1-smooth(q*1.2));m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;});
}
update(0);
return {update,dispose};
文字がマス目の上を横へ、続けて縦へと直角に歩いて、元の位置へ収まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 文字を整数マスぶん離れた位置に置き、まず横、続いて縦へ急加速・急減速で動かす。退場は縦、続いて横へ歩いて消える
const {THREE,scene,createText,text,width,height,fontSize,duration,random}=api;
const clamp=(x,a=0,b=1)=>Math.max(a,Math.min(b,x));
const smooth=x=>{x=clamp(x);return x*x*(3-2*x)};
const spring=x=>{x=clamp(x);return 1-(1-x)*Math.exp(-3.4*x)*Math.cos(6.6*x)};
const mix=(a,b,k)=>a+(b-a)*k;
const hash=(a,b)=>{const s=Math.sin(a*127.1+b*311.7)*43758.5453;return s-Math.floor(s)};
const W=Math.max(1,width),H=Math.max(1,height),em=Math.max(8,fontSize||H*.18),life=Math.max(.05,duration);
const intro=Math.min(1.05,life*.38),outro=Math.min(.6,life*.2),stag=Math.min(.2,life*.07);
const root=new THREE.Group();scene.add(root);
const lines=[[]];let off=0;
for(const ch of Array.from(String(text||''))){if(ch==='\r')continue;if(ch==='\n'){lines.push([]);off+=1;continue;}lines[lines.length-1].push({ch,start:off});off+=ch.length;}
const count=lines.reduce((s,l)=>s+l.length,0),group=Math.max(1,Math.ceil(count/64));
const L=[];
lines.forEach((line,r)=>{for(let k=0;k<line.length;k+=group){const part=line.slice(k,k+group),value=part.map(p=>p.ch).join('');
const w=em*Math.max(.3,part.reduce((s,p)=>s+(/\s/.test(p.ch)?.32:p.ch.codePointAt(0)<256?.6:1),0));
if(!value.trim()){L.push({row:r,w,meshes:[],blank:true});continue;}
const mesh=createText(value,{textStart:part[0].start,fontSize:em,anchorX:'center',anchorY:'middle',outlineWidth:'3%',outlineColor:'#101819'});
mesh.fillOpacity=0;mesh.outlineOpacity=0;root.add(mesh);
L.push({row:r,w,mesh,meshes:[mesh],value,start:part[0].start,info:null,r1:random(),r2:random(),r3:random(),r4:random(),x:0,y:0});}});
const vis=L.filter(l=>l.mesh);vis.forEach((l,j)=>{l.i=j;l.o=vis.length>1?j/(vis.length-1):.5;});
let fit=1,laid=false,dead=false;
function layout(){
const gap=em*.06,limit=W*.86,rows=[];
const hard=[];for(const l of L)(hard[l.row]=hard[l.row]||[]).push(l);
for(const row of hard){if(!row)continue;
for(const l of row){const b=l.mesh&&l.mesh.textRenderInfo&&l.mesh.textRenderInfo.blockBounds;
if(b&&b.length===4&&b.every(Number.isFinite))l.w=Math.max(em*.2,b[2]-b[0]);}
const total=row.reduce((sum,l,k)=>sum+l.w+(k?gap:0),0),n=Math.max(1,Math.ceil(total/limit)),cap=n>1?Math.min(limit,total/n*1.12+gap):limit;
let cur=[],x=0;
for(const l of row){if(cur.length&&x+gap+l.w>cap){rows.push(cur);cur=[];x=0;}
x+=(cur.length?gap:0)+l.w;cur.push(l);}
rows.push(cur);}
const step=em*1.25;let maxW=1;
rows.forEach((row,r)=>{let x=0;for(const l of row){l.x=x+l.w/2;x+=l.w+gap;}const tw=x-gap;maxW=Math.max(maxW,tw);for(const l of row){l.x-=tw/2;l.y=((rows.length-1)/2-r)*step;}});
fit=Math.min(1,W*.84/maxW,H*.66/(rows.length*step));root.scale.setScalar(fit);
}
function place(l,x,y,rot,sx,sy,a){for(const m of l.meshes){m.position.set(x,y,0);m.rotation.z=rot;m.scale.set(sx,sy,1);m.fillOpacity=a;m.outlineOpacity=a;m.visible=a>.002;}}
function update(time){
if(dead)return;const t=clamp(Number.isFinite(time)?time:0,0,life);
let changed=!laid;for(const l of vis)if(l.mesh.textRenderInfo&&l.info!==l.mesh.textRenderInfo){l.info=l.mesh.textRenderInfo;changed=true;}
if(changed){layout();laid=true;if(typeof onLaid==='function')onLaid();}
for(const l of vis){const u=clamp((t-stag*l.o)/intro),v=clamp((life-t-stag*(1-l.o))/outro);pose(l,t,u,v);}
}
function dispose(){if(dead)return;dead=true;for(const l of L)for(const m of l.meshes){root.remove(m);m.dispose();}scene.remove(root);}
const q5=x=>{x=clamp(x);return x<.5?16*x*x*x*x*x:1-Math.pow(-2*x+2,5)/2};
function pose(l,t,u,v){
const ox=Math.round((l.r1-.5)*8)*em,oy=Math.round((l.r2-.5)*5)*em*1.1,ex=Math.round((l.r3-.5)*8)*em,ey=Math.round((l.r4-.5)*5)*em*1.1;
const a=q5(clamp(u*2)),b=q5(clamp(u*2-1)),r=1-v,c=q5(clamp(r*2)),d=q5(clamp(r*2-1));
place(l,l.x+ox*(1-a)+ex*d,l.y+oy*(1-b)+ey*c,(1-a)*(l.r1>.5?1:-1)*Math.PI*.5+d*(l.r3>.5?1:-1)*Math.PI*.5,1,1,smooth(u*4)*smooth(v*4));
}
update(0);
return {update,dispose};
色が溶けあうフルシェーダーの霧が、文字の後ろでうねりながら現れます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// フルのフラグメントシェーダー(#define / uniform / main)で、色の溶けあう霧を文字の後ろにうねらせる
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
// 画面いっぱいの板に貼る頂点シェーダー(vUv は 0〜1 の画面位置)
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
// ここからが「フルシェーダー」。GLSLをそのまま書ける
const FRAG=`precision highp float;
#define TAU 6.28318530718
#define ROT(a) mat2(cos(a),-sin(a),sin(a),cos(a))
uniform float u_time; // 行が始まってからの秒
uniform float u_fade; // 登場・退場で 0→1→0
uniform vec2 u_resolution;
uniform vec3 u_accent; // 歌詞の強調色
varying vec2 vUv;
float hash21(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}
float noise(vec2 p){
vec2 i=floor(p),f=fract(p);f=f*f*(3.-2.*f);
return mix(mix(hash21(i),hash21(i+vec2(1,0)),f.x),mix(hash21(i+vec2(0,1)),hash21(i+vec2(1,1)),f.x),f.y);
}
float fbm(vec2 p){float s=0.,a=.5;for(int i=0;i<5;i++){s+=a*noise(p);p=ROT(.5)*p*2.03+7.1;a*=.5;}return s;}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float t=u_time*.3;
vec2 q=vec2(fbm(p*2.2+t),fbm(p*2.2+vec2(5.2,1.3)-t));
vec2 r=vec2(fbm(p*2.2+3.*q+vec2(1.7,9.2)+.4*t),fbm(p*2.2+3.*q+vec2(8.3,2.8)-.3*t));
float f=fbm(p*2.2+3.*r);
float hue=f*3.2+q.x*.8+t*.2; // 濃さに応じて色相を大きく振る
vec3 col=.62+.38*cos(TAU*(hue+vec3(0.,.33,.67)));
col=mix(col,u_accent,smoothstep(.5,.9,length(q))*.6);
float cloud=smoothstep(.26,.52,f);
float oval=1.-smoothstep(.55,1.,length(p/vec2(.62,.34)));
gl_FragColor=vec4(col,cloud*oval*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad); // 文字より後ろ
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.62/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
ドットの大きさが波のように変わる網点が、文字の後ろに帯になって流れます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// ドットの大きさが波のように変わる網点を、横長の帯にして文字の後ろに流す(フルシェーダー)
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
#define CELLS 30.0 // 縦に並べる網点の数
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec2 g=p*CELLS,id=floor(g),f=fract(g)-.5;
// 3つの波を足して、マスごとの大きさにする
float wave=sin(id.x*.33+u_time*2.2)+sin(id.y*.5-u_time*1.7)+sin((id.x+id.y)*.21+u_time*1.1);
float radius=.14+.3*(.5+.5*wave/3.);
float disc=1.-smoothstep(radius-.07,radius,length(f));
vec3 col=.55+.45*cos(TAU*(vec3(0.,.33,.67)+id.x*.011+id.y*.02+u_time*.08));
col=mix(col,u_accent,.22);
float band=1.-smoothstep(.2,.3,abs(p.y));
float side=1.-smoothstep(.55,.75,abs(p.x));
gl_FragColor=vec4(col,disc*band*side*u_fade*.9);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
赤・緑・青がずれた同心円のリングが、ゆがみながら脈打ちます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 赤・緑・青を少しずつずらした同心円のリングが、ゆがみながら脈打つ(フルシェーダー)
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define FREQ 30.0 // リングの細かさ
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float r=length(p),a=atan(p.y,p.x);
// 角度ごとに半径をゆがめる(輪郭が波打つ)
float warp=.022*sin(a*5.+u_time*1.6)+.014*sin(a*9.-u_time*2.3);
float pulse=.5+.5*sin(u_time*2.4);
vec3 ring=vec3(0.);
for(int i=0;i<3;i++){ // R,G,B をずらして重ねる
float shift=float(i)*.9;
float ph=(r+warp)*FREQ-u_time*3.+shift;
ring[i]=smoothstep(.5,.95,.5+.5*sin(ph));
}
float body=1.-smoothstep(.26+.04*pulse,.4,r); // 外へ向かって消える
float alpha=max(ring.r,max(ring.g,ring.b))*body*u_fade;
vec3 col=mix(ring,u_accent,.15)+.1;
gl_FragColor=vec4(col,alpha);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
弧のタイルがマスごとに向きを変えて、迷路のような模様が動きます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 弧のタイルがマスごとに向きを入れ替え、迷路のような模様が動く(フルシェーダー、時間だけで決まる)
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
#define CELLS 8.0 // 縦に並べるマスの数
#define PERIOD 1.9 // 1マスが向きを入れ替える間隔(秒)
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
float hash21(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}
// 四角い箱までの符号付き距離(角丸)
float roundBox(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}
// 弧のタイルまでの距離。flip が true なら左右を反転
float arcs(vec2 f,bool flip){
if(flip)f.x=1.-f.x;
return min(abs(length(f)-.5),abs(length(f-vec2(1.))-.5));
}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec2 g=p*CELLS,id=floor(g),f=fract(g);
float h=hash21(id);
float ph=u_time/PERIOD+h*7.;
float step0=floor(ph),k=smoothstep(0.,.3,fract(ph)); // 0.3周期かけて切り替わる
bool a=hash21(id+step0)>.5,b=hash21(id+step0+1.)>.5;
// いまの向きから次の向きへ、距離を混ぜて動かす
float d=mix(arcs(f,a),arcs(f,b),k);
float line=1.-smoothstep(.045,.085,d);
vec3 col=.55+.45*cos(TAU*(vec3(0.,.33,.67)+h*.5+u_time*.05));
col=mix(col,u_accent,.25);
float panel=1.-smoothstep(-.05,0.,roundBox(p,vec2(.66,.31),.07));
gl_FragColor=vec4(col,line*panel*u_fade*.92);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'5%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
Shadertoyのコードをそのまま貼れる枠で、光の粒が輪を描いて回ります。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// Shadertoy形式(mainImage / iTime / iResolution)のコードをそのまま貼れる枠。黒い所は透けて、光だけが文字の後ろに残る
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
// ↓ Shadertoy のコードはこの中身を差し替える(iChannel の画像は使えません)
const SHADERTOY=`
#define N 28
#define TAU 6.28318530718
void mainImage(out vec4 fragColor,in vec2 fragCoord){
vec2 uv=(fragCoord-.5*iResolution.xy)/iResolution.y;
vec3 col=vec3(0.);
for(int i=0;i<N;i++){
float fi=float(i);
float a=fi/float(N)*TAU+iTime*.5;
float wob=1.+.18*sin(a*3.+iTime*1.4); // 輪を波打たせる
vec2 c=vec2(cos(a),sin(a))*vec2(.55,.26)*wob;
float d=length(uv-c);
vec3 tint=.5+.5*cos(TAU*(fi/float(N)+vec3(0.,.33,.67)));
col+=tint*(.0011/(d*d+.0005)); // 小さな光の粒
}
fragColor=vec4(1.-exp(-col*.55),1.);
}`;
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
uniform float iTime;
uniform vec3 iResolution;
uniform float u_fade;
varying vec2 vUv;
${SHADERTOY}
void main(){
vec4 c=vec4(0.,0.,0.,1.);
mainImage(c,vUv*iResolution.xy);
float a=clamp(max(c.r,max(c.g,c.b)),0.,1.); // 明るさをそのまま不透明度にする
vec2 p=(vUv-.5)*vec2(iResolution.x/iResolution.y,1.);
float oval=1.-smoothstep(.55,1.,length(p/vec2(.68,.36)));
gl_FragColor=vec4(c.rgb/max(a,.0001),a*oval*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{iTime:{value:0},iResolution:{value:new THREE.Vector3(width,height,1)},u_fade:{value:0}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'5%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.iTime.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
色の違う細いリボンが重なりながら、波のようにうねって流れます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 細い帯を6本、位相の違う sin 波で重ねて流す(for は6回の定数ループ)。後から描いた帯が上に来る
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec3 col=vec3(0.);
float cover=0.;
for(int i=0;i<6;i++){
float fi=float(i);
float y=.11*sin(p.x*(3.+fi*.7)+u_time*(1.1+fi*.23)+fi*1.7)+.05*sin(p.x*(7.+fi)-u_time*1.7+fi)+(fi-2.5)*.065;
float w=.026+.01*sin(fi+u_time);
float a=1.-smoothstep(w*.4,w,abs(p.y-y));
col=mix(col,pal(fi*.17+u_time*.05),a); // 後の帯が上
cover=max(cover,a);
}
float box=(1.-smoothstep(.5,.74,abs(p.x)))*(1.-smoothstep(.25,.33,abs(p.y)));
gl_FragColor=vec4(col,cover*box*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
細胞のような区切りがゆっくり動いて、境目が光の線になって走ります。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// ボロノイ図(最近傍と2番目の距離の差)の境目だけを光らせる。点は sin でゆっくり漂う(3×3の定数ループ)
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
vec2 h22(vec2 p){p=vec2(dot(p,vec2(127.1,311.7)),dot(p,vec2(269.5,183.3)));return fract(sin(p)*43758.5453);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec2 g=p*5.5,id=floor(g),f=fract(g);
float d1=9.,d2=9.;
vec2 near=vec2(0.);
for(int j=-1;j<=1;j++){
for(int i=-1;i<=1;i++){
vec2 o=vec2(float(i),float(j));
vec2 r=.5+.42*sin(u_time*.8+TAU*h22(id+o));
vec2 q=o+r-f;
float d=dot(q,q);
if(d<d1){d2=d1;d1=d;near=id+o;}else if(d<d2){d2=d;}
}
}
float edge=sqrt(d2)-sqrt(d1);
float line=1.-smoothstep(.0,.1,edge);
float h=h22(near).x;
vec3 col=pal(h+u_time*.05);
float fill=.25*(1.-smoothstep(.0,.55,sqrt(d1)));
float oval=1.-smoothstep(.55,1.,length(p/vec2(.66,.33)));
gl_FragColor=vec4(mix(col,u_accent,.2),(line+fill)*oval*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
1本の光る糸が、リサジュー曲線を描きながらゆっくり形を変えます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 曲線を48本の線分にして、各画素から最短距離を求めて光らせる(for は48回の定数ループ)。曲線上の位置で色を変える
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
#define SEG 48
vec2 curve(float s){
float a=s*TAU;
return vec2(.56*sin(3.*a+u_time*.7+1.2),.26*sin(2.*a+u_time*.5));
}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float dmin=1.,ph=0.;
vec2 prev=curve(0.);
for(int i=1;i<=SEG;i++){
float s=float(i)/float(SEG);
vec2 cur=curve(s);
vec2 pa=p-prev,ba=cur-prev;
float h=clamp(dot(pa,ba)/dot(ba,ba),0.,1.);
float d=length(pa-ba*h);
if(d<dmin){dmin=d;ph=(float(i-1)+h)/float(SEG);}
prev=cur;
}
float line=1.-smoothstep(.007,.02,dmin);
float glow=.5*.012/(dmin+.012);
vec3 col=mix(pal(ph+u_time*.08),u_accent,.15);
gl_FragColor=vec4(col,clamp(line+glow*.6,0.,1.)*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
丸い万華鏡の中で、対称な模様がゆっくり回りながら色を変えます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 角度を6分割して折り返し(abs と mod)、折り返した座標に波の模様を重ねる。円の外は透明
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float r=length(p);
float a=atan(p.y,p.x)+u_time*.15;
float seg=TAU/6.;
a=abs(mod(a,seg)-seg*.5);
vec2 q=r*vec2(cos(a),sin(a));
q+=.03*vec2(sin(q.y*18.+u_time),cos(q.x*14.-u_time*1.2));
float f=.5+.5*sin(q.x*26.+sin(q.y*19.+u_time*1.3)*1.5-u_time*1.8);
float g=.5+.5*cos(q.y*31.+q.x*7.-u_time);
float pat=smoothstep(.2,.55,f*g*1.5+.12);
vec3 col=pal(r*1.6+f*.5+u_time*.06);
float disc=1.-smoothstep(.28,.35,r);
gl_FragColor=vec4(mix(col,u_accent,.15),pat*disc*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.4/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
ゼリーのような玉がくっついたり離れたりしながら、ぷるぷる漂います。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 7つの玉の場(r²/d²)を足して、しきい値で輪郭を出す。しきい値の少し手前を縁取りの光にする(for は7回)
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float field=0.;
for(int i=0;i<7;i++){
float fi=float(i);
vec2 c=vec2(.42*sin(u_time*(.6+.13*fi)+fi*2.1),.2*cos(u_time*(.5+.11*fi)+fi*1.3));
float r=.075+.02*sin(fi*3.+u_time);
float d=length(p-c);
field+=r*r/(d*d+1e-4);
}
float body=smoothstep(.95,1.1,field);
float rim=smoothstep(.75,.95,field)*(1.-smoothstep(1.0,1.3,field));
vec3 col=mix(pal(.55+u_time*.04),u_accent,smoothstep(1.,3.,field));
col+=rim*.35;
gl_FragColor=vec4(col,max(body*.82,rim*.7)*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
何本もの細い線が重なって、山脈のように波打ち続けます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 14本の線を上から順に重ね、線の下を暗く塗って奥の線を隠す。線の高さは周波数の違う sin を足して作る(for は14回)
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
#define LINES 14
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec3 col=vec3(0.);
float cover=0.;
float env=1.-smoothstep(.0,.5,abs(p.x));
for(int i=0;i<LINES;i++){
float fi=float(i);
float y0=.26-fi*(.5/float(LINES-1));
float h=sin(p.x*9.+fi*1.7+u_time*1.3)*.5+sin(p.x*17.-fi*.9-u_time*.9)*.3+sin(p.x*31.+fi*2.3+u_time*1.7)*.2;
float y=y0+h*.09*env;
float under=step(p.y,y); // 線より下を暗くして、奥の線を隠す
col=mix(col,vec3(.03,.05,.09),under*.9);
cover=mix(cover,.78,under);
float line=1.-smoothstep(.003,.009,abs(p.y-y));
col=mix(col,mix(pal(fi*.05+u_time*.04),vec3(1.),.35),line);
cover=mix(cover,1.,line);
}
float box=(1.-smoothstep(.46,.52,abs(p.x)))*(1.-smoothstep(.0,.05,p.y-.34))*smoothstep(-.33,-.28,p.y);
gl_FragColor=vec4(col,cover*box*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
マス目が中心から外へ順に、ふくらんで回りながら丸くなっていきます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// マスごとの時刻を「中心からの距離」だけずらし、①ふくらむ ②四分の一回転して角が丸くなる、の2段階を smoothstep でつなぐ。色はマスごとのハッシュ
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
#define CELLS 12.0
float hash21(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}
float roundBox(vec2 p,vec2 b,float r){vec2 q=abs(p)-b+r;return length(max(q,0.))+min(max(q.x,q.y),0.)-r;}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec2 g=p*CELLS,id=floor(g),f=fract(g)-.5;
vec2 cc=(id+.5)/CELLS;
float k=clamp(u_time*1.2-length(cc)*2.4,0.,2.); // 0..1 ふくらむ / 1..2 回って丸くなる
float grow=smoothstep(0.,1.,k),turn=smoothstep(1.,2.,k);
float size=.46*grow;
float ang=turn*1.5707963;
float c=cos(ang),s=sin(ang);
vec2 q=mat2(c,-s,s,c)*f;
float sd=roundBox(q,vec2(size),size*turn);
float shape=1.-smoothstep(-.03,.03,sd);
vec3 col=pal(hash21(id)*.6+u_time*.04);
float panel=1.-smoothstep(.0,.05,roundBox(p,vec2(.64,.3),.05));
gl_FragColor=vec4(mix(col,u_accent,.2),shape*panel*u_fade*.78);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
緑から紫へ色を変えるカーテンが、縦のすじをなびかせて揺れます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 一次元の値ノイズでカーテンの下端と高さを決め、縦すじ(高周波の sin)を掛ける。下ほど明るく、上へ向かって透けて消える
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash11(float x){return fract(sin(x*127.1)*43758.5453);}
float vnoise(float x){float i=floor(x),f=fract(x);f=f*f*(3.-2.*f);return mix(hash11(i),hash11(i+1.),f);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float x=p.x;
float base=-.14+.12*vnoise(x*2.2+u_time*.35)+.04*sin(x*3.+u_time*.6);
float h=.34+.16*vnoise(x*3.1-u_time*.25);
float streak=.5+.5*sin(x*55.+vnoise(x*6.+u_time)*6.);
float y=p.y-base;
float body=smoothstep(0.,.03,y)*pow(1.-smoothstep(0.,h,y),.7);
float inten=clamp(body*(.5+.9*streak)*1.35,0.,1.);
vec3 col=mix(vec3(.2,1.,.55),vec3(.8,.3,.95),smoothstep(0.,h,y));
float side=1.-smoothstep(.5,.78,abs(x));
gl_FragColor=vec4(mix(col,u_accent,.12),inten*side*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
重なった花びらが逆向きに回りながら、息をするように開いたり閉じたりします。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// 極座標で半径を |cos(n/2·角度)| に比例させて花びらの輪郭を作る。花びらの数は5・7・9枚、回る向きは交互(for は3回)
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
float r=length(p),a=atan(p.y,p.x);
vec3 col=vec3(0.);
float cover=0.;
for(int i=0;i<3;i++){
float fi=float(i);
float n=5.+fi*2.;
float rot=u_time*(.25+.1*fi)*(mod(fi,2.)<.5?1.:-1.);
float R=(.31-.07*fi)*(.85+.15*sin(u_time*1.6+fi));
float shape=R*(.4+.6*abs(cos(n*.5*(a+rot))));
float edge=r-shape;
float line=1.-smoothstep(.0,.014,abs(edge));
float fill=(1.-smoothstep(-.012,.0,edge))*.34;
float al=max(line,fill);
col=mix(col,pal(fi*.3+u_time*.06),al);
cover=max(cover,al);
}
gl_FragColor=vec4(mix(col,u_accent,.12),cover*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.4/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
小さな星がそれぞれのタイミングで瞬いて、十字の光を放ちます。
メソッド工房の「エフェクト」のサンプルから選びます(文字の後ろや周りに重なる装飾です)。
// マスごとのハッシュで星の有無・位置・瞬きの位相を決め、周囲3×3マスぶんを足す。核(1/d²)と十字の筋を重ねる
const {THREE,scene,createText,text,width,height,fontSize,duration,accent}=api;
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.32),exit=Math.min(.55,duration*.2);
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const VERT=`varying vec2 vUv;
void main(){vUv=uv;gl_Position=projectionMatrix*modelViewMatrix*vec4(position,1.0);}`;
const FRAG=`precision highp float;
#define TAU 6.28318530718
uniform float u_time;
uniform float u_fade;
uniform vec2 u_resolution;
uniform vec3 u_accent;
varying vec2 vUv;
vec3 pal(float k){return .55+.45*cos(TAU*(k+vec3(0.,.33,.67)));}
float hash12(vec2 p){p=fract(p*vec2(123.34,456.21));p+=dot(p,p+45.32);return fract(p.x*p.y);}
void main(){
float aspect=u_resolution.x/u_resolution.y;
vec2 p=(vUv-.5)*vec2(aspect,1.);
vec2 g=p*6.,id=floor(g),f=fract(g);
float glow=0.;
vec3 col=vec3(0.);
for(int j=-1;j<=1;j++){
for(int i=-1;i<=1;i++){
vec2 o=vec2(float(i),float(j));
float h=hash12(id+o);
if(h<.45)continue;
vec2 q=o+.5+.34*vec2(sin(h*40.+u_time*.5),cos(h*37.+u_time*.4))-f;
float tw=.5+.5*sin(u_time*3.+h*60.);
float d=length(q);
float core=.02/(d*d+.003)*(.35+.65*tw);
float cross=(1.-smoothstep(.0,.035,min(abs(q.x),abs(q.y))))*(1.-smoothstep(.0,.55,d))*tw;
float s=core*.45+cross*1.1;
col+=pal(h*3.+u_time*.04)*s;
glow+=s;
}
}
float oval=1.-smoothstep(.6,1.,length(p/vec2(.7,.34)));
gl_FragColor=vec4(col/max(glow,.001),clamp(glow,0.,1.)*oval*u_fade);
}`;
const material=new THREE.ShaderMaterial({transparent:true,depthTest:false,depthWrite:false,vertexShader:VERT,fragmentShader:FRAG,
uniforms:{u_time:{value:0},u_fade:{value:0},u_resolution:{value:new THREE.Vector2(width,height)},u_accent:{value:new THREE.Color(accent)}}});
const quad=new THREE.Mesh(new THREE.PlaneGeometry(width,height),material);
quad.position.z=-10;quad.renderOrder=-1;scene.add(quad);
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,anchorX:'center',anchorY:'middle',outlineWidth:'4%',outlineColor:'#0b0f14'});
let fitted=false;
function update(t){
if(!fitted&&label.textRenderInfo){const b=label.textRenderInfo.blockBounds;label.scale.setScalar(Math.min(1,height*.5/Math.max(1,b[3]-b[1]),width*.84/Math.max(1,b[2]-b[0])));fitted=true;}
const a=smooth(t/enter),b=smooth((duration-t)/exit);
material.uniforms.u_time.value=t;material.uniforms.u_fade.value=a*b;
const f=smooth(t/(enter*.7))*b;label.fillOpacity=f;label.outlineOpacity=f;
}
function dispose(){quad.geometry.dispose();material.dispose();}
update(0);
return {update,dispose};
縦の帯が真ん中から順に細くなりながら、左右へ開いていきます。
// 絵を縦の10本の帯に分けて、真ん中の帯から順に細くしながら左右へ寄せる
vec3 userMethod(vec2 p, float amount) {
float col = floor(cell.x * 10.); // タイルの位置で帯を決める
float sc = ((col + .5) / 10. - .5) * uSize.x; // 帯の真ん中の x
float d = abs((col + .5) / 10. - .5) * 2.; // 0 = 真ん中、1 = 端
float e = smoothstep((1. - d) * .3, (1. - d) * .3 + .4, amount);
float side = (col < 5.) ? -1. : 1.;
float x = sc + (p.x - sc) * (1. - .8 * e) + side * e * uSize.x * (.25 + .35 * d);
return vec3(x, p.y, e * .3);
}
中心から波紋が広がり、タイルが順に傾いて浮き上がります。
メソッド工房の「画像・立体・物理」のサンプルから選びます。
// 中心から波紋が広がり、タイルが順に傾いて手前へ浮く。強さ(amount)が戻ると元の位置へ帰る
const {createImageTiles,size,smooth}=api;
const tiles=createImageTiles();
const half=size.width*.5;
return {update(frame){
const t=Math.min(1,frame.amount/.8);
for(const piece of tiles){
const x=piece.home.x,y=piece.home.y,r=Math.hypot(x,y)/half;
const k=smooth(Math.max(0,Math.min(1,t*1.6-r*.7))); // 中心のタイルほど早く動く
const a=Math.atan2(y,x);
piece.mesh.position.set(x*(1+.08*k),y*(1+.08*k),k*half*.35);
piece.mesh.rotation.set(-Math.sin(a)*k*1.1,Math.cos(a)*k*1.1,k*(Math.sin(a*3)*.5));
piece.mesh.scale.setScalar(1-.18*k);
}
}};
横に平たく潰れた文字が、ぐにゃっと伸びて縦に弾み、止まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 平たく潰れた文字が、ばねのように伸び縮みしながら元の形に落ち着く。退場は縦に潰れて消える
const {createText,text,width,fontSize,duration}=api;
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,outlineWidth:'2%',outlineColor:'#101819'});
const clamp=x=>Math.max(0,Math.min(1,x));
const enter=Math.min(.8,duration*.3),exit=Math.min(.4,duration*.15);
return {update(t){
const u=clamp(t/enter),b=clamp((duration-t)/exit);
const g=1-Math.exp(-5*u)*Math.cos(12*u); // 0 から1を少し行き過ぎて、1へ落ち着く
const sy=Math.max(.02,g)*b,sx=1+(1-g)*.5*(u<1?1:0); // 縦が伸びる間、横は縮む
label.scale.set(Math.max(.02,sx),sy,1);
const fade=clamp(t/(enter*.25))*b;
label.fillOpacity=fade;label.outlineOpacity=fade;
}};
左上の粒から順に、くるくる回りながら左上の奥へ吹き飛ばされます。
// 左上の粒から順に、回りながら左上へ吹き飛ばされる
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float diag = ((c.x / uSize.x + .5) + (.5 - c.y / uSize.y)) * .5; // 0 = 左上、1 = 右下
float delay = diag * .35 + ph(1.) * .08; // 左上ほど早く動き出す
float e = smoothstep(delay, delay + .3, amount);
l = rot((ph(2.) - .5) * 9. * e) * l * (1. - .25 * e); // 粒ごとにくるくる回る
vec2 q = c + l + vec2(1., -.7) * e * e * (.9 + ph(3.) * 1.2) * uSize.x * .5;
gKind = 2.; // 粒の見た目: 2 = 丸い点
gAlpha = 1. - .25 * e;
gLocal = 1.;
return vec3(q, (ph(4.) - .5) * .4 * e);
}
絵が衝撃波で砕けて飛び散り、文字にぶつかって跳ね、光の粒とともに元へ戻ります。
メソッド工房の「画像・立体・物理」のサンプルから選びます。
// 物理(Rapier)・衝撃波(THREE)・文字(Troika)・GSAPを重ねる。絵が砕けて飛び散り、床と文字で跳ね、光の粒を残して元の絵へ戻る
const {createImageTiles,createText,physics,random,params,size,returnTime,assemble,THREE,gsap,smooth}=api;
const W=size.width,H=size.height,tiles=createImageTiles();
physics.floor(-H*.4);
// 文字は動かない壁として物理に参加し、飛んできたタイルを弾く。登場はGSAPで時刻から決める
const title=createText(params.label,{fontSize:H*.2,color:'#ffffff',outlineColor:'#ff3d81',outlineWidth:.012,fillOpacity:0,outlineOpacity:0});
title.position.set(0,H*.04,.35);
physics.bind(title,{fixed:true,size:[Math.min(W*.8,params.label.length*H*.13+.2),H*.2,.25]});
// 中心に近いタイルほど強く外へ飛ばす
const reach=Math.hypot(W,H)*.5;
for(const piece of tiles){
const x=piece.home.x,y=piece.home.y,r=Math.hypot(x,y),ux=r>.001?x/r:0,uy=r>.001?y/r:1,power=1+2.2*(1-r/reach)+random()*1.2;
physics.bind(piece.mesh,{size:[piece.width*.94,piece.height*.94,.045],velocity:[ux*power,uy*power*.6+1.2,.5+random()*2],angular:[(random()-.5)*6,(random()-.5)*6,(random()-.5)*6]});
}
// 衝撃波: 2本のリングが広がって消える
const ringMaterial=()=>new THREE.MeshBasicMaterial({color:'#ffe08a',transparent:true,opacity:0,blending:THREE.AdditiveBlending,depthWrite:false,side:THREE.DoubleSide});
const rings=[new THREE.Mesh(new THREE.RingGeometry(.94,1,96),ringMaterial()),new THREE.Mesh(new THREE.RingGeometry(.96,1,96),ringMaterial())];
rings[1].rotation.x=1.15;rings[1].material.color.set('#ff7ab8');
for(const ring of rings){ring.position.z=.2;ring.visible=false;api.root.add(ring);}
// 光の粒: 作るときに向きと速さを決め、あとは時刻から位置を求める
const COUNT=240,dir=new Float32Array(COUNT*4),pos=new Float32Array(COUNT*3);
for(let i=0;i<COUNT;i++){const a=random()*Math.PI*2,u=random()*2-1,s=Math.sqrt(1-u*u);dir.set([Math.cos(a)*s,u,Math.sin(a)*s,1.5+random()*4.5],i*4);}
const sparkGeometry=new THREE.BufferGeometry();sparkGeometry.setAttribute('position',new THREE.BufferAttribute(pos,3));
const sparks=new THREE.Points(sparkGeometry,new THREE.PointsMaterial({color:'#fff1b8',size:H*.022,transparent:true,opacity:0,blending:THREE.AdditiveBlending,depthWrite:false}));
sparks.frustumCulled=false;sparks.visible=false;api.root.add(sparks);
// GSAP: 文字が大きく現れて着地し、最後に薄れる
const timeline=gsap.timeline({paused:true});
timeline.fromTo(title.scale,{x:2.6,y:2.6,z:2.6},{x:1,y:1,z:1,duration:.55,ease:'expo.out'},.9)
.fromTo(title,{fillOpacity:0,outlineOpacity:0},{fillOpacity:1,outlineOpacity:1,duration:.2,ease:'none'},.9)
.to(title,{fillOpacity:0,outlineOpacity:0,duration:.6,ease:'power2.in'},4.2);
return {timeline,physicsTime:returnTime,update(frame){
const e=Math.min(1,frame.amount/.8),mix=Math.max(assemble(frame),1-e),q=new THREE.Quaternion();
for(const piece of tiles){piece.mesh.position.lerp(piece.home,mix);piece.mesh.quaternion.slerp(q,mix);}
title.visible=frame.progress>.05&&frame.progress<.95;
// 衝撃波と粒は、強さの立ち上がりの瞬間(progress .06)から数える
const t=Math.max(0,(frame.progress-.06)*frame.duration);
rings.forEach((ring,i)=>{const k=Math.min(1,Math.max(0,(t-i*.18)/1.3)),on=k>0&&k<1;ring.visible=on;if(on){ring.scale.setScalar(H*(.1+(i?3.6:4.8)*smooth(k)));ring.material.opacity=(1-k)*(i?.5:.85);}});
const fade=Math.max(0,1-t/3.6);sparks.visible=t>0&&fade>0&&frame.amount>0;
if(sparks.visible){for(let i=0;i<COUNT;i++){const sp=dir[i*4+3]*t,f=i*3;pos[f]=dir[i*4]*sp;pos[f+1]=dir[i*4+1]*sp-.5*params.gravity*t*t*.25;pos[f+2]=dir[i*4+2]*sp;}
sparkGeometry.attributes.position.needsUpdate=true;sparks.material.opacity=fade*.9;}
// 着地の揺れ
const shake=Math.exp(-t*7)*(t>0?1:0)*.05*H;api.root.position.set(Math.sin(t*61)*shake,Math.cos(t*53)*shake,0);
}};
文字が上から落ちてきて、少し沈んでから静かに止まります。
メソッド工房の「文字モーション」のサンプルから選びます。
// 上から落ちて、わずかに沈み込んでから止まる。退場は上へ戻りながら消える
const {createText,text,width,height,fontSize,duration}=api;
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const label=createText(text,{overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,outlineWidth:'2%',outlineColor:'#101819'});
const clamp=x=>Math.max(0,Math.min(1,x));
const enter=Math.min(.7,duration*.25),exit=Math.min(.4,duration*.15);
const drop=height*.35;
return {update(t){
const a=clamp(t/enter),b=clamp((duration-t)/exit);
const fall=(1-a)*(1-a); // 落ちる間は速くなる
const sink=Math.sin(Math.PI*clamp((a-.7)/.3))*height*.015; // 着地の直前にわずかに沈む
label.position.set(0,fall*drop-sink+(1-b)*drop*.4,0);
const fade=clamp(t/(enter*.4))*b;
label.fillOpacity=fade;label.outlineOpacity=fade;
}};
絵が細かい砂粒になって、上のほうから順にさらさらと落ちていきます。
// 粒が砂になって、上の粒から順に、だんだん速く落ちていく
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float top = c.y / uSize.y + .5; // 0 = 下、1 = 上
float delay = (1. - top) * .3 + ph(1.) * .12; // 上の粒ほど早く落ち始める
float e = smoothstep(delay, delay + .3, amount);
vec2 q = c + l * (1. - .3 * e); // 粒が少し小さな砂になる
q.x += sin(e * 7. + ph(2.) * 6.2832 + uTime * 1.2) * .06 * e + (ph(3.) - .5) * .25 * e;
q.y -= e * e * (1.2 + ph(4.) * 1.8); // 重さで、だんだん速く
gKind = 2.; // 粒の見た目: 2 = 丸い点
gAlpha = 1. - .2 * e;
gLocal = 1.;
return vec3(q, (ph(5.) - .5) * .5 * e);
}
横の帯が一本おきに左右へ、時間差でスライドして流れていきます。
// 絵を8本の横帯に分けて、一本おきに左右へ、ずらしながら滑らせる
vec3 userMethod(vec2 p, float amount) {
float row = floor(cell.y * 8.); // タイルの位置で帯を決める(帯のなかで形が崩れない)
float side = mod(row, 2.) * 2. - 1.; // 帯ごとに 右(+1) と 左(-1)
float delay = hash(vec2(row, 3.)) * .1; // 帯ごとに少しだけ時間差
float e = smoothstep(delay, delay + .7, amount);
return vec3(p.x + side * e * uSize.x, p.y, 0.);
}
タイルが渦を巻いて中心へ寄り、また元の絵へ戻ります。
メソッド工房の「画像・立体・物理」のサンプルから選びます。
// タイルが渦を巻いて中心へ寄る。内側ほど速く回り、強さ(amount)が戻ると元の位置へ帰る
const {createImageTiles,THREE,size}=api;
const tiles=createImageTiles();
const half=size.width*.5;
return {update(frame){
const e=Math.min(1,frame.amount/.8);
for(const piece of tiles){
const x=piece.home.x,y=piece.home.y,r=Math.hypot(x,y)/half;
const turn=e*(3.4/(r+.5)),c=Math.cos(turn),s=Math.sin(turn),pull=1-.5*e*(1-r*.35);
piece.mesh.position.set((x*c-y*s)*pull,(x*s+y*c)*pull,0);
piece.mesh.rotation.set(0,0,turn);
piece.mesh.scale.setScalar(1-.3*e);
}
}};
赤とシアンにずれた像が、本体に重なって落ち着きます。
メソッド工房の「文字モーション」のサンプルから選びます。
// 赤とシアンの像が左右にずれた状態から、本体に重なって落ち着く。退場でもう一度ずれて消える
const {createText,text,width,fontSize,duration}=api;
// 長い歌詞は、行の長さがそろうように折り返す幅を決める(日本語は空白がなく、幅を指定しないと画面の外へ出るため)
const wrapAt=(()=>{const rows=String(text||'').split('\n').map(s=>Array.from(s).reduce((a,c)=>a+(/\s/.test(c)?.32:c.codePointAt(0)<256?.6:1),0)*fontSize),longest=Math.max(1,...rows),limit=width*.9,n=Math.ceil(longest/limit-.02);return n>1?longest/n*1.12:limit;})();
const opts={overflowWrap:'break-word',maxWidth:wrapAt,textAlign:'center',lineHeight:1.25,outlineWidth:'2%'};
const red=createText(text,{...opts,color:'#ff5d73',colorRanges:null,outlineColor:'#ff5d73'});
const cyan=createText(text,{...opts,color:'#4fd8ff',colorRanges:null,outlineColor:'#4fd8ff'});
const label=createText(text,{...opts,outlineColor:'#101819'});
const smooth=x=>{x=Math.max(0,Math.min(1,x));return x*x*(3-2*x)};
const enter=Math.min(.9,duration*.3),exit=Math.min(.5,duration*.18);
return {update(t){
const a=smooth(t/enter),fin=smooth(t/(enter*.45)),b=smooth((duration-t)/exit),apart=(1-a)+(1-b),fade=fin*b; // 出てくるのは速く、ずれが重なるのはゆっくり
const shift=width*.045*apart,ghost=fade*Math.min(1,.2+.8*Math.min(1,apart*1.4));
red.position.set(-shift,shift*.15,0);cyan.position.set(shift,-shift*.15,0);
red.fillOpacity=ghost;cyan.fillOpacity=ghost;red.outlineOpacity=0;cyan.outlineOpacity=0;
label.fillOpacity=fade;label.outlineOpacity=fade;
}};
上から落ちてきて、床に当たったように弾みながら止まります。
歌詞の行の「動き」で「落ちて弾む」を選びます。
文字の動き(かんたん設定)10/7 追加
粒が花びらになって、風に揺れながら手前へ舞い上がります。
// 粒が花びらになり、風に揺れながら手前へ舞う
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC(), l = p - c;
float delay = (c.x / uSize.x + .5) * .22 + ph(1.) * .16;
float e = smoothstep(delay, delay + .4, amount);
float spin = e * (4. + ph(2.) * 6. + uTime * (1. + ph(3.) * 2.));
vec2 leaf = rot(spin) * (l * mix(vec2(1.), vec2(2.4, 3.2), e));
leaf.x *= mix(1., cos(spin * 1.7 + ph(4.) * 6.2832), e);
vec2 q = c + leaf + vec2(e * (1. + ph(5.) * 1.8),
-e * e * (.4 + ph(6.)) + sin(e * 6. + ph(7.) * 6.2832 + uTime * 1.5) * .25 * e);
gKind = 8.; gLocal = 1.;
return vec3(q, (ph(8.) - .5) * 1.4 * e + sin(spin) * .2 * e);
}
中心に近いほど強く回りながら、渦に吸い込まれます。
// 中心に近いほど強く回して、中心へ縮める
vec3 userMethod(vec2 p, float amount) {
float r = length(p);
vec2 q = rot(amount * 3.4 / (r + .35)) * p * (1. - amount * .6 * exp(-r * .5));
return vec3(q, -amount * .9 * exp(-r * r * .7));
}
縦にも横にも、ゆらゆらと波打ちます。
// 縦と横にゆらゆら揺らす
vec3 userMethod(vec2 p, float amount) {
vec2 q = p;
q.x += sin(p.y * 6. + uSeed) * amount * .6;
q.y += sin(p.x * 5. + uSeed) * amount * .48;
return vec3(q, 0.);
}
タイルごとに回りながら、ばらばらに散らばります。
// タイルごとにランダムに回って散らばる(cell はタイルの位置)
vec3 userMethod(vec2 p, float amount) {
float n = hash(cell);
vec2 center = (cell - .5) * uSize;
vec2 q = center + rot((n - .5) * 6.28 * amount) * (p - center) * (1. - amount * .2);
q += vec2(n - .5, hash(cell + 5.) - .5) * 2.0 * amount;
return vec3(q, 0.);
}
画像が小さな粒に分かれて、ちかちか光ります。
// 画像が小さな粒に分かれて、ちかちか光る(partC() は粒の中心)
vec3 userMethod(vec2 p, float amount) {
vec2 c = partC();
vec2 l = p - c;
vec2 q = c + l * (1. + amount * .6) + vec2(ph(1.) - .5, ph(2.) - .5) * .25 * amount;
float z = (ph(3.) - .5) * .6 * amount;
gKind = 4.; // 粒の見た目: 4 = きらめき
gGlow = amount * (.35 + .65 * sin(uTime * (3. + ph(4.) * 5.) + ph(5.) * 6.2832)) * .9;
gLocal = 1.; // 粒ごとの形を使う
return vec3(q, z);
}
タイルが傾きながら手前に飛び出して、奥行きが生まれます。
// タイルが手前に飛び出す。傾きと拡縮で正投影でも奥行きが見える
vec3 userMethod(vec2 p, float amount) {
vec2 c = (cell - .5) * uSize;
float n = hash(cell);
float e = smoothstep(n * .18, .65 + n * .15, amount);
vec2 l = rot((n - .5) * e * 1.2) * (p - c) * (1. - .3 * e);
float tilt = (hash(cell + 3.) - .5) * 2.3 * e;
vec2 q = c * (1. + .18 * e) + vec2(l.x, l.y * cos(tilt));
q += vec2((n - .5) * .18, (hash(cell + 7.) - .5) * .25) * e;
return vec3(q, l.y * sin(tilt) + (.25 + n * n * 1.7) * e);
}
下から少し浮かびながら、ふわっと現れます。
歌詞の行の「動き」で「ふわっと」を選びます。
文字の動き(かんたん設定)10/4 追加
選んだ方向から、すっと流れ込んできます。
歌詞の行の「動き」で「流れ込む」を選びます。
文字の動き(かんたん設定)10/4 追加
文字が一つずつ、順番に現れます。
歌詞の行の「動き」で「一文字ずつ」を選びます。
文字の動き(かんたん設定)10/4 追加
ぽんと大きくなってから、弾んで落ち着きます。
歌詞の行の「動き」で「弾む」を選びます。
文字の動き(かんたん設定)10/4 追加
*強調*した言葉が、拍に合わせてどくんと膨らみます。
歌詞の行の「動き」で「強調」を選びます。
文字の動き(かんたん設定)10/4 追加