TECH PLAY

Laravel

むベント

マガゞン

技術ブログ

はじめたしお。5月にM&Aクラりドぞ入瀟した、峯岞です。 既に1ヶ月が経過したしたが、入瀟゚ントリを曞いおいきたす。 たずは軜く自己玹介を。 神奈川県出身で、新卒からバック゚ンド゚ンゞニアずしお働き始め、珟圚4幎目です。 趣味はゲヌムで、モンスタヌハンタヌのような協力プレむができるゲヌムが特に奜き。 意倖かもしれたせんが、折り玙もたたにやりたす。小孊生の頃に熱䞭しおいた名残で、今では蚘憶をたどりながら䜜るこずがある皋床ですが。 前職に぀いお 以前はむベント系の䌚瀟に3幎ほど圚籍しおおり、LaravelやCakePHP等のPHPフレヌムワヌクを䞻に甚いたプロダクト開発・案件開発を行っおいたした。 
はじめに こんにちは。リテヌルハブ開発郚小売アプリチヌムの池です。 業務で Laravel Octane のメモリが残る挙動に぀いお調査する機䌚がありたした。 Laravel Octane は、長時間皌働するプロセス䞊で Laravel アプリケヌションを動かしお高速化するツヌルです。䟿利な䞀方で、プロセスが長く生きるためメモリが残り続け、曞き方次第ではリク゚スト間で状態が匕き継がれおしたうずいう、埓来の Nginx + PHP-FPM 構成の Laravel では発生しにくい特性を持っおいたす。この特性を理解せずに䜿うず予期しない事故に぀ながる可胜性があるず感じたした。 そこで本蚘事では、Octane + Swoole の仕組みを敎理した䞊で、サンプルプログラムで挙動を怜蚌し、Worker プロセスが垞駐するこずに起因しお気を぀けるべきポむントに぀いお敎理したいず思いたす。 Laravel および Octane に぀いお倚少の知識がある方を前提に曞いおおり、Laravel 本䜓の解説等には觊れたせん。 なお、本蚘事の内容は䞀次情報から確認するように努めおいたすが、私の理解違いや Octane / Swoole のバヌゞョン差による挙動の違いが含たれおいる可胜性がありたす。誀っお実装するず事故に぀ながり埗る領域でもあるため、最終的にはご自身で゜ヌスコヌドや公匏ドキュメントをご確認の䞊で適甚ください。 仕組みの敎理 この章では、Octane + Swoole で前のリク゚ストの情報が次のリク゚ストに残る仕組みを、次の 4 ぀の芳点から敎理したす。 Octane + Swoole では Worker プロセスが垞駐するため、PHP プロセス内のメモリがリク゚ストごずに初期化されない Octane はリク゚ストごずに $this->app を clone しお $sandbox 䞊で凊理するこずで、ベヌスのアプリむンスタンスを盎接曞き換えないようにしおいる ただし PHP の clone はシャロヌコピヌなので、共有されたオブゞェクトの内郚状態はリク゚スト間で残り埗る Octane は RequestReceived むベントに玐づくリスナヌ矀でフレヌムワヌク偎の状態をリセットしおいる 本蚘事に登堎するアヌキテクチャ図やラむフサむクル図は必芁な芁玠に絞っお簡略化しおいたす。 1. Worker プロセスが垞駐するため、メモリは初期化されない Octane + Swoole は、長時間生きる PHP プロセスを立ち䞊げる仕組みです。 Swoole のプロセスアヌキテクチャ Worker は OS プロセスです。Manager から耇数生成され、䞀定数のリク゚ストを凊理するか停止シグナルを受信するたで走り続けたす。 Worker のラむフサむクル 長時間生き続ける Worker プロセスは次のように動きたす。 泚目したいのは、1 ぀の Worker プロセスが生き続けたたた耇数のリク゚ストを順に凊理し続ける構造です。Worker 起動時に 1 回だけ Laravel アプリケヌションを組み立おお $this->app に保持し、その埌はリク゚ストのたびにこの $this->app を clone しお $sandbox ずしお䜿い回したす。この「同じ $this->app が耇数リク゚ストにたたがっお䜿われる」点が、埌で芋る「前のリク゚ストの情報が次のリク゚ストに残っおしたう」仕組みの䞀郚になっおいたす。 Worker 起動時に Laravel アプリケヌションを組み立おおいる凊理 Worker::boot() の実装は以䞋の通りです。 <?php // vendor/laravel/octane/src/Worker.php public function boot ( array $ initialInstances = []) : void { // ベヌスずなる Laravel アプリむンスタンスを1぀生成 // 以降、リク゚ストのたびにここから clone する $ this -> app = $ app = $ this -> appFactory -> createApplication ( ... ) ; $ this -> dispatchEvent ( $ app , new WorkerStarting ( $ app )) ; } なぜメモリが残るのか Swoole + Octane では以䞋の仕組みでメモリが残りたす。 Worker プロセスが生き続けるため、リク゚スト終了時に倉数・オブゞェクトに割り圓おられたメモリが解攟されない 結果ずしお、 $this->app を含む倉数・オブゞェクトがリク゚ストをたたいでメモリに残り続ける ここから先は、この「メモリに残り続ける $this->app をリク゚ストごずにどう扱うか」ずいうこずに焊点を圓おたす。 2. Octane はリク゚ストごずに $this->app を clone する Worker が垞駐すれば $this->app も残りたす。ただ、リク゚スト凊理の䞭で $this->app を盎接曞き換えおしたうず、その倉曎が次のリク゚ストにそのたた残りたす。Octane はこれを避けるために、リク゚ストのたびに $this->app を clone しお $sandbox を䜜り、その䞊でリク゚スト凊理を回す蚭蚈になっおいたす。 <?php // vendor/laravel/octane/src/Worker.php public function handle ( Request $ request , RequestContext $ context ) : void { // アプリむンスタンスを clone しおリク゚スト甚の sandbox を䜜る CurrentApplication :: set ( $ sandbox = clone $ this -> app ) ; $ gateway = new ApplicationGateway ( $ this -> app, $ sandbox ) ; try { $ response = $ gateway -> handle ( $ request ) ; // ... レスポンス返华 ... } finally { $ sandbox -> flush () ; // sandbox 偎の bindings クリア unset ( $ gateway , $ sandbox , ... ) ; CurrentApplication :: set ( $ this -> app ) ; // 元のアプリむンスタンスに戻す } } ここでの clone の圹割は、リク゚スト凊理を $sandbox 偎に隔離しお、ベヌスの $this->app を盎接曞き換えないようにするこずです。 $this->app 自䜓は Worker 寿呜たでずっず生き続けたすが、毎リク゚ストの凊理が $this->app を盎接曞き換えなければ、結果ずしおベヌスを倉曎せずに䜿い回せる、ずいう蚭蚈になっおいたす。 3. clone はシャロヌコピヌなので、内郚状態はリク゚スト間で残り埗る ただし、PHP の clone はシャロヌコピヌであるため、前節の「ベヌスを曞き換えない」が成り立぀範囲には限界がありたす。 Application オブゞェクト自䜓は新芏リク゚ストごずの噚 配列プロパティ bindings / instances などはコピヌされるsandbox 偎で曞き換えおも元には反映されない 配列の䞭身実際のオブゞェクトは元のアプリ $this->app ず $sandbox で共有される これにより、以䞋のような挙動になりたす。 䟋えばリク゚スト䞭に app()->instance('request', $req) のように差し替えおも、 $sandbox 偎にのみ反映され、ベヌスの $this->app には反映されない 䞀方で、ベヌスの $this->app に登録された解決枈み singleton むンスタンスは䞡者で共有されたたた 「singleton にリク゚スト固有デヌタを入れるず次のリク゚ストにも残る」ずいう珟象は、この「clone しおもオブゞェクト自䜓は共有される」こずが芁因ず考えられたす。 clone はコンテナの配列レベルでの隔離は提䟛するものの、配列の䞭に入っおいるオブゞェクトの内郚状態たでは守っおくれない、ずいうのがポむントです。 4. Octane の RequestReceived リスナヌが䞀郚の状態をリセットする clone だけでは配列の䞭に入っおいるオブゞェクトのプロパティ曞き換えを防げないため、Octane はそこを、リク゚ストごずに発火するむベントずそれに玐づくリスナヌで、明瀺的に状態をリセットするこずで補っおいたす。 Worker のメむンルヌプ Worker のメむンルヌプの䞭では RequestReceived むベントが発火し、デフォルトで 8 個のリスナヌを順に実行しおから HTTP Kernel に凊理を枡したす。 Worker 起動 ↓ WorkerStarting むベント ↓ ┌── メむンルヌプ ─────────────────────────────────────────┐ │ RequestReceived むベント ─→ [8 listeners] │ │ ├─ FlushLocaleState │ │ ├─ FlushQueuedCookies │ │ ├─ FlushSessionState │ │ ├─ FlushAuthenticationState │ │ ├─ EnforceRequestScheme │ │ ├─ EnsureRequestServerPortMatchesScheme │ │ ├─ GiveNewRequestInstanceToApplication │ │ └─ GiveNewRequestInstanceToPaginator │ │ ↓ │ │ HTTP Kernel (Middleware → Controller) │ │ ↓ │ │ リク゚スト終了 │ └─────────────────────────────────────────────────────────┘ ↓ max_requests に達したら Worker 再起動 これら 8 個のリスナヌは、Locale / Cookie / Session / Auth ずいったフレヌムワヌク状態のリセットや、 app('request') の差し替え、HTTPS スキヌムやポヌトの敎合性チェックなどを担いたす。 実装の䞭でも、特に挙動を把握しおおきたい 2 ぀を確認したす。 たず、認蚌ガヌドを毎リク゚スト砎棄する FlushAuthenticationState を芋おみたす。 Laravel の AuthManager は内郚で Guard むンスタンスを $guards 配列にキャッシュし、各 Guard はさらに認蚌枈みナヌザヌをプロパティに保持したす。Octane でこのむンスタンスをクリアしないず、 singleton で起きる珟象ず同じ構造で、前のリク゚ストの認蚌ナヌザヌが次のリク゚ストに匕き継がれおしたいたす。 FlushAuthenticationState は、このキャッシュを毎リク゚スト砎棄するこずで、前のリク゚ストの情報が次のリク゚ストに残らないようにしおいたす。 実装は以䞋の通りです。 <?php // vendor/laravel/octane/src/Listeners/FlushAuthenticationState.php class FlushAuthenticationState { public function handle ( $ event ) : void { if ( $ event -> sandbox -> resolved ( 'auth.driver' )) { $ event -> sandbox -> forgetInstance ( 'auth.driver' ) ; } if ( $ event -> sandbox -> resolved ( 'auth' )) { with ( $ event -> sandbox -> make ( 'auth' ) , function ( $ auth ) use ( $ event ) { $ auth -> setApplication ( $ event -> sandbox ) ; $ auth -> forgetGuards () ; }) ; } } } auth がコンテナで解決枈みの堎合に forgetGuards() を呌びだし、Guards のキャッシュをクリアしおいるこずがわかりたす。 次に、Request むンスタンスを差し替える GiveNewRequestInstanceToApplication の実装は以䞋の通りです。 <?php // vendor/laravel/octane/src/Listeners/GiveNewRequestInstanceToApplication.php class GiveNewRequestInstanceToApplication { public function handle ( $ event ) : void { $ event -> app -> instance ( 'request' , $ event -> request ) ; $ event -> sandbox -> instance ( 'request' , $ event -> request ) ; } } app('request') を新しい Request むンスタンスに差し替えたす。 app('request') を呌ぶコヌドが垞に最新の Request を芋られるのは、このリスナヌの働きによるものず理解できたす。 䜕が残っお、䜕が消えるのか 2 ぀のレむダヌに分けお敎理したす レむダヌ 䜕が垞駐するか リセット手段 Worker プロセス党䜓 Worker プロセス自䜓 + 関数テヌブル / クラステヌブル / static 倉数 / グロヌバル倉数 octane:reload / max_request 到達による Worker 再起動 Laravel Application ( $this->app ) bindings、singleton むンスタンス、boot 枈み ServiceProvider RequestReceived リスナヌ郚分的 なお、各 Worker は独立した OS プロセスであるため、Worker 間のメモリは分離されおいたす。コルヌチン無効時には同じ Worker 内のリク゚ストも順次凊理されるため、気にすべきは「同じ Worker の䞭で、前のリク゚ストのデヌタが次のリク゚ストに匕き継がれおしたわないか」ずいう点に絞られるず考えられたす。 そのうえで、Laravel アプリコヌド䞊でよく䜿う状態保持の方法ごずに、同じ Worker・別リク゚ストでどう振る舞うかを敎理するず次のようになりたす。 状態保持の方法 同 Worker・別リク゚スト static 倉数 残る グロヌバル倉数 / $GLOBALS 残る Worker boot 時点などで解決枈みの singleton むンスタンスのプロパティ 残る 通垞 bind (毎回新芏) 毎回新芏 scoped バむンディング 次のラむフサむクル開始時に flush $request->attributes Request 自䜓が新芏生成 「残る」ずなっおいる static / グロヌバル倉数 / singleton プロパティは、リク゚スト固有のデヌタや、リク゚ストごずに増え続けるデヌタを眮くず事故に぀ながり埗る点に泚意が必芁です。アプリ起動時に 1 床だけ初期化されるような䞍倉なデヌタや、Worker 内で意図的に共有したいデヌタを眮く分には問題ないず考えおいたす。 ここたでは仕様䞊こうなっおいるはず、ずいう敎理でした。次は簡易的なプログラムで動䜜を怜蚌したす。 怜蚌 仕組みの敎理で瀺した芳点を、実機で順に確認しおいきたす。 static 倉数 / グロヌバル倉数がリク゚スト間で残るこず $this->app の内郚状態singleton むンスタンスのプロパティがリク゚スト間で残るこず RequestReceived リスナヌが䞀郚の状態を実際にリセットするこず 加えお、これらの怜蚌が成立する前提ずしお「同 Worker 内ではリク゚ストが順次凊理される」こずを最初に確認したす。 怜蚌環境 怜蚌は macOS 䞊のロヌカル Docker コンテナで、以䞋のバヌゞョン構成で行いたす。 ゜フトりェア バヌゞョン Laravel 12.59.0 Octane 2.17.3 Swoole 6.2.1 Octane は次のコマンドで起動したす。 php artisan octane:start --server=swoole --workers=10 --max-requests=500 このずき内郚で適甚される䞻な Swoole オプションは次の通りです。 蚭定 倀 由来 enable_coroutine false Octane デフォルトLaravel 本䜓がコルヌチンセヌフでないため意図的に無効化 worker_num 10 --workers=10 で指定 max_request 500 --max-requests=500 で指定メモリリヌク察策 特別な蚭定はしおおらず、Octane / Swoole のデフォルトのたたです。 怜蚌甚ルヌトは routes/web.php に甚意し、curl で叩いお結果を芳察したす。 前提の確認: 同 Worker 内でリク゚ストが順次凊理されるこず たず倧前提ずしお、1 Worker 内では耇数リク゚ストが䞊行凊理されず、1 ぀ず぀順番に凊理されるこずを確認したす。これ以降の怜蚌はすべお「同じ Worker に来た耇数リク゚ストが順番に凊理される」ずいう前提で議論を組み立おるため、確認したす。 確認甚コヌド リク゚ストを受け取ったら 2 秒スリヌプしお PID ずコルヌチン ID を返すだけの単玔なルヌトを甚意したす。 <?php Route :: get ( '/test-coroutine' , function () { $ pid = getmypid () ; $ cid = \Swoole\Coroutine :: getCid () ; sleep ( 2 ) ; return [ 'pid' => $ pid , 'cid' => $ cid , 'is_coroutine' => $ cid !== - 1 , ] ; }) ; 実行 Worker 数を 1 に絞った状態 --workers=1 で、3 リク゚ストを䞊列で投げたす。もし䞊行凊理されるなら合蚈 2 秒前埌で完了するはずです。 start= $( date +%s ) curl -s http://localhost:8001/test-coroutine > /tmp/r1 & curl -s http://localhost:8001/test-coroutine > /tmp/r2 & curl -s http://localhost:8001/test-coroutine > /tmp/r3 & wait echo " Total: $(( $ ( date +%s ) - start )) s " 結果 { " pid ": 14 ," cid ": -1 ," is_coroutine ": false } { " pid ": 14 ," cid ": -1 ," is_coroutine ": false } { " pid ": 14 ," cid ": -1 ," is_coroutine ": false } Total : 6s 3 リク゚ストの PID が䞀臎=14し、 cid = -1 でコルヌチン倖、合蚈時間が 2 秒×3  6 秒ずなっおいるこずから、1 Worker 内ではリク゚ストが䞊行ではなく順次凊理されるこずが確認できたした。 怜蚌 1: static 倉数 / グロヌバル倉数がリク゚スト間で残るこず 仕組みの敎理で「static 倉数は同 Worker 内では持続する」ず曞きたした。これを実際に確かめたす。 確認甚コヌド <?php Route :: get ( '/test-static' , function () { static $ counter = 0 ; $ counter ++ ; $ GLOBALS [ 'global_counter' ] = ( $ GLOBALS [ 'global_counter' ] ?? 0 ) + 1 ; return [ 'pid' => getmypid () , 'static_counter' => $ counter , 'global_counter' => $ GLOBALS [ 'global_counter' ] , ] ; }) ; static 倉数ずグロヌバル倉数の䞡方をむンクリメントするシンプルなコヌドです。 実行 たず、同 Worker 内挙動を芋るため Worker 数 1 --workers=1 で順次 10 リク゚ストを投げたす。 for i in { 1 .. 10 } ; do curl -s http://localhost:8001/test-static echo done 次に、Worker 数 10 --workers=10 で䞊列 20 リク゚ストを投げ、Worker 間の独立性を確認したす。 for i in { 1 .. 20 } ; do curl -s http://localhost:8001/test-static > /tmp/s_ $i & done wait for i in { 1 .. 20 } ; do cat /tmp/s_ $i ; echo; done | sort 結果 順次 10 リク゚スト --workers=1  { " pid ": 14 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 14 ," static_counter ": 2 ," global_counter ": 2 } { " pid ": 14 ," static_counter ": 3 ," global_counter ": 3 } ... { " pid ": 14 ," static_counter ": 10 ," global_counter ": 10 } 䞊列 20 リク゚スト --workers=10  { " pid ": 22 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 22 ," static_counter ": 2 ," global_counter ": 2 } { " pid ": 23 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 23 ," static_counter ": 2 ," global_counter ": 2 } { " pid ": 24 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 24 ," static_counter ": 2 ," global_counter ": 2 } ... { " pid ": 31 ," static_counter ": 1 ," global_counter ": 1 } { " pid ": 31 ," static_counter ": 2 ," global_counter ": 2 } 順次 10 リク゚ストでは党お同じ PID=14で、 static_counter ず global_counter が 1 から 10 たで連続しお増加しおいたす。同 Worker 内では static / グロヌバル倉数が持続しおいるこずが確認できたす 䞊列 20 リク゚ストでは 10 個の異なる PID22 〜 31にリク゚ストが分散し、それぞれの Worker でカりンタが独立に 1 から始たっおいたす。Worker 間でメモリが分離されおいるこずが分かりたす 以䞊から、 static 倉数ずグロヌバル倉数は同 Worker 内のリク゚スト間で持続し、Worker 間では分離されるこずが確認できたした。 これは、1 リク゚スト目の倀が 2 リク゚スト目に意図せず芋えおしたう可胜性を意味したす。前のリク゚ストの情報が次のリク゚ストに残るパタヌンず考えられるため、利甚には泚意が必芁そうです。 怜蚌 2: $this->app の内郚状態がリク゚スト間で残るこず singleton の䞭にリク゚スト固有のデヌタを保持しお、リク゚スト間で倀が残るこずを確認したす。 確認甚コヌド <?php // app/Services/UserContextSingletonService.php class UserContextSingletonService { private ? string $ currentUserName = null ; public function setCurrentUser ( string $ name ) : void { $ this -> currentUserName = $ name ; } public function getCurrentUser () : ? string { return $ this -> currentUserName; } } <?php // app/Providers/AppServiceProvider.php public function register () : void { $ this -> app -> singleton ( UserContextSingletonService :: class ) ; } public function boot () : void { // ベヌスの $this->app->instances にむンスタンスを栌玍するため、boot 時点で resolve する $ this -> app -> make ( UserContextSingletonService :: class ) ; } 通垞の singleton() だけでは、初回 app(...) 解決時にむンスタンスが sandbox 偎に入り、リク゚スト終了時の $sandbox->flush() で砎棄されたす。リク゚スト間で持続する状態を再珟するため、サンプルでは boot() で make() を呌んでベヌスの $this->app->instances にむンスタンスを積んでいたす。 <?php use App\Services\UserContextSingletonService; Route :: get ( '/test-singleton-set/{name}' , function ( string $ name ) { app ( UserContextSingletonService :: class ) -> setCurrentUser ( $ name ) ; return [ 'pid' => getmypid () , 'action' => 'SET' , 'value' => app ( UserContextSingletonService :: class ) -> getCurrentUser () , ] ; }) ; Route :: get ( '/test-singleton-get' , function () { return [ 'pid' => getmypid () , 'action' => 'GET' , 'value' => app ( UserContextSingletonService :: class ) -> getCurrentUser () , ] ; }) ; 実行 curl http://localhost:8001/test-singleton-set/Alice curl http://localhost:8001/test-singleton-get curl http://localhost:8001/test-singleton-get 結果 { " pid ": 14 ," action ":" SET "," value ":" Alice " } { " pid ": 14 ," action ":" GET "," value ":" Alice " } ← 別リク゚ストなのに残っおいる { " pid ": 14 ," action ":" GET "," value ":" Alice " } ← ただ残っおいる 別のリク゚ストにもかかわらず、Alice ずいう倀がそのたた芋えおいたす。 singleton バむンディングのむンスタンスプロパティに栌玍した倀が残るこずが確認できたした。 怜蚌 3: RequestReceived リスナヌが䞀郚の状態をリセットするこず FlushAuthenticationState リスナヌが Guard キャッシュを砎棄しおいるこずを確かめたす。 確認甚コヌド <?php use App\Models\User; use Illuminate\Support\Facades\Auth; Route :: get ( '/test-auth-set/{name}' , function ( string $ name ) { $ user = new User ([ 'name' => $ name ]) ; Auth :: setUser ( $ user ) ; return [ 'pid' => getmypid () , 'action' => 'SET' , 'user_name' => Auth :: user () ?-> name , ] ; }) ; Route :: get ( '/test-auth-get' , function () { return [ 'pid' => getmypid () , 'action' => 'GET' , 'user_name' => Auth :: user () ?-> name , ] ; }) ; Auth::setUser() を利甚しおデフォルトの Guardの $user プロパティに盎接 User むンスタンスを入れ、Guard キャッシュの状態を䜜っお怜蚌したす。 実行 (1) デフォルト構成 FlushAuthenticationState 有効 curl http://localhost:8001/test-auth-set/Alice curl http://localhost:8001/test-auth-get 結果は以䞋の通りです。 { " pid ": 14 ," action ":" SET "," user_name ":" Alice " } { " pid ": 14 ," action ":" GET "," user_name ": null } ← flush で消えおいる 実行 (2) FlushAuthenticationState を倖した堎合 怜蚌のため、 RequestReceived リスナヌから FlushAuthenticationState を陀倖したす。 <?php // config/octane.php listeners 郚分のみ抜粋 use Laravel\Octane\Events\RequestReceived; use Laravel\Octane\Listeners\FlushAuthenticationState; use Laravel\Octane\Octane; return [ // ... 'listeners' => [ // ... RequestReceived :: class => array_values ( array_filter ( Octane :: prepareApplicationForNextRequest () , fn ( $ listener ) => $ listener !== FlushAuthenticationState :: class , )) , // ... ] , ] ; 同じ curl を実行したす。 { " pid ": 15 ," action ":" SET "," user_name ":" Alice " } { " pid ": 15 ," action ":" GET "," user_name ":" Alice " } ← 前のリク゚ストの情報が残っおいる リク゚ストをたたいで Alice ずいう倀が残っおいたす。 怜蚌結果から、 FlushAuthenticationState を倖すず認蚌状態がリク゚スト間で残り、有効な堎合は Guard キャッシュが毎リク゚スト砎棄されるこずが確認できたした。フレヌムワヌク暙準の Auth が Octane でも安党に䜿えるのは、このリスナヌが裏で動いおいるからこそだず蚀えたす。 おわりに 本蚘事では、Octane + Swoole で前のリク゚ストの情報が次のリク゚ストに残る仕組みを敎理し、サンプルコヌドで動䜜を怜蚌したした。その結果、 clone ずリスナヌによっお Auth などのフレヌムワヌク偎の状態はリク゚ストごずにクリアされる䞀方、 static 倉数 / グロヌバル倉数や、Worker boot 時に解決枈みの singleton むンスタンスのプロパティは自動ではクリアされないこずが確認できたした。 持続させおはいけない堎所に状態を眮かないこず、たた、もし独自のグロヌバル状態を持たせる堎合には RequestReceived などのむベントに自前のリスナヌを远加しおクリアするこずを意識できればず思いたす。 最埌たで読んでいただきありがずうございたした。 参考 Deep Dive into Laravel OctaneAlbert Chen
はじめに NTT西日本の䞭川 拓哉です。 本蚘事では、Three.jsWebGLをラップするJavaScriptラむブラリで春倏秋冬を衚すサンプルを䜜り、各シヌンで䜿っおいるWebGL/Three.jsの機胜を解説したす。 本蚘事は2026幎4月時点の情報に基づきたす。 Webの特にフロント゚ンドに携わりたいず思う人の倚くは、たず芋た目の動く画面の魅力を感じおフロント゚ンドに関わりたいず思われたのではないでしょうか。 フロント゚ンドはデヌタの管理なども受け持぀こずが倚いですが、なんず蚀っおもリッチな芋た目やぬるぬる動く動䜜が最倧の魅力の䞀぀です。 私自身、ぬるぬる動く芋た目からフロント゚ンドに魅力を感じた䞀人です。 今回ご玹介するThree.jsは「3Dが描けるラむブラリ」です。ゲヌムなども䜜成でき、非垞に倚圩な䜿い方ができたすが、個人的には、UIや可芖化に 季節感・空気感 を少ない芁玠で足すための“衚珟”ずしお䜿う堎面が倚いです。季節的なプロモヌションサむトの背景や、䞀぀の商品を倚角的なむメヌゞで䌝えたいランディングペヌゞなどです。この蚘事では、WebGLの利甚むメヌゞを掎んでいただくための入口ずしお四季を題材にし぀぀、各ポむントに぀いお解説しおいきたす。 四季サンプルの完成むメヌゞ春・倏・秋・冬 察象読者 本蚘事が想定する察象読者は以䞋の通りです。 本蚘事では、「ブレンディング」や「フォグ」ずいった聞きなれない甚語が出おきたすが、Webで3Dを衚珟するにあたっおは専門的な甚語が倚数存圚したす。䞀぀䞀぀を解説しおいるず甚語の解説蚘事になっおしたうため、この蚘事は䞀定の知識がある方を前提ずしおいたす。 フロント゚ンドのリッチな衚珟を詊しおみたい方 3D描画に぀いお䞀定の知識がある方 Webサむトに「立䜓的な挔出」を入れたい方 目次 はじめに 察象読者 目次 1.背景・目的 2.前提条件・動䜜環境 2.1 うたく動かないずきの最短チェック 2.2 簡単な語句の説明 3.たず動かす四季サンプルの党䜓像 3.1 サンプルの構成Input / Update / Render 4.春桜の降る景色パヌティクル + アルファブレンディング 4.1 利甚する䞻な機胜 4.2 桜っぜく芋せるコツ圢状 + 動き サンプル䜜成で苊劎したこず 4.3 ぀たずきポむント透明衚珟・深床 4.4 カスタマむズする堎合のポむント 5.倏花火の打ち䞊げ打ち䞊げ + 加算合成 + バヌスト 5.1 利甚する䞻な機胜 5.2 「打ち䞊がり」を芋せるロケット + 軌跡 サンプル䜜成で苊劎したこず 5.3 泚意点加算は“䜿い過ぎるず癜飛び”する 5.4 カスタマむズする堎合のポむント 6.秋玅葉の景色InstancedMesh + 颚の揺れ サンプル䜜成で苊劎したこず 6.1 利甚する䞻な機胜 6.2 泚意点 6.3 コヌドのポむントInstancedMeshの曎新 6.4 カスタマむズする堎合のポむント 7.冬雪景色フォグ + ラむティング + パヌティクル サンプル䜜成で苊劎したこず 7.1 利甚する䞻な機胜 7.2 コヌドのポむントFogず雪の芋せ方 7.3 カスタマむズする堎合のポむント 8.たずめ 付録フルセットのコヌド 執筆者 参考資料・出兞 商暙 1.背景・目的 Webサむトやデモに倧きな動きを入れようずするず、掟手に䜜ろうずするほど実装が砎綻しがちです。䞀方で、衚珟を絞っお「それっぜさ」を出すこずに甚途を絞るず、短い時間で䜓隓を底䞊げできたす。 本蚘事の目的は、四季の衚珟を題材にしお、以䞋の2点を満たすこずです。 できるだけ小さな仕組み Points/InstancedMesh/Fog/Blending などで挔出を䜜る 䜕を有効にするず䜕が起きるか WebGLの機胜を説明できるようになる 2.前提条件・動䜜環境 Three.js: 0.164.1 今回のサンプルでの利甚バヌゞョンCDNで読み蟌み 起動方法: file:// ではなく http://localhost などで配信するVS CodeのLive ServerやApache HTTP Server環境など 察応ブラりザ: Chromiumç³» / Firefox / Safari など、WebGLが有効な環境端末差が出る堎合がありたす 補足 : Three.jsやWebGLは非垞に奥深い技術です。本蚘事のサンプルは、抂芁レベルの説明であるため、倖郚画像の利甚はせず、テクスチャもCanvasで生成したす。 利点ずしお、ネットワヌクが䞍安定な環境でも再珟しやすいですが、画像を利甚しない為、リアルな衚珟ではないこずをご留意ください。 2.1 うたく動かないずきの最短チェック この手の蚘事は、コヌドの蚘述よりも前にそもそも「動かし方・開き方」で぀たずくこずがありたす。 私自身も最初は Three.jsのコヌドより前に、実行環境で止たったり詰たったりするこずが䜕床かありたした。そのため、この蚘事ではできるだけ簡単に描画を䜓隓しおいただけるような構成にしおいたすので、ぜひいろいろ詊しおみおください。 実行環境に関しおは、たずは次の3点だけ芋れば十分です。 file:// で開いおいないか原則ずしお http://localhost などで開く 画面が真っ黒なたたかコン゜ヌル゚ラヌずWebGLの有効/無効を確認する 動くが動䜜が重いなどがないかたず描画の数やむンスタンス数を半分にしお、差が出るかを芋る 「たず描画の数やむンスタンス数を半分にしお、差が出るかを芋る」は、地味ですが䞀番効く切り分けです。 2.2 簡単な語句の説明 板ポリ板ポリゎン平面のポリゎン1枚に、透明テクスチャを貌ったもの Fogフォグ3D描画で「遠いものほど霞んで芋える」ようにする霧・空気遠近法の衚珟 3.たず動かす四季サンプルの党䜓像 本蚘事は この蚘事の䞭だけで完結 するように、サンプルコヌドもすべお本文内に掲茉したす。 起動方法の詳现は前節の通りです。ロヌカルサヌバヌ経由で開いおください。 画面巊䞊の切り替えで、春桜・倏花火・秋玅葉・冬雪を切り替えられたす。 季節切り替えUI 以降はポむントだけコヌド抜粋し、フルコヌドは最埌にたずめたす。 3.1 サンプルの構成Input / Update / Render 構成は、実務でも䜿えるように次のような構成にしおいたす。 Input : ボタン操䜜季節切り替えを状態に萜ずす Update : 季節ごずの「状態曎新」 Render : renderer.render(scene, camera) は基本1行重い凊理を集䞭させない 最初から党郚の凊理を远うより、以䞋のように 䞀぀の季節ず぀その季節ごずの凊理の特城を理解しながら詊す ほうが党䜓的に理解しやすいです。 春の桜透明・深床のクセが分かりやすい 倏の花火ロケット、軌跡、バヌストで「挔出の組み立お」が分かりやすい 秋の玅葉 InstancedMesh の実務的な掻甚法が分かりやすい 冬の雪Fogずラむトだけでも十分に挔出できるこずが分かりやすい 「党郚を理解する」ずいうより、「この季節のこの芋え方は自分の案件でも䜿えそう」ずいうようにいずれかの技術に泚力した方が3D系は理解しやすいず思いたす。 4.春桜の降る景色パヌティクル + アルファブレンディング 春は、萜ちる粒を“桜の花びら”に芋せられるず、䞀気に季節感が出やすいです。 ただし THREE.Points は「点スプラむト」なので、調敎が甘いず ピンクの雪 っぜくなっおしたいたす。そこで本サンプルでは、桜だけは InstancedMesh 板ポリで花びらを描くようにしおいたす。 ※䜙談ですが、こういった詊行錯誀も3D描画の衚珟の楜しみだず個人的には感じたす。 ※ InstancedMesh はThree.jsのクラスずなりたす。 Three.js - InstancedMesh  4.1 利甚する䞻な機胜 BufferGeometry : 粒の䜍眮・速床などをTypedArrayで持぀曎新が速い 透明alpha : アルファ付きテクスチャで花びら圢に芋せる Blending : NormalBlending 自然に重なる InstancedMesh : 花びらを“板ポリ”ずしお倧量描画する回転が絵に乗る 4.2 桜っぜく芋せるコツ圢状 + 動き 「ピンクの雪」に芋える堎合、原因は、次の2぀が倚いです。 圢が䞞すぎる 粒に芋えるず雪に芋えがちです 動きが単調すぎる 雪の萜䞋に近い速床になっおしたっおいる 察策ずしお、(1) 花びら圢のテクスチャを䜜り、(2) 板ポリを回転させながら萜ずしたすひらひら感を出したす。 サンプル䜜成で苊劎したこず 最初は Points にピンクの円圢テクスチャを圓おお「これで桜っぜいはず」ず思っおいたしたが、実際に動かすずほが雪でした。 詊行錯誀しお、決定的な原因だなず思ったのは “圢”ほが䞞 ず “回転が絵に乗るか” でした。 そのため、桜だけ InstancedMesh に切り替えるこずで察策したした。 テクスチャ偎も、茪郭を少し入れお「䞞」ではない圢にするだけで、印象がかなり倉わりたすので、是非色々詊しおみおください。 function makePetalTexture () { const c = document . createElement ( 'canvas' ) ; c . width = 64 ; c . height = 64 ; const g = c . getContext ( '2d' ) ; g . clearRect ( 0 , 0 , 64 , 64 ) ; g . translate ( 32 , 32 ) ; g . rotate ( -0 . 25 ) ; const base = g . createRadialGradient ( 0 , -6 , 2 , 0 , -6 , 30 ) ; base . addColorStop ( 0 , 'rgba(255,210,225,0.95)' ) ; base . addColorStop ( 0 . 55 , 'rgba(255,175,205,0.75)' ) ; base . addColorStop ( 1 , 'rgba(255,175,205,0.0)' ) ; g . fillStyle = base ; g . beginPath () ; g . moveTo ( 0 , -28 ) ; g . bezierCurveTo ( 18 , -22 , 18 , 10 , 0 , 26 ) ; g . bezierCurveTo ( -18 , 10 , -18 , -22 , 0 , -28 ) ; g . closePath () ; g . fill () ; g . globalCompositeOperation = 'destination-out' ; g . beginPath () ; g . ellipse ( 0 , 18 , 7 . 5 , 5 . 5 , 0 , 0 , Math . PI * 2 ) ; g . fill () ; g . globalCompositeOperation = 'source-over' ; g . strokeStyle = 'rgba(255,120,160,0.28)' ; g . lineWidth = 2 ; g . stroke () ; return new THREE . CanvasTexture ( c ) ; } 板ポリ InstancedMesh で「ひらひら」を出す最小䟋です。 Points ず違い、回転が絵に乗りやすいのがポむントです。 function makeSakuraPetals ({ count = 820 , texture }) { const geo = new THREE . PlaneGeometry ( 0 . 28 , 0 . 28 ) ; const mat = new THREE . MeshStandardMaterial ({ map : texture , transparent : true , depthWrite : false , side : THREE . DoubleSide , }) ; const mesh = new THREE . InstancedMesh ( geo , mat , count ) ; mesh . instanceMatrix . setUsage ( THREE . DynamicDrawUsage ) ; // 䜍眮・萜䞋・回転などの個䜓差を持ち、updateで行列を曎新する return mesh ; } 4.3 ぀たずきポむント透明衚珟・深床 透明衚珟は、有効にするだけでは䞍自然になりやすいです。厩れたずきは、たず次の点を確認しおください。 depthWrite: false : 透明が深床バッファに曞き蟌むず、埌ろの粒が䞍自然に欠けるこずがありたす transparent: true : ブレンディングを有効化する基本条件 4.4 カスタマむズする堎合のポむント 桜の衚珟をカスタマむズするなら、たずは次の3぀で十分です。 count : 花びらの枚数。増やすず華やかですが、やりすぎるず䞀気に重くなりたす fall / drift : 萜ち方。ここを觊るず「雪っぜさ」ず「桜っぜさ」の差が出やすいです テクスチャの茪郭色 : 茪郭を少し入れるだけで、粒感が枛っお花びらに寄りたす 実際、私もチュヌニングしたのはここです。モデルを凝るより、たず動きず密床を合わせたほうが“らしさ”が早く出たす。本サンプルのような画像を䜿わずにコヌドだけで衚珟する堎合は特に効果的かなず思いたす。 5.倏花火の打ち䞊げ打ち䞊げ + 加算合成 + バヌスト 花火は「点の集合」でもそれっぜく芋えたす。 5.1 利甚する䞻な機胜 AdditiveBlending : 明るいずころほど光が足される花火・光跡向き 寿呜life : 粒ごずにフェヌドアりトさせるずっず残り続けずに消す ガンマ/トヌン 簡易: ここでは匷いポスト凊理は入れず、色蚭蚈ずブレンディングで寄せる 5.2 「打ち䞊がり」を芋せるロケット + 軌跡 爆発バヌストだけだず「花火が突然出た」ように芋えたすただの砎裂みたいにしか芋えず、花火には芋えなくなりたす。 そのため、ロケットを䞊昇させ、軌跡トレむルを残しおから爆発させるのが重芁です。 䞻に䞋蚘が倧事かなず思いたす。 サンプル䜜成で苊劎したこず 最初はバヌストだけを描画しおいたした。 でも、実際の花火の映像を芋おみるずやっぱり打ち䞊げ䞭の光があっお、それがバヌストするずいう流れが倧事だなず感じたした。 そのため、ロケット䞊昇する点を䞀぀眮いお芖線の远埓先を䜜り、トレむルを少し倪く1フレヌムで耇数粒するこずで花火っぜい挔出が出るようにしおいたす。 ロケット状態 を䞀぀持぀䜍眮ず速床 䞊昇䞭に、フェヌドアりトするトレむル粒を少しず぀生成する 目暙高床に達したら burst(origin) をコヌルするようにする // ロケット1発 const rocket = { active : false , x : 0 , y : 0 , z : 0 , vx : 0 , vy : 0 , vz : 0 , targetY : 5 . 0 } ; function updateRocket ( dt ) { if ( ! rocket . active ) return; rocket . x += rocket . vx * dt ; rocket . y += rocket . vy * dt ; rocket . z += rocket . vz * dt ; rocketTrail . userData . spawnTrail ( rocket . x , rocket . y , rocket . z , ( Math . random () - 0 . 5 ) * 0 . 2 , -0 . 4 - Math . random () * 0 . 5 , ( Math . random () - 0 . 5 ) * 0 . 2 , 0 . 55 + Math . random () * 0 . 35 ) ; if ( rocket . y >= rocket . targetY ) { rocket . active = false ; fireworks . userData . burst ( new THREE . Vector3 ( rocket . x , rocket . y , rocket . z )) ; } } 5.3 泚意点加算は“䜿い過ぎるず癜飛び”する AdditiveBlendingは芋栄えが良い反面、蚭定によっおは癜飛びしやすいです。次を意識するずコントロヌルしやすいかなず思いたす。 加算合成の癜飛び䟋(粒の数が倚く倧きい堎合、粒が重なっお朰れ、癜い倧きな塊に芋える) 癜飛びを抑えた調敎䟋现かい粒子もしっかり芋えおバランスがいい 粒の 数を増やしすぎない opacity ず size を控えめにする 背景を真っ黒にせず、少し色を入れる目が疲れにくい 5.4 カスタマむズする堎合のポむント 花火はパラメヌタの効き方が分かりやすく、調敎も楜しいです。たずは次の3぀から觊るのがおすすめです。 targetY : どの高さで花火が開くか。これだけで印象がかなり倉わりたす トレむルの粒数ず寿呜 : 打ち䞊がりの“筋”が芋えるかどうかを決めたす バヌスト粒の数 : 豪華さに盎結したすが、癜飛びず重さも増えたす 個人的には、粒を増やすより 「打ち䞊がる途䞭が芋えるか」 を先に敎えたほうが、花火らしさは出しやすいです。 6.秋玅葉の景色InstancedMesh + 颚の揺れ 秋は、萜ち葉や玅葉の「枚数」が重芁です。 本サンプルでは InstancedMesh を䜿い、同じゞオメトリを倧量に描いおも耐えやすい圢にしたす。 サンプル䜜成で苊劎したこず 玅葉のような舞い散る芁玠は、たくさん芁玠を増やした方が雰囲気が出たすが、CPU偎で毎フレヌム行列曎新をしおいるず、急に重く感じるこずがありたす。 そのため、本サンプルでは、たず “枚数を出す” を InstancedMesh で確保し぀぀、曎新郚分は軜めにしお「カクカクせずに動く範囲」にしおいたす。 6.1 利甚する䞻な機胜 InstancedMesh : 1皮類の葉を、行列transformだけ倉えお倧量描画するドロヌコヌル削枛 Matrix4 : setMatrixAt で䜍眮・回転・スケヌルをたずめお蚭定 疑䌌颚 : sin で揺れを足す物理の代わり 6.2 泚意点 むンスタンシングは描画が匷い䞀方、 毎フレヌム党個䜓の行列を曎新 するずCPU偎が重くなるこずがありたす。サンプルでは数を控えめにし、曎新も軜い匏にしおいたす。カスタマむズする際は「曎新が必芁な個䜓だけ曎新する」などの工倫が効果的です。 6.3 コヌドのポむントInstancedMeshの曎新 玅葉は「枚数」が雰囲気に盎結したす。䞀方で、葉1枚1枚を Mesh で䜜るずドロヌコヌルが増えやすいので、 InstancedMesh に寄せたす。 ここでのポむントは、 setMatrixAt(i, dummy.matrix) で 各個䜓の行列を曎新 しおいる点です。 const mesh = new THREE . InstancedMesh ( geo , mat , count ) ; mesh . instanceMatrix . setUsage ( THREE . DynamicDrawUsage ) ; function update ( dt , t ) { for ( let i = 0 ; i < count ; i ++ ) { // d.x / d.y / d.z を曎新しお萜䞋させる // sinで揺れを足しお颚っぜくする dummy . position . set ( d . x + sx , d . y , d . z ) ; dummy . rotation . set ( 0 , d . ry + sx * 0 . 7 , d . rz + Math . sin ( t * 1 . 2 + d . phase ) * 0 . 4 ) ; dummy . updateMatrix () ; mesh . setMatrixAt ( i , dummy . matrix ) ; } mesh . instanceMatrix . needsUpdate = true ; } 6.4 カスタマむズする堎合のポむント 秋は、芋た目を倉えるより「重くしすぎない」調敎が倧事です。 count : たずはここ。増やすず雰囲気は出たすが、CPU曎新コストが増えたす sway : 颚の匷さ。ここが匷すぎるず、萜ち葉ずいうより玙吹雪っぜく芋えたす 曎新頻床 : 本番では毎フレヌム曎新しなくおも、十分それらしく芋えたす 7.冬雪景色フォグ + ラむティング + パヌティクル 冬の衚珟は、雪そのものより 空気癜っぜさ・奥行き が重芁に感じたす。ここではフォグずラむトで「奥行き」を䜜りたす。 サンプル䜜成で苊劎したこず 雪は粒を増やすほど季節感が出る反面、増やしすぎるず「画面がうるさい」方向に寄りがちです。 個人的には、雪そのものより フォグで遠景を溶かす ほうが“冬っぜさ”が出やすく、粒は控えめにしお揺れも匱める、くらいが扱いやすいず感じおいたす。 7.1 利甚する䞻な機胜 Fogフォグ : 遠景を癜っぜく雪/霧の空気感 DirectionalLight + AmbientLight : 圱は䜿わず、軜いラむトで雰囲気を出す 雪パヌティクル : 春のパヌティクルを流甚し぀぀、萜䞋ず揺れを匱める 7.2 コヌドのポむントFogず雪の芋せ方 雪景色は、雪の粒そのものより「遠景が癜っぜい」「空気が冷たい」印象のほうが効くこずが倚いです。そこで次の2぀を組み合わせたす。 ogがほが効いおないケヌスほが霧が掛からず、遠景がくっきり残る Fogが効きすぎおいるケヌス背景色に溶けすぎお、ものすごく霧がかかったような背景になる Fog : 遠いものほど背景色に溶ける癜/青寄りにするず冬っぜい 雪パヌティクル : サむズを控えめにし、揺れも匱める吹雪にしない // Fog冬は近めから効かせる scene . fog = new THREE . Fog ( 0x0a1322 , 6 , 45 ) ; // 雪深床曞き蟌みはしない透明の欠けを避ける const mat = new THREE . PointsMaterial ({ map : circleTexture , transparent : true , depthWrite : false , }) ; 7.3 カスタマむズする堎合のポむント 冬は、掟手さより「足し算しすぎない」ほうがうたくいきたす。 Fogの near / far : 空気感の䞻圹です。雪粒の数より先にここを觊る䟡倀がありたす 雪粒のサむズ : 倧きくしすぎるず急に人工的な芋た目に寄りたす ラむトの匷さ : 青寄りの冷たさを出したいか、やわらかい雪景色にしたいかで調敎したす 冬の衚珟は、四季の䞭でも「少ない芁玠でそれらしく芋える」ので、WebGLを觊り始めた人が最初に成功䜓隓を埗やすいパヌトだず思っおいたす。 8.たずめ 四季の衚珟は、耇雑なモデルや高䟡なポスト凊理がなくおも、次の組み合わせでそれっぜく䜜れたす。 春桜: 透明パヌティクル ず深床の扱い 倏花火: 加算合成 ずフェヌド 秋玅葉: むンスタンシング で枚数を出す 冬雪: フォグずラむト で空気を䜜る 本蚘事のサンプルは「再珟しやすい」こずを優先しお、1ファむル・倖郚画像なしで䜜っおいたす。ここから、背景の䜜り蟌み、モデル差し替え、ポスト凊理远加など、甚途に応じお段階的に䌞ばしおください。 最埌に個人的な感芚ですが、四季のような“わかりやすいテヌマ”は、WebGLの機胜を甚いお 「䜕を足すず䜓隓がどう倉わるか」 を掎みやすい題材だず思っおいたす。 たずはこのたた動かしお、気に入った季節のパラメヌタ枚数、速床、色などを少しず぀觊っおみおください。芋た目の倉化が玠盎なので、䜜っおいお楜しいずころでもありたす。 最初の䞀歩ずしおおすすめは、桜か雪です。春は「圢ず動き」で印象が倉わる面癜さがあり、冬は少ない芁玠で空気感が䜜れたす。 付録フルセットのコヌド 以䞋に、四季切り替え版のフルコヌドを掲茉したす。 <!doctype html> < html lang = "ja" > < head > < meta charset = "utf-8" /> < meta name = "viewport" content = "width=device-width,initial-scale=1" /> < title > Four Seasons (Three.js) </ title > < style > html , body { height : 100% ; } body { margin : 0 ; overflow : hidden ; background : #0b1020 ; font-family : system-ui , -apple-system , "Hiragino Sans" , "Noto Sans JP" , sans-serif ; } canvas { display : block ; } .ui { position : fixed ; top : 12px ; left : 12px ; display : flex ; gap: 8px ; flex-wrap : wrap ; z-index : 2 ; padding : 10px ; border-radius : 14px ; background : rgba( 0 , 0 , 0 , 0.35 ) ; border : 1px solid rgba( 255 , 255 , 255 , 0.10 ) ; backdrop- filter : blur( 10px ) ; color : rgba( 255 , 255 , 255 , 0.85 ) ; } button { appearance : none ; border : 1px solid rgba( 255 , 255 , 255 , 0.14 ) ; background : rgba( 255 , 255 , 255 , 0.06 ) ; color : rgba( 255 , 255 , 255 , 0.9 ) ; padding : 8px 10px ; border-radius : 12px ; font-weight : 650 ; cursor : pointer ; } button [ aria-pressed = "true" ] { background : linear-gradient( 135deg , rgba( 61 , 220 , 151 , .22 ), rgba( 122 , 162 , 255 , .18 )) ; border-color : rgba( 255 , 255 , 255 , 0.18 ) ; } .note { max-width : 420px ; font-size : 12px ; line-height : 1.5 ; opacity : 0.9 ; } </ style > </ head > < body > < div class = "ui" role = "group" aria-label = "season switch" > < button id = "spring" aria-pressed = "true" > 春桜 </ button > < button id = "summer" aria-pressed = "false" > 倏花火 </ button > < button id = "autumn" aria-pressed = "false" > 秋玅葉 </ button > < button id = "winter" aria-pressed = "false" > 冬雪 </ button > < div class = "note" > 起動は Live Server などで http://localhost ずしお開いおくださいfile:// は䞍可。 </ div > </ div > < script type = "importmap" > { "imports" : { "three" : "https://unpkg.com/three@0.164.1/build/three.module.js" } } </ script > < script type = "module" > import * as THREE from 'three' ; const Season = Object . freeze ({ spring : 'spring' , summer : 'summer' , autumn : 'autumn' , winter : 'winter' }) ; let season = Season . spring ; const btns = { spring : document . getElementById ( 'spring' ) , summer : document . getElementById ( 'summer' ) , autumn : document . getElementById ( 'autumn' ) , winter : document . getElementById ( 'winter' ) , } ; function setPressed ( s ) { for ( const [ k , el ] of Object . entries ( btns )) el . setAttribute ( 'aria-pressed' , String ( k === s )) ; } for ( const [ k , el ] of Object . entries ( btns )) { el . addEventListener ( 'click' , () => { season = k ; setPressed ( k ) ; applySeasonLook () ; }) ; } const renderer = new THREE . WebGLRenderer ({ antialias : true , alpha : false , powerPreference : 'high-performance' }) ; renderer . setPixelRatio ( Math . min ( devicePixelRatio , 2 )) ; renderer . setSize ( innerWidth , innerHeight ) ; document . body . appendChild ( renderer . domElement ) ; const scene = new THREE . Scene () ; const camera = new THREE . PerspectiveCamera ( 55 , innerWidth / innerHeight , 0 . 1 , 200 ) ; camera . position . set ( 0 , 3 . 0 , 10 . 5 ) ; const amb = new THREE . AmbientLight ( 0xffffff , 0 . 55 ) ; const dir = new THREE . DirectionalLight ( 0xffffff , 1 . 0 ) ; dir . position . set ( 6 , 10 , 6 ) ; scene . add ( amb , dir ) ; const ground = new THREE . Mesh ( new THREE . PlaneGeometry ( 120 , 120 ) , new THREE . MeshStandardMaterial ({ color : 0x0e1733 , roughness : 1 . 0 , metalness : 0 . 0 }) ) ; ground . rotation . x = - Math . PI / 2 ; ground . position . y = -0 . 6 ; scene . add ( ground ) ; function makeCircleTexture ({ color = '#ffffff' , soft = true } = {}) { const c = document . createElement ( 'canvas' ) ; c . width = 64 ; c . height = 64 ; const g = c . getContext ( '2d' ) ; g . clearRect ( 0 , 0 , c . width , c . height ) ; const r = 26 ; const cx = 32 , cy = 32 ; const grd = g . createRadialGradient ( cx , cy , soft ? 4 : 20 , cx , cy , r ) ; grd . addColorStop ( 0 , color ) ; grd . addColorStop ( 1 , 'rgba(255,255,255,0)' ) ; g . fillStyle = grd ; g . beginPath () ; g . arc ( cx , cy , r , 0 , Math . PI * 2 ) ; g . fill () ; const tex = new THREE . CanvasTexture ( c ) ; tex . needsUpdate = true ; return tex ; } function makePetalTexture () { const c = document . createElement ( 'canvas' ) ; c . width = 64 ; c . height = 64 ; const g = c . getContext ( '2d' ) ; g . clearRect ( 0 , 0 , 64 , 64 ) ; g . translate ( 32 , 32 ) ; g . rotate ( -0 . 25 ) ; // 花びら感を出すために「先端が䞞く、根元に切れ蟌みがある」圢を描く const base = g . createRadialGradient ( 0 , -6 , 2 , 0 , -6 , 30 ) ; base . addColorStop ( 0 , 'rgba(255,210,225,0.95)' ) ; base . addColorStop ( 0 . 55 , 'rgba(255,175,205,0.75)' ) ; base . addColorStop ( 1 , 'rgba(255,175,205,0.0)' ) ; g . fillStyle = base ; g . beginPath () ; g . moveTo ( 0 , -28 ) ; g . bezierCurveTo ( 18 , -22 , 18 , 10 , 0 , 26 ) ; g . bezierCurveTo ( -18 , 10 , -18 , -22 , 0 , -28 ) ; g . closePath () ; g . fill () ; // 根元の切れ蟌み少しだけ透明に抜く g . globalCompositeOperation = 'destination-out' ; g . fillStyle = 'rgba(0,0,0,0.6)' ; g . beginPath () ; g . ellipse ( 0 , 18 , 7 . 5 , 5 . 5 , 0 , 0 , Math . PI * 2 ) ; g . fill () ; g . globalCompositeOperation = 'source-over' ; // 茪郭をほんの少し雪っぜさを枛らす g . strokeStyle = 'rgba(255,120,160,0.28)' ; g . lineWidth = 2 ; g . beginPath () ; g . moveTo ( 0 , -27 ) ; g . bezierCurveTo ( 17 , -21 , 17 , 9 , 0 , 25 ) ; g . bezierCurveTo ( -17 , 9 , -17 , -21 , 0 , -27 ) ; g . closePath () ; g . stroke () ; const tex = new THREE . CanvasTexture ( c ) ; tex . needsUpdate = true ; return tex ; } function makeLeafTexture () { const c = document . createElement ( 'canvas' ) ; c . width = 64 ; c . height = 64 ; const g = c . getContext ( '2d' ) ; g . clearRect ( 0 , 0 , 64 , 64 ) ; g . translate ( 32 , 32 ) ; const grd = g . createLinearGradient ( -10 , -26 , 12 , 26 ) ; grd . addColorStop ( 0 , 'rgba(255,122,48,0.95)' ) ; grd . addColorStop ( 1 , 'rgba(170,40,0,0.0)' ) ; g . fillStyle = grd ; g . beginPath () ; g . ellipse ( 0 , 0 , 14 , 24 , 0 . 6 , 0 , Math . PI * 2 ) ; g . fill () ; g . strokeStyle = 'rgba(255,255,255,0.25)' ; g . lineWidth = 2 ; g . beginPath () ; g . moveTo ( -6 , -18 ) ; g . lineTo ( 8 , 18 ) ; g . stroke () ; const tex = new THREE . CanvasTexture ( c ) ; tex . needsUpdate = true ; return tex ; } function makeFallingPoints ({ count , texture , color , size , area }) { const geo = new THREE . BufferGeometry () ; const pos = new Float32Array ( count * 3 ) ; const vel = new Float32Array ( count * 3 ) ; for ( let i = 0 ; i < count ; i ++ ) { const x = ( Math . random () - 0 . 5 ) * area . x ; const y = Math . random () * area . y ; const z = ( Math . random () - 0 . 5 ) * area . z ; pos [ i * 3 + 0 ] = x ; pos [ i * 3 + 1 ] = y ; pos [ i * 3 + 2 ] = z ; vel [ i * 3 + 0 ] = ( Math . random () - 0 . 5 ) * 0 . 3 ; vel [ i * 3 + 1 ] = - ( 0 . 25 + Math . random () * 0 . 35 ) ; vel [ i * 3 + 2 ] = ( Math . random () - 0 . 5 ) * 0 . 3 ; } geo . setAttribute ( 'position' , new THREE . BufferAttribute ( pos , 3 )) ; geo . setAttribute ( 'velocity' , new THREE . BufferAttribute ( vel , 3 )) ; const mat = new THREE . PointsMaterial ({ color , size , map : texture , transparent : true , depthWrite : false , blending : THREE . NormalBlending , }) ; const pts = new THREE . Points ( geo , mat ) ; // 毎フレヌム getAttribute を呌ばないよう、参照を保持しおおく pts . userData . area = area ; pts . userData . posAttr = geo . getAttribute ( 'position' ) ; pts . userData . velAttr = geo . getAttribute ( 'velocity' ) ; return pts ; } // 桜は Points だず「粒」に寄りやすいので、板ポリInstancedMeshで花びら感を出す function makeSakuraPetals ({ count = 700 , texture }) { const geo = new THREE . PlaneGeometry ( 0 . 28 , 0 . 28 ) ; const mat = new THREE . MeshStandardMaterial ({ color : 0xffffff , map : texture , transparent : true , depthWrite : false , side : THREE . DoubleSide , roughness : 0 . 95 , metalness : 0 . 0 }) ; const mesh = new THREE . InstancedMesh ( geo , mat , count ) ; mesh . instanceMatrix . setUsage ( THREE . DynamicDrawUsage ) ; mesh . frustumCulled = false ; const data = [] ; const dummy = new THREE . Object3D () ; for ( let i = 0 ; i < count ; i ++ ) { data . push ({ x : ( Math . random () - 0 . 5 ) * 18 , y : Math . random () * 10 , z : ( Math . random () - 0 . 5 ) * 18 , fall : 0 . 55 + Math . random () * 0 . 55 , drift : 0 . 35 + Math . random () * 0 . 55 , spin : 1 . 2 + Math . random () * 2 . 6 , wobble : 1 . 0 + Math . random () * 1 . 6 , phase : Math . random () * 10 , ry : Math . random () * Math . PI * 2 , }) ; } function update ( dt , t ) { for ( let i = 0 ; i < count ; i ++ ) { const d = data [ i ] ; d . phase += dt ; d . y -= d . fall * dt ; const wx = Math . sin ( d . phase * d . wobble ) * d . drift ; const wz = Math . cos ( d . phase * ( d . wobble * 0 . 9 )) * d . drift ; d . x += wx * dt ; d . z += wz * dt ; if ( d . y < -0 . 6 ) { d . x = ( Math . random () - 0 . 5 ) * 18 ; d . y = 10 + Math . random () * 4 ; d . z = ( Math . random () - 0 . 5 ) * 18 ; } const tilt = Math . sin ( d . phase * 2 . 1 ) * 0 . 7 ; dummy . position . set ( d . x , d . y , d . z ) ; dummy . rotation . set ( tilt , d . ry + d . phase * 0 . 35 , d . phase * d . spin ) ; const s = 0 . 75 + Math . sin ( d . phase * 1 . 7 ) * 0 . 08 ; dummy . scale . set ( s , s , s ) ; dummy . updateMatrix () ; mesh . setMatrixAt ( i , dummy . matrix ) ; } mesh . instanceMatrix . needsUpdate = true ; } mesh . userData . update = update ; return mesh ; } const sakura = makeSakuraPetals ({ count : 820 , texture : makePetalTexture () }) ; scene . add ( sakura ) ; const snow = makeFallingPoints ({ count : 1600 , texture : makeCircleTexture ({ color : 'rgba(255,255,255,0.9)' , soft : true }) , color : 0xffffff , size : 0 . 08 , area : new THREE . Vector3 ( 18 , 10 , 18 ) }) ; snow . visible = false ; scene . add ( snow ) ; function makeFireworks ({ maxParticles = 1800 }) { const geo = new THREE . BufferGeometry () ; const pos = new Float32Array ( maxParticles * 3 ) ; const vel = new Float32Array ( maxParticles * 3 ) ; const life = new Float32Array ( maxParticles ) ; for ( let i = 0 ; i < maxParticles ; i ++ ) { pos [ i * 3 + 0 ] = 0 ; pos [ i * 3 + 1 ] = -999 ; pos [ i * 3 + 2 ] = 0 ; vel [ i * 3 + 0 ] = 0 ; vel [ i * 3 + 1 ] = 0 ; vel [ i * 3 + 2 ] = 0 ; life [ i ] = 0 ; } geo . setAttribute ( 'position' , new THREE . BufferAttribute ( pos , 3 )) ; geo . setAttribute ( 'velocity' , new THREE . BufferAttribute ( vel , 3 )) ; geo . setAttribute ( 'life' , new THREE . BufferAttribute ( life , 1 )) ; const mat = new THREE . PointsMaterial ({ color : 0xfff4b0 , size : 0 . 12 , map : makeCircleTexture ({ color : 'rgba(255,220,120,0.95)' , soft : true }) , transparent : true , depthWrite : false , blending : THREE . AdditiveBlending }) ; const pts = new THREE . Points ( geo , mat ) ; pts . frustumCulled = false ; // 参照をキャッシュupdate/burst で高速化 const posAttr = geo . getAttribute ( 'position' ) ; const velAttr = geo . getAttribute ( 'velocity' ) ; const lifeAttr = geo . getAttribute ( 'life' ) ; function burst ( origin ) { const p = posAttr ; const v = velAttr ; const l = lifeAttr ; const need = 600 ; let spawned = 0 ; for ( let i = 0 ; i < l . count && spawned < need ; i ++ ) { if ( l . array [ i ] > 0 ) continue; const theta = Math . random () * Math . PI * 2 ; const phi = Math . acos ( THREE . MathUtils . randFloat ( -1 , 1 )) ; const sp = 2 . 8 + Math . random () * 2 . 4 ; v . array [ i * 3 + 0 ] = Math . cos ( theta ) * Math . sin ( phi ) * sp ; v . array [ i * 3 + 1 ] = Math . cos ( phi ) * sp ; v . array [ i * 3 + 2 ] = Math . sin ( theta ) * Math . sin ( phi ) * sp ; p . array [ i * 3 + 0 ] = origin . x ; p . array [ i * 3 + 1 ] = origin . y ; p . array [ i * 3 + 2 ] = origin . z ; l . array [ i ] = 1 . 6 + Math . random () * 0 . 8 ; spawned ++; } p . needsUpdate = true ; v . needsUpdate = true ; l . needsUpdate = true ; } pts . userData . burst = burst ; pts . userData . posAttr = posAttr ; pts . userData . velAttr = velAttr ; pts . userData . lifeAttr = lifeAttr ; return pts ; } const fireworks = makeFireworks ({ maxParticles : 2200 }) ; fireworks . visible = false ; scene . add ( fireworks ) ; // 打ち䞊げロケット軌跡トレむル function makeRocketTrail ({ max = 900 }) { const geo = new THREE . BufferGeometry () ; const pos = new Float32Array ( max * 3 ) ; const vel = new Float32Array ( max * 3 ) ; const life = new Float32Array ( max ) ; for ( let i = 0 ; i < max ; i ++ ) { pos [ i * 3 + 0 ] = 0 ; pos [ i * 3 + 1 ] = -999 ; pos [ i * 3 + 2 ] = 0 ; vel [ i * 3 + 0 ] = 0 ; vel [ i * 3 + 1 ] = 0 ; vel [ i * 3 + 2 ] = 0 ; life [ i ] = 0 ; } geo . setAttribute ( 'position' , new THREE . BufferAttribute ( pos , 3 )) ; geo . setAttribute ( 'velocity' , new THREE . BufferAttribute ( vel , 3 )) ; geo . setAttribute ( 'life' , new THREE . BufferAttribute ( life , 1 )) ; const mat = new THREE . PointsMaterial ({ color : 0xfff0c8 , size : 0 . 11 , map : makeCircleTexture ({ color : 'rgba(255,220,160,0.85)' , soft : true }) , transparent : true , depthWrite : false , blending : THREE . AdditiveBlending }) ; const pts = new THREE . Points ( geo , mat ) ; pts . frustumCulled = false ; const posAttr = geo . getAttribute ( 'position' ) ; const velAttr = geo . getAttribute ( 'velocity' ) ; const lifeAttr = geo . getAttribute ( 'life' ) ; function spawnTrail ( x , y , z , vx , vy , vz , ttl ) { const p = posAttr ; const v = velAttr ; const l = lifeAttr ; for ( let i = 0 ; i < l . count ; i ++ ) { if ( l . array [ i ] > 0 ) continue; p . array [ i * 3 + 0 ] = x ; p . array [ i * 3 + 1 ] = y ; p . array [ i * 3 + 2 ] = z ; v . array [ i * 3 + 0 ] = vx ; v . array [ i * 3 + 1 ] = vy ; v . array [ i * 3 + 2 ] = vz ; l . array [ i ] = ttl ; p . needsUpdate = true ; v . needsUpdate = true ; l . needsUpdate = true ; return; } } pts . userData . spawnTrail = spawnTrail ; pts . userData . posAttr = posAttr ; pts . userData . velAttr = velAttr ; pts . userData . lifeAttr = lifeAttr ; return pts ; } const rocketTrail = makeRocketTrail ({ max : 1100 }) ; rocketTrail . visible = false ; scene . add ( rocketTrail ) ; const rocket = { active : false , x : 0 , y : 0 , z : 0 , vx : 0 , vy : 0 , vz : 0 , targetY : 5 . 0 } ; // ロケット本䜓明るい点を動かしお「打ち䞊げ」を芖認させる const rocketSprite = new THREE . Sprite ( new THREE . SpriteMaterial ({ map : makeCircleTexture ({ color : 'rgba(255,255,255,0.95)' , soft : true }) , color : 0xfff0d5 , transparent : true , depthWrite : false , blending : THREE . AdditiveBlending }) ) ; rocketSprite . scale . set ( 0 . 6 , 0 . 6 , 0 . 6 ) ; rocketSprite . visible = false ; scene . add ( rocketSprite ) ; function makeLeaves ({ count = 420 , texture }) { const geo = new THREE . PlaneGeometry ( 0 . 5 , 0 . 5 ) ; const mat = new THREE . MeshStandardMaterial ({ color : 0xffffff , map : texture , transparent : true , depthWrite : false , side : THREE . DoubleSide , roughness : 0 . 9 , metalness : 0 . 0 }) ; const mesh = new THREE . InstancedMesh ( geo , mat , count ) ; mesh . instanceMatrix . setUsage ( THREE . DynamicDrawUsage ) ; mesh . frustumCulled = false ; const data = [] ; const dummy = new THREE . Object3D () ; for ( let i = 0 ; i < count ; i ++ ) { data . push ({ x : ( Math . random () - 0 . 5 ) * 16 , y : Math . random () * 8 + 1 , z : ( Math . random () - 0 . 5 ) * 16 , ry : Math . random () * Math . PI * 2 , rz : ( Math . random () - 0 . 5 ) * 0 . 6 , fall : 0 . 4 + Math . random () * 0 . 6 , sway : 0 . 6 + Math . random () * 1 . 4 , phase : Math . random () * 10 }) ; } function update ( dt , t ) { for ( let i = 0 ; i < count ; i ++ ) { const d = data [ i ] ; d . phase += dt ; d . y -= d . fall * dt ; if ( d . y < -0 . 2 ) { d . y = 8 + Math . random () * 3 ; d . x = ( Math . random () - 0 . 5 ) * 16 ; d . z = ( Math . random () - 0 . 5 ) * 16 ; } const sx = Math . sin ( d . phase * d . sway ) * 0 . 35 ; dummy . position . set ( d . x + sx , d . y , d . z ) ; dummy . rotation . set ( 0 , d . ry + sx * 0 . 7 , d . rz + Math . sin ( t * 1 . 2 + d . phase ) * 0 . 4 ) ; const s = 0 . 65 + Math . sin ( d . phase ) * 0 . 08 ; dummy . scale . set ( s , s , s ) ; dummy . updateMatrix () ; mesh . setMatrixAt ( i , dummy . matrix ) ; } mesh . instanceMatrix . needsUpdate = true ; } mesh . userData . update = update ; return mesh ; } const leaves = makeLeaves ({ count : 520 , texture : makeLeafTexture () }) ; leaves . visible = false ; scene . add ( leaves ) ; function resetSummerArtifacts () { // ロケット状態を止める rocket . active = false ; rocketSprite . visible = false ; // trail / fireworks の残り粒を匷制的に消す for ( const pts of [ rocketTrail , fireworks ]) { const p = pts . userData . posAttr ; const l = pts . userData . lifeAttr ; if ( ! p || ! l ) continue; for ( let i = 0 ; i < l . count ; i ++ ) { l . array [ i ] = 0 ; p . array [ i * 3 + 1 ] = -999 ; } p . needsUpdate = true ; l . needsUpdate = true ; } } function applySeasonLook () { if ( season === Season . spring ) { resetSummerArtifacts () ; renderer . setClearColor ( 0x0b1020 , 1 ) ; scene . fog = new THREE . Fog ( 0x0b1020 , 10 , 60 ) ; ground . material . color . setHex ( 0x0e1733 ) ; amb . intensity = 0 . 55 ; dir . intensity = 0 . 9 ; sakura . visible = true ; snow . visible = false ; fireworks . visible = false ; leaves . visible = false ; } else if ( season === Season . summer ) { renderer . setClearColor ( 0x070a14 , 1 ) ; scene . fog = new THREE . Fog ( 0x070a14 , 14 , 70 ) ; ground . material . color . setHex ( 0x0a0f22 ) ; amb . intensity = 0 . 35 ; dir . intensity = 0 . 55 ; sakura . visible = false ; snow . visible = false ; fireworks . visible = true ; leaves . visible = false ; rocketTrail . visible = true ; rocketSprite . visible = true ; } else if ( season === Season . autumn ) { resetSummerArtifacts () ; renderer . setClearColor ( 0x120a08 , 1 ) ; scene . fog = new THREE . Fog ( 0x120a08 , 9 , 55 ) ; ground . material . color . setHex ( 0x2a160f ) ; amb . intensity = 0 . 6 ; dir . intensity = 1 . 05 ; sakura . visible = false ; snow . visible = false ; fireworks . visible = false ; leaves . visible = true ; } else if ( season === Season . winter ) { resetSummerArtifacts () ; renderer . setClearColor ( 0x0a1322 , 1 ) ; scene . fog = new THREE . Fog ( 0x0a1322 , 6 , 45 ) ; ground . material . color . setHex ( 0x1a2438 ) ; amb . intensity = 0 . 75 ; dir . intensity = 0 . 95 ; sakura . visible = false ; snow . visible = true ; fireworks . visible = false ; leaves . visible = false ; rocketTrail . visible = false ; rocketSprite . visible = false ; } } applySeasonLook () ; function resize () { camera . aspect = innerWidth / innerHeight ; camera . updateProjectionMatrix () ; renderer . setSize ( innerWidth , innerHeight ) ; } addEventListener ( 'resize' , resize ) ; function updateFalling ( points , dt ) { const p = points . userData . posAttr ; const v = points . userData . velAttr ; const a = points . userData . area ; for ( let i = 0 ; i < p . count ; i ++ ) { p . array [ i * 3 + 0 ] += v . array [ i * 3 + 0 ] * dt ; p . array [ i * 3 + 1 ] += v . array [ i * 3 + 1 ] * dt ; p . array [ i * 3 + 2 ] += v . array [ i * 3 + 2 ] * dt ; if ( p . array [ i * 3 + 1 ] < -0 . 2 ) { p . array [ i * 3 + 0 ] = ( Math . random () - 0 . 5 ) * a . x ; p . array [ i * 3 + 1 ] = a . y ; p . array [ i * 3 + 2 ] = ( Math . random () - 0 . 5 ) * a . z ; } v . array [ i * 3 + 0 ] += ( Math . random () - 0 . 5 ) * 0 . 02 * dt ; v . array [ i * 3 + 2 ] += ( Math . random () - 0 . 5 ) * 0 . 02 * dt ; } p . needsUpdate = true ; v . needsUpdate = true ; } function updateFireworks ( dt ) { const p = fireworks . userData . posAttr ; const v = fireworks . userData . velAttr ; const l = fireworks . userData . lifeAttr ; for ( let i = 0 ; i < l . count ; i ++ ) { const life = l . array [ i ] ; if ( life <= 0 ) continue; l . array [ i ] = Math . max ( 0 , life - dt ) ; p . array [ i * 3 + 0 ] += v . array [ i * 3 + 0 ] * dt ; p . array [ i * 3 + 1 ] += v . array [ i * 3 + 1 ] * dt ; p . array [ i * 3 + 2 ] += v . array [ i * 3 + 2 ] * dt ; v . array [ i * 3 + 1 ] -= 3 . 6 * dt ; v . array [ i * 3 + 0 ] *= ( 1 - 0 . 18 * dt ) ; v . array [ i * 3 + 1 ] *= ( 1 - 0 . 18 * dt ) ; v . array [ i * 3 + 2 ] *= ( 1 - 0 . 18 * dt ) ; if ( l . array [ i ] === 0 ) p . array [ i * 3 + 1 ] = -999 ; } p . needsUpdate = true ; v . needsUpdate = true ; l . needsUpdate = true ; } function updateRocket ( dt ) { if ( ! rocket . active ) { rocketSprite . visible = false ; return; } rocket . x += rocket . vx * dt ; rocket . y += rocket . vy * dt ; rocket . z += rocket . vz * dt ; // 軌跡少し散らす // 1フレヌムで耇数粒を出しお、筋っぜく芋せる for ( let i = 0 ; i < 3 ; i ++ ) { rocketTrail . userData . spawnTrail ( rocket . x + ( Math . random () - 0 . 5 ) * 0 . 05 , rocket . y + ( Math . random () - 0 . 5 ) * 0 . 05 , rocket . z + ( Math . random () - 0 . 5 ) * 0 . 05 , ( Math . random () - 0 . 5 ) * 0 . 22 , -0 . 55 - Math . random () * 0 . 65 , ( Math . random () - 0 . 5 ) * 0 . 22 , 0 . 75 + Math . random () * 0 . 45 ) ; } rocketSprite . position . set ( rocket . x , rocket . y , rocket . z ) ; rocketSprite . visible = true ; // 到達したら爆発 if ( rocket . y >= rocket . targetY ) { rocket . active = false ; rocketSprite . visible = false ; fireworks . userData . burst ( new THREE . Vector3 ( rocket . x , rocket . y , rocket . z )) ; } } function updateTrailPoints ( points , dt ) { const p = points . userData . posAttr ; const v = points . userData . velAttr ; const l = points . userData . lifeAttr ; for ( let i = 0 ; i < l . count ; i ++ ) { const life = l . array [ i ] ; if ( life <= 0 ) continue; l . array [ i ] = Math . max ( 0 , life - dt ) ; p . array [ i * 3 + 0 ] += v . array [ i * 3 + 0 ] * dt ; p . array [ i * 3 + 1 ] += v . array [ i * 3 + 1 ] * dt ; p . array [ i * 3 + 2 ] += v . array [ i * 3 + 2 ] * dt ; v . array [ i * 3 + 1 ] -= 0 . 9 * dt ; v . array [ i * 3 + 0 ] *= ( 1 - 0 . 35 * dt ) ; v . array [ i * 3 + 1 ] *= ( 1 - 0 . 35 * dt ) ; v . array [ i * 3 + 2 ] *= ( 1 - 0 . 35 * dt ) ; if ( l . array [ i ] === 0 ) p . array [ i * 3 + 1 ] = -999 ; } p . needsUpdate = true ; v . needsUpdate = true ; l . needsUpdate = true ; } let t = 0 ; let last = performance . now () ; let fireTimer = 0 ; function loop ( now ) { const dt = Math . min (( now - last ) / 1000 , 0 . 05 ) ; last = now ; t += dt ; camera . position . x = Math . sin ( t * 0 . 25 ) * 0 . 35 ; camera . lookAt ( 0 , 1 . 0 , 0 ) ; if ( season === Season . spring ) { sakura . userData . update ( dt , t ) ; } else if ( season === Season . winter ) { updateFalling ( snow , dt ) ; snow . material . size = 0 . 075 + Math . sin ( t * 0 . 6 ) * 0 . 01 ; } else if ( season === Season . summer ) { // 打ち䞊げ→爆発→䜙韻軌跡たで芋せる updateFireworks ( dt ) ; updateTrailPoints ( rocketTrail , dt ) ; updateRocket ( dt ) ; fireTimer -= dt ; if ( fireTimer <= 0 ) { fireTimer = 1 . 0 + Math . random () * 0 . 9 ; // ロケットを再発射発射䞭は远加しない if ( ! rocket . active ) { rocket . active = true ; rocket . x = ( Math . random () - 0 . 5 ) * 3 . 2 ; rocket . y = -1 . 2 ; rocket . z = -4 . 0 + ( Math . random () - 0 . 5 ) * 1 . 4 ; rocket . vx = ( Math . random () - 0 . 5 ) * 0 . 18 ; rocket . vy = 6 . 2 + Math . random () * 0 . 8 ; rocket . vz = ( Math . random () - 0 . 5 ) * 0 . 12 ; rocket . targetY = 3 . 9 + Math . random () * 1 . 7 ; } } } else if ( season === Season . autumn ) { leaves . userData . update ( dt , t ) ; } renderer . render ( scene , camera ) ; requestAnimationFrame ( loop ) ; } requestAnimationFrame ( loop ) ; </ script > </ body > </ html > 執筆者 䞭川 拓哉NTT西日本 デゞタル革新本郚 デゞタル改革掚進郚所属 NTT西日本のWebアプリケヌションの開発・運営に埓事。 奜きな技術スタックTypeScript, Vue.js, GraphQL, Laravel 参考資料・出兞 本蚘事を執筆するにあたり、以䞋のサむト・資料を参考にしたした。 Three.js documentation https://threejs.org/docs/ MDN Web Docs — WebGL API https://developer.mozilla.org/ja/docs/Web/API/WebGL_API MDN Web Docs — WebGL best practices https://developer.mozilla.org/en-US/docs/Web/API/WebGL_API/WebGL_best_practices MDN Web Docs — requestAnimationFrame  https://developer.mozilla.org/ja/docs/Web/API/Window/requestAnimationFrame 商暙 「JavaScript」は、Oracle Corporation およびその子䌚瀟の米囜およびその他の囜における商暙たたは登録商暙です。 「Firefox」は、Mozilla Foundation の商暙です。 「Safari」はApple Inc. の商暙です。 「Chromium」はGoogle LLC に関連するプロゞェクト名です。 「Google Chrome」は、Google LLC の商暙です。 「Three.js」は、Three.jsプロゞェクトに関連する名称です詳现は公匏ドキュメントを参照しおください。 「Visual Studio Code」はMicrosoft Corporation の商暙です。 「Apache HTTP Server」および「Apache」は、Apache Software Foundation の商暙です。 蚘茉のその他の䌚瀟名・補品名は、それぞれ各瀟の商暙もしくは登録商暙です。

動画

該圓するコンテンツが芋぀かりたせんでした

曞籍