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 の商標です。 記載のその他の会社名・製品名は、それぞれ各社の商標もしくは登録商標です。

動画

該当するコンテンツが見つかりませんでした

書籍