blog

8回描いて1回しか効いていなかったprewarm。three.jsの技エフェクトがnpmになるまで

Rollshade は、炎の魔法や剣の斬撃のような「技のエフェクト」を集めた three.js 向けのライブラリです。サイトでは各エフェクトをブラウザで動かしながら調整でき、気に入ったものは npm パッケージ rollshade(MIT ライセンス)からゲームに呼び出せます。

fx.play(effect('meteor', 'fire'), { from: hero.hand, to: enemy });

使う側のコードはこの 1 行ですが、裏では粒子、光の輪、斬撃の軌跡、画面の揺れなどを、決まったタイミングで組み合わせて出しています。

ただ、最初からこういうライブラリを作るつもりだったわけではありません。2026 年 9 月 19 日に書き始めたときは「シェーダーをランダムに生成するサイト」で、npm の 0.1.0 を出したのは 10 月 1 日です。その 2 週間で方針を 2 回変えました。この記事では、その経緯と、three.js の WebGPU 対応と TSL で踏んだ穴を順に書きます。

Rollshade も、ほかのサービスと同じく Claude Code と一緒に作っています。私が仕様と判断と見た目の確認を受け持ち、実装は Claude Code に任せる分担です。以下で「調べると」「直した」と書いているところも、手を動かしたのは多くが Claude Code です。

最初はシェーダーのランダム生成サイトだった

最初の依頼は「Three.js の TSL を、数式をランダムに組み合わせて生成できるサイト」でした。キラキラや禍々しいオーラのような、ゲームでよくシェーダーで表現されるものを部品として用意し、ランダムに組み合わせる。姉妹サービスの Rollpoly と同じく、サイコロを振るたびに新しいものが出てくる、というのが元の発想です(名前も Roll と shade です)。

TSL(Three Shading Language)は、three.js が用意している、JavaScript の関数を組み合わせてシェーダーを書くしくみです。書いたものは、WebGPU 用の言語(WGSL)にも WebGL2 用の言語(GLSL)にも変換されます。描画には three.js の WebGPURenderer を使い、WebGPU が使えないブラウザでは自動で WebGL2 に切り替わります。逆に、従来の WebGLRenderer では TSL が動かないので、Rollshade を使うゲームは WebGPURenderer が前提です。

仕様を書いた段階で、「数式を完全にランダムに組む」案は捨てています。低いレベルの式を無作為につなぐと、真っ黒や真っ白に破綻する組み合わせが多く、狙った雰囲気が出にくいためです。代わりに、意味のある部品をテーマごとの重みで重ね、変異の度合いに応じて式を差し込む形にしました。

書き始めた日は、まず スパイク(本番のコードを書く前に、危なそうなところだけを小さく試す作業)を回しました。対象は uniform の更新、シェーダーのコンパイル待ち、パーティクル、透過背景のブルーム、動画の書き出しです。ここで分かったことのうち、あとで効いてくるのは次の 2 つです。

  • WebGL2 のコンパイル待ち:Chrome の WebGL2 では、新しいシェーダーをコンパイルしたあとの初回の描画で、メインスレッドが 90〜210 ミリ秒止まる
  • three.js のフレーム番号:ポストプロセスのシーンの描画は、three.js 内部のフレーム番号が進んだときにしか描き直されない。連続で描いて動画にすると、前の絵が繰り返される

後者は、動画の書き出しで 1 枚描くたびに内部のフレーム番号を手で進めることで避けました。three.js の公開 API には無い操作です。

export function advanceFrame(renderer: THREE.WebGPURenderer): void {
  (renderer as unknown as { _nodes: { nodeFrame: { update(): void } } })._nodes.nodeFrame.update();
}

この「1 フレームに 1 回しか描かれない」という性質には、2 週間後にもう一度つまずくことになります(後で書きます)。

スパイクのあと、ノード図での編集、共有 URL、テーマ、モデルの読み込み、動画の書き出しまでを、その日のうちに一通り作りました。

「演出」が「しょぼい」と言われるまで

翌日から、生成したシェーダーをゲームで使う場面を考え始めました。氷の魔法なら、溜めて、撃って、飛んで、当たって、余韻が残る。この流れを「演出」として足し、予兆、発生、移動、着弾、余韻の 5 段階を、ループの位相から位置と寿命を決める式で GPU の中で動かす形にしました。

この演出は、5 日かけて何度作り直しても良くなりませんでした。市販のエフェクト集の画像を見せて「まさにこういう感じ」と伝えても、見比べると地味です。別の日に Claude Code へ「自由に攻撃モーションを作って」とだけ頼んで作ってもらった単体の HTML のほうが、まだ見栄えがしました。9 月 25 日、私は「演出がクオリティが低すぎて使い物にならない」「今の仕様などに縛られてクオリティを高くできないのでは?」と書いています。

原因を調べると、作りそのものに限界がありました。位相から閉じた式で位置を決める方式では、状態を持つ粒(減速する、吸い寄せられる、跳ね返る)や、時間上の段取り(溜めてから放つ、遅れて誘爆する、ヒットストップで止める)、点光源、画面の歪みが原理的に作れません。出力するコードも 1 ファイルに 1 つのエフェクトで、共通の処理を毎回複製していました。同じ技を何発も重ねて撃つこともできませんでした。

共通のランタイムと手作りのレシピに作り直す

そこで、ランダム生成を主役から降ろしました。手作りの技(レシピ)を土台にし、ランダムはレシピの中の値の揺らぎに限ります。技を動かすのは、ゲームに 1 つだけ置く共通のランタイムです。

技 1 つは、部品をいつ、どこに出すかを並べた TypeScript の関数です。魔法の弾を撃つ技は、次のように書いています(長いので一部を省略しています)。

export function projectile(ctx: Ctx, p: P): void {
  const s = ctx.scale * p.size;
  const aim0 = aimOf(ctx);
  ctx.part('cast');
  ctx.charge(ctx.from(), p.charge, { radius: 0.55 * p.size, aim: aim0, ground: ctx.from() });
  targetCircle(ctx, 1.3 * s, p.charge + ctx.from().distanceTo(ctx.to()) / p.speed + 0.6);
  ctx.after(p.charge, () => {
    ctx.handle.emit('release');
    const count = Math.max(1, Math.round(p.count));
    for (let i = 0; i < count; i++) {
      ctx.after(i * 0.08, () => {
        // ...
        launchFlash(ctx, a, aim, p.size);
        missile(ctx, a, () => ctx.to().add(spreadTo), {
          dur: Math.max(dist / p.speed, 0.15),
          // ...
          arrive: (b) => {
            ctx.impact(b, { power: p.power, scale: (1.25 * p.size) / Math.sqrt(count) /* ... */ });
            // ...
          },
        });
      });
    }
  });
}
projectile.defaults = { circleFeet: 1, circleHand: 1, circleTarget: 0, charge: 0.35, speed: 12, curve: 0.8, size: 1, after: 3, power: 1, density: 1, count: 1, extra: 1 };

溜め(charge)を見せ、ctx.after() で溜め終わりを待ってから弾を撃ち、着弾したら爆発(impact)を出す、という流れです。ctx.after() はゲームの時間で数えるタイマーなので、ヒットストップでゲームの時間が遅くなると、技の進み方も一緒に遅くなります。閉じた式では書けなかった段取りが、ふつうの関数呼び出しの順番として書けるようになりました。

defaults に並んでいる値は、サイトでスライダーとして調整できる項目そのものです。effect() にシード値を渡すと、それぞれの値を決められた範囲の中で振り直し、同じ技の別バージョンを作ります。

export function effect(recipe: string, element = 'fire', options: EffectOptions = {}): EffectDef {
  // ...
  const seed = options.seed === undefined ? 0 : typeof options.seed === 'string' ? seedFromString(options.seed) : options.seed >>> 0;
  const base = variant(recipe, element, seed);
  return {
    ...base,
    id: options.id ?? (options.seed === undefined ? `${element}-${recipe}` : base.id),
    hue: options.hue ?? (options.color != null ? 0 : base.hue),
    ...(options.color != null ? { color: new Color(options.color).getHex() } : {}),
    params: { ...base.params, ...options.params },
  };
}

作り直しは、最初の段階で例の単体 HTML と並べた比較動画を作り、私が「同等以上」と認めてから先へ進む決まりにしました。その日のうちに 5 段階を終え、古い演出のコードは消しています。

パックを売る計画と、three.js に振り切った理由

作り直しで見栄えが良くなると、欲が出ました。Unreal Engine 向けのエフェクト集が売られているのだから、これも売れるのではないか。9 月 25 日から、書き出したスプライトシートを itch.io でパックとして売る計画を立て、ライセンスも CC0 から独自のものに切り替えました。3 日後には、MIT ライセンスで公開されているエフェクトツールの Effekseer を(コードは写さずに)研究し、竜巻や追尾ミサイル、衝撃波などの技と部品を足して、技の数は 14 本から 27 本になっています。

9 月 29 日、売る前に itch.io の様子を Claude Code に調べてもらいました。エフェクトのパックで上位に並ぶのは攻撃や呪文の素材で、しかも「手書きの Python のコードで描いたパック」を数ドルで次々に出している作者がすでにいました。計画していたのとほぼ同じことを、先にやっている人がいたわけです。一方で、three.js 向けのエフェクト素材は見当たりませんでした。

ここで私は「逆張りする」と決めました。いま売れている場所にわざわざ行かず、three.js と TSL のゲームを作る人だけに振り切ります。AI のおかげで three.js でゲームを作る人が増えている、そういう人は素材を探しに行くより AI に頼むだろう、と考えて、AI から使える形を最優先にしました。決めたことは次のとおりです。

  • ランタイムとシードによる揺らぎを、npm の rollshade として MIT ライセンスで公開する
  • サイトで書き出した素材は CC0 にし、独自ライセンスは撤回する
  • README から AI 向けの llms.txt を生成し、npm パッケージに AI 向けのスキルの説明を同梱する
  • 作ったパックは消す

AI から使えるかどうかは、実際に使わせて確かめました。空のプロジェクトで、別のモデル(Sonnet と Haiku)にスキルの説明と README と型定義だけを渡し、魔法のアリーナを作らせます。どちらも 1 回目でエラーなく動きましたが、迷った箇所(ワープの出現位置、power の大きさなど)は README に書き足しました。Haiku が状態異常を effect() で作ろうとして止まったので、取り違えたときに正しい呼び方を示すエラーも足しています。

方針転換の計画からは、サイト自体の作り直しが抜けていました。翌日に私が「アプリの画面は変わってない?」「なんか色々取りこぼしてたりする?」と聞いて気づき、サイトとギャラリーとガイドを作り直してから、10 月 1 日に 0.1.0 を公開しました。元の「ランダム生成のシェーダー」は、いまはサイトの「シェーダー」タブとして残っています。

同じ種類の部品は 1 回の描画命令にまとめる

ここからは、ランタイムの中身と、そこで踏んだ穴の話です。

ゲームのエフェクトは、1 つの技でも粒子が数百個、光の輪が数個、と細かい部品の集まりです。部品ごとに描画命令(ドローコール)を出していると、技が重なった瞬間に命令の数が膨れ上がり、CPU が追いつかなくなります。

そこで、同じ種類の部品はすべて 1 つのメッシュにまとめて描いています。粒子は火の粉や煙など 12 種類あり、種類ごとに 1 枚の四角形を インスタンシング(同じ形を位置や色だけ変えて大量に描く機能)で何千個も描きます。光の輪や魔法陣のような図形も、種類ごとに 1 つの InstancedMesh にまとめています。

粒子の動きは CPU で計算し、毎フレーム、生きている粒子の分だけ GPU 用のバッファに書き込みます。

      const q = out * 4;
      A[q] = x;
      A[q + 1] = y;
      A[q + 2] = z;
      A[q + 3] = size;
      B[q] = (x - x0) * mv;
      B[q + 1] = (y - y0) * mv;
      B[q + 2] = (z - z0) * mv;
      B[q + 3] = d[f + 10];
      // ... (C = interpolated colour * bright, alpha; D = t, seed, age, dissolve)
      out++;
    }
    this.count = n;
    this.geometry.instanceCount = out;
    for (const attr of [this.a, this.b, this.c, this.d]) {
      attr.clearUpdateRanges();
      attr.addUpdateRange(0, Math.max(out, 1) * 4);
      attr.needsUpdate = true;
    }

粒子 1 個ぶんの値を 4 つの vec4(A〜D)に詰め、instanceCount を生きている数に合わせます。addUpdateRange() で転送する範囲を生きている分だけに絞っているので、バッファを大きく確保していても(火の粉なら 6000 個ぶん)、送るのは使った分だけです。B に入れている前のフレームからの移動量は、火の粉を進む向きに引き伸ばして、速さを感じさせるのに使っています。

光の輪や魔法陣のような図形は、インスタンスごとの値がもっと多くなります。魔法陣は 21 種類の値を持つので、値ごとに別のバッファにすると、WebGPU の頂点バッファの上限(8 本)にすぐ届いてしまいます。そこで、すべての値を 1 本のバッファに交互に詰め、シェーダーの側で vec4 ずつ読み出しています。

    let at = 0;
    for (const [name, size, def] of spec.layout) {
      this.offsets.push({ name, size, at, def: def ?? 0 });
      at += size;
    }
    const vecs = Math.ceil(at / 4);
    this.stride = vecs * 4;
    const nodes: Record<string, N> = {};
    this.buffer = new THREE.InstancedInterleavedBuffer(new Float32Array(spec.cap * this.stride), this.stride);
    this.buffer.setUsage(THREE.DynamicDrawUsage);
    const reads: N[] = Array.from({ length: vecs }, (_, i) => varying(instancedDynamicBufferAttribute(this.buffer as N, 'vec4', this.stride, i * 4)));
    const comp = (i: number) => reads[Math.floor(i / 4)][['x', 'y', 'z', 'w'][i % 4]];
    for (const o of this.offsets) nodes[o.name] = o.size === 1 ? comp(o.at) : vec3(comp(o.at), comp(o.at + 1), comp(o.at + 2));
    this.mesh = new THREE.InstancedMesh(spec.geometry, spec.build(nodes), spec.cap);

layout に「名前と成分の数」を並べると、それを 4 つずつに区切ってバッファ上の位置を決め、名前で引ける TSL のノードを作ります。材質を書く側は turn や main のような名前で値を使えばよく、バッファのどこに入っているかを意識しなくて済みます。

作り直した直後(技が 14 本だった 9 月 25 日)に測ったときは、1280×720 で技を 40 本ほど同時に出しても描画命令は 100〜120 回に収まり、手元の Mac(Apple M5)の Chrome で WebGPU と WebGL2 のどちらも 60fps でした(いちばん重いフレームは 19 ミリ秒)。技は今 39 本に増えていて、この数字はまだ測り直していません。統合 GPU のノート PC でも試せていません。

シェーダーのコンパイルによるカクつき

Rollshade でいちばん長く付き合ったのが、シェーダーのコンパイルによるカクつきです。WebGPU でも WebGL2 でも、新しい材質を初めて描くときに、シェーダーをコンパイルして描画の準備(パイプライン)を作ります。ゲームの途中でこれが起きると、その間フレームが止まります。作り直したランタイムで技を 20 本同時に初めて出したときは、最悪で 1 フレーム 340 ミリ秒(9fps)まで落ちました。

対策として、ゲームの開始前にすべての部品を一度ずつ描いておく prewarm() を用意しました。部品を 1 つずつ、画面の 1000m 下に 0.001 倍の大きさで置き、まとめて描かせます。

    for (const { type, prim } of made) {
      if (type === 'arc') arcGeometry(prim.mesh.geometry, o, x, z, 0, 1, 0.5, 1);
      // ...
      prim.mesh.position.set(0, -1000, 0);
      prim.mesh.scale.setScalar(0.001);
      this.root.add(prim.mesh);
    }
    this.activate();
    for (const sys of Object.values(this.particles)) sys.primeForCompile(true);
    this.rocks.primeForCompile(true);
    this.crystals.primeForCompile(true);
    this.spikes.primeForCompile(true);
    this.prims.prime(true);
    const hidden = new THREE.Vector3(0, -1000, 0);
    const fixtures = Object.keys(FIXTURES).map((name) => this.loop(name, hidden));
    await this.compile([this.root, ...fixtures.map((f) => f.group)], onProgress);

粒子のように普段は 0 個のものも、準備の間だけ 1 個に増やしてから描きます。0 個のメッシュは描画そのものが省かれ、シェーダーも作られないからです。見えない場所に置くだけでは、画面外の物を描かない最適化(視錐台カリング)で同じく省かれるので、準備の間はそれも切っています。

  primeForCompile(on: boolean): void {
    this.geometry.instanceCount = on ? 1 : 0;
    if (on) {
      const A = this.a.array as Float32Array;
      A.set([0, -1000, 0, 0.001], 0);
      this.a.needsUpdate = true;
    }
  }

これで終わりにはなりませんでした。コンパイルは、思わぬところで何度も起きています。

光源の数とコンパイル

最初に見つかったのは光源です。発動の直後に 1 コマだけ 80〜90 ミリ秒止まるのを調べると、技が出す点光源を足したり外したりするたびに、床など光を受ける材質がすべてコンパイルし直されていました。three.js は、光源の数が変わると光を受けるシェーダーを作り直すためです。

そこで、明るさ 0 の点光源を最初に 6 個作っておき、空いているものを借りる形にしました。

  constructor(root: THREE.Object3D, size = 6, public scale = 1) {
    for (let i = 0; i < size; i++) {
      const light = new THREE.PointLight(0xffffff, 0, 8, 2);
      root.add(light);
      this.slots.push({ light, until: 0, start: 0, peak: 0, held: false });
    }
  }

  private take(): Slot | undefined {
    const free = this.slots.find((s) => !s.held && s.until <= this.time);
    if (free) return free;
    return this.slots.filter((s) => !s.held).sort((a, b) => a.until - b.until)[0];
  }

空きが無いときは、いちばん早く消える予定の光源を横取りします。光源を足したり外したりせず、明るさを 0 にするだけなので、シェーダーの作り直しが起きません。斬撃の弧のような形も、作り直すとシェーダーまで作り直しになることがあるので、使い回しています。

スライムに魔法を当てると重い

次は 9 月 30 日、デモ用に作った魔法のアリーナで遊んでいたときです。「ブロブ型のモンスターに魔法を当てると一気に遅くなります。すでに燃えてたりすると重くないのですが、何もない状態から燃えたり凍ったりするとすごく重くなります」と私が報告しました。

原因は状態異常(燃焼や凍結の殻)の作り方でした。敵ごとに材質を作り、モデルの拡大率をシェーダーに定数で埋め込んでいたため、伸び縮みするスライムは状態を掛けるたびに別のシェーダーになっていました。1 体あたり約 15 ミリ秒、掛け直すたびにプログラムが約 20 個増えます。材質を元の材質ごとに 1 つにして共有し、敵ごとの値は uniform で渡すように直すと、1〜2 ミリ秒になり、プログラムも増えなくなりました。

読み込みが遅いので、準備を 8 回に分けた

10 月 1 日、公開した直後に、今度は読み込みの遅さが気になりました。「ライブラリの読み込みだけでこれはちょっと許容範囲を超えてる」と伝えて調べてもらうと、prewarm() は同じ準備を 2 回していました。three.js の compileAsync() でポストプロセスを通さない版のパイプライン(WebGPU で 52 本)を作り、そのあとポストプロセスを通して描いて、本番で使う版(107 本)をもう一度作っていたのです。前者は、ポストプロセスを使うゲームでは一度も使われません。

そこで、ポストプロセスを使うときは compileAsync() をやめ、メッシュを 8 組に分けて、組ごとに表示を切り替えながらポストプロセスを通して描くようにしました。次のコードは、このとき(0.1.1)の版です。

    const steps = Math.max(Math.min(WARM_STEPS, meshes.length), 1);
    for (let step = 0; step < steps && !this.disposed; step++) {
      meshes.forEach((m, i) => (m.visible = i % steps === step));
      this.renderAll(roots);
      onProgress?.((step + 1) / steps);
      if (step < steps - 1) await new Promise((r) => setTimeout(r, 0));
    }

準備が終わるまでの時間は、WebGPU で約 1.5 秒から 0.9 秒に、WebGL2 で約 4.0〜5.7 秒から 1.3〜3.1 秒に縮みました。「めっちゃ早くなった!」と喜んで、0.1.1 として出しています。このとき、準備のあとにゲームの中で作られるパイプラインが「以前と同じ」であることも確かめていました。比べた相手は以前の数で、0 ではありません。

実際にゲームで使ってみて見つかった空振り

10 月 5 日、別のチャットで Claude に Rollshade を使ってゲームを作らせたところ、いくつか指摘が返ってきました。その 1 つが「状態異常の事前コンパイルは、対象がシーンに入っていないと黙って何もしない」です。これ自体は対象を一時的にシーンへ足せば済む話でしたが、ついでに調べると、原因が別に 3 つ見つかりました。

  1. ポストプロセスのシーンの描画(three.js の pass())は、1 フレームに 1 回しかシーンを描かない。8 組に分けて描いても、同じフレームの中で繰り返した 2 回目以降は何も描いていなかった
  2. 組ごとに親のメッシュを隠すとき、その子の殻まで一緒に隠れていた
  3. 状態の殻を dispose() すると three.js がパイプラインも解放し、付け直すたびにコンパイルし直していた

1 つめは、スパイクの日に動画の書き出しで踏んだのと同じ性質です。あのときは内部のフレーム番号を手で進めて避けましたが、事前コンパイルでも同じことが起きるとは気づいていませんでした。数えてみると、WebGPU では準備のあとに技の分で 6 本、状態異常は全部のパイプラインがゲームの最中に作られていました。

直し方は、ポストプロセス全体ではなく、シーンのパスだけを直接描くことです。

  warm(): void {
    const r = this.renderer;
    const { toneMapping, outputColorSpace } = r;
    r.toneMapping = THREE.NoToneMapping;
    r.outputColorSpace = THREE.ColorManagement.workingColorSpace;
    try {
      (this.loopBloomUsed ? this.loopScenePass : this.scenePass).updateBefore({ renderer: r });
    } finally {
      r.toneMapping = toneMapping;
      r.outputColorSpace = outputColorSpace;
    }
  }

準備のループは、この warm() を呼ぶように 1 引数を足しただけです。2 つめの原因のために、子にメッシュを持つメッシュは組分けの対象から外しています。

-    for (const root of roots) root.traverse((o) => (o as THREE.Mesh).isMesh && o.visible && meshes.push(o));
+    const leaf = (o: THREE.Object3D) => {
+      let inner = false;
+      o.traverse((c) => (inner ||= c !== o && (c as THREE.Mesh).isMesh));
+      return !inner;
+    };
+    for (const root of roots) root.traverse((o) => (o as THREE.Mesh).isMesh && o.visible && leaf(o) && meshes.push(o));
     const steps = Math.max(Math.min(WARM_STEPS, meshes.length), 1);
     for (let step = 0; step < steps && !this.disposed; step++) {
       meshes.forEach((m, i) => (m.visible = i % steps === step));
-      this.renderAll(roots);
+      this.renderAll(roots, true);

これで、準備のあとに作られるパイプラインは、技 39 本、状態異常 10 種、シーンの外のモデルのどれでも、WebGPU と WebGL2 の両方で 0 本になりました。その代わり、デモの読み込みは WebGPU で約 1.3 秒から 2.4 秒に、WebGL2 で約 1.6 秒から 3.2 秒に伸びています。空振りしていたコンパイルが、ゲームの最中から読み込みの間に移ったぶんです。0.1.1 で縮んだ読み込み時間には、本来やるべきコンパイルの一部が入っていなかったことになります。

コンパイルが走ったかどうかは、見た目ではまず分かりません。いまは、three.js の内部のキャッシュ(renderer._pipelines)を包んで、パイプラインが作られるたびに記録し、準備のあとに作られた数を数えて確かめています。これも公開 API ではないので、three.js を上げるたびに確かめ直す前提です。

重なった粒子と、自動の画質調整

コンパイルとは別に、重さの原因はもう一つあります。画面の広い範囲を半透明の粒子が何枚も重なって覆うと、1 つのピクセルを何十回も塗ることになります(オーバードロー)。

一つめの対策は、炎や煙の揺らぎに使うノイズの計算を減らしたことです。最初はピクセルごとに 3 次元のノイズを何段も重ねて計算していたのを、起動時に CPU で作った 256×256 の繰り返し模様のテクスチャを読む形に変えました。

function fbm(seed: number, octaves: number) {
  const noise = periodicNoise(seed);
  return (u: number, v: number) => {
    let sum = 0;
    let amp = 1;
    let period = PERIOD;
    for (let o = 0; o < octaves; o++) {
      sum += noise(u * period, v * period, period) * amp;
      amp *= 0.5;
      period *= 2;
    }
    return sum * 0.5 + 0.5;
  };
}
// ...
export const tnoise = (uv: any): any => texture(noiseTexture(), uv);

細かさを倍にしながら振れ幅を半分にしたノイズを重ねる、よくある作り方(fBm)です。periodicNoise() は、格子の番号を周期で折り返すので、テクスチャの端と端がつながります。周期も段ごとに倍にしているので、何段重ねても継ぎ目が出ません。煙や炎の粒は、このテクスチャを 2 回読むだけになり、5120×2880 という極端な解像度で隕石の技は 57 ミリ秒から 26 ミリ秒になりました1。

二つめとして、粒子が重なった場面をさらに軽くする方法を 2 つ入れました。

  • A:大きさの上限:粒子の大きさを画面の高さの半分までに抑え、カメラの直前に来た粒子は薄くして消す
  • B:自動の画質調整:重くなったら、粒子の量と描く解像度を自動で下げる(quality: 'auto')

同じ 5120×2880 での 1 フレームの時間(中央値)は次のとおりです。

場面対策前A のみA と B
40 本同時72 ms81 ms24 ms
カメラ間近の隕石22 ms23 ms17 ms

大きさの上限は、40 本同時の場面ではほとんど効いていません(中央値ではかえって悪化しています)。カメラ間近の場面では、たまに来る重いフレームを抑えるのに効きました(90 パーセンタイルで 82 ミリ秒から 59 ミリ秒)。40 本同時が 24 ミリ秒まで下がったのは、B が粒子の量と解像度を下げたためです。

その B にも副作用がありました。その指摘の 1 つが「quality: 'auto' が起動直後のカクつきで最低品質まで落ち、1 分以上戻らないことがある。画面もぼやける」です。起動直後はシェーダーの準備などでフレームが長くなりやすく、それを「重い」と判断して画質を下げていました。起動と事前準備のあとの 2 秒は判断しない、1 フレームの長さは予算の 2 倍で頭打ちにする、下げるのは重さが 1 秒続いてから、という条件を足しています。

WebGL2 だけ画面が真っ黒になる

9 月 29 日、魔法陣やセーブポイントのような「置いておくエフェクト」を足したところ、WebGL2 で画面全体が真っ黒になりました。WebGPU では起きません。

原因は、物の縁を光らせる計算(フレネル)でした。1 - |法線と視線の内積| を求め、これを pow() で何乗かして縁の光り方を調整しています。理屈では 0 以上になるはずの値が、丸め誤差でわずかに負になり、負の数の小数乗で NaN(数ではない値)になっていました。NaN が 1 ピクセルでも混ざると、周りをぼかすブルームがそれを広げ、画面が黒く塗りつぶされます。

-    fres: pow(float(1).sub(abs(dot(normalView, positionViewDirection))), 1.5) as N,
+    fres: pow(float(1).sub(abs(dot(normalView, positionViewDirection))).max(0), 1.5) as N,

.max(0) を 1 つ足すだけの修正です(上は状態異常の殻の差分です)。同じ形の式を使っていた設置エフェクト、球、光線、盾、闇の球、回転体にも入れて回り、2 つのコミットに分かれました。

加算合成の白飛びと、増えすぎた煙

光のエフェクトは、下の色に明るさを足していく 加算合成 で描いています。重なるほど明るくなるので光らしく見える一方、炎の技を何発も重ねると、赤も橙も足し合わされて真っ白になります。

白飛びには、原因が段階的に重なっていました。まず 0.3.0 で、ゲームを作らせていた別のチャットの Claude から「ブルームが画面全体にかかり、ゲーム側のモデルまで白飛びする」と指摘されました。ブルームの既定を、エフェクトが書いた分だけに掛ける形に変えています。

10 月 6 日の 0.5.0 では、私自身が「ブルームを 0 にしても白い」と気づきました。調べると原因は 3 つで、属性ごとの芯の色がほぼ白だったこと、粒の明るいところを白へ寄せる処理、加算の重なりです。3 つともパラメータにし、既定値はサイトの演出モードのつまみで私が見て決めました。3 つめの加算の重なりには、色合いを保ったまま明るさに上限をかける処理を入れています。

    const fx: N = look(scenePass.getTextureNode('glow'), this.chroma);
    const capped = Fn(() => {
      const l: N = max(max(fx.r, fx.g), fx.b);
      const k: N = this.limit.mul(0.6);
      const e: N = max(l.sub(k), 0);
      const target: N = k.add(e.div(e.div(this.limit.sub(k)).add(1)));
      return fx.mul(select(this.limit.greaterThan(0).and(l.greaterThan(k)), target.div(max(l, 1e-4)), float(1)));
    })();

RGB のうちいちばん明るい成分 l を見て、上限の 6 割(k)を超えた分だけを、上限に向かってなだらかに縮めます。縮めるときは R、G、B に同じ倍率を掛けるので、成分の比、つまり色合いは変わりません。成分ごとに 1 で切り詰めると、赤だけが先に頭打ちになって緑と青が追いつき、白に寄っていきます。調整の途中で試した値では、真っ白になった画素の数が、炎の弾で 3725 から 0、隕石で 39996 から 0 になりました。

同じ日に「炎が当たったときの煙が多すぎる」とも感じていました。調べると、着弾の煙の数が技の大きさ(scale)の 2 乗で増えるバグでした。粒の数をすでに scale 倍していたところへ、もう一度 scale を掛けていたのです。直すと、scale 2 の爆発で 1169 粒から 322 粒になりました。ところが、それでも多いと感じたものをよく見ると、煙だと思っていたのは炎の粒そのものでした。煙の量とは別に、すべての粒の量を変えられる設定も足しています。

衝撃波の歪みが、上下逆の場所に出る

画面を歪ませる衝撃波でも、同じゲーム作りの中で「着弾と同時に、関係のない左下〜右下あたりの空間だけが歪む」と報告がありました。歪みの中心を、画面上の位置から uv 座標に変換するときの向きが逆でした。ポストプロセスで画面全体に貼る板(QuadMesh)の uv は上端が 0 なので、y を反転させる必要があります。

-    this.zoomCenter.value.set(s.x * 0.5 + 0.5, s.y * 0.5 + 0.5);
+    this.zoomCenter.value.set(s.x * 0.5 + 0.5, 0.5 - s.y * 0.5);

画面の真ん中に出した場合だけは、上下が逆でも同じ場所になります。

動画の書き出し、npm の公開、配信

サイトでは、エフェクトをループする動画(MP4 と透過 WebM)にも書き出せます。Safari では WebGPU のキャンバスから取り込んだフレームが 1 枚遅れたり、H.264 のエンコーダーがエラーも出さずに止まったり、キーフレームの直後のフレームを落としたりと、癖がいくつもありました。4 つのサービスでの MP4 の書き出しをまとめたブラウザで MP4 を書き出す記事に詳しく書いたので、そちらを見てください。

npm の公開でもつまずきました。npm publish が E404 を返しましたが、原因は npm のログインが切れていたことでした(npm whoami が 401 を返します)。書き込む権限が見えないときも 404 になるためです。0.4.0 では、CHANGELOG の日付を入れる前に公開してしまい、「Unreleased」のまま npm に載っています。

three.js のバージョンは、peerDependencies で 0.186 系の 1 つに固定しています。TSL と WebGPU まわりはまだ変化が速く、リリースによって名前や挙動が変わるためです2。また、three.js は必ず three/webgpu から読み込むよう README に書いています。three と three/webgpu の両方から読み込むと、ページに three.js が 2 つ入り、TSL の材質が正しく動かなくなるためです。

サイトは Cloudflare Pages に置き、Pages Functions でセキュリティのヘッダーを付けています。ほかのサービスと共通の作りは、Cloudflare Pages の記事にまとめました。

いま残っていること

2 週間で方針を 2 回変えましたが、振り返ると、大きな作り直しのきっかけの多くは、実際に使う場面で見たことでした。演出が地味だと分かったのは別の HTML と並べたときで、事前コンパイルの空振りが分かったのは、実際にゲームへ組み込んで使ったときです。

性能の数字はすべて Apple M5 の Mac で測ったもので、統合 GPU のノート PC ではまだ確かめていません。

  1. この解像度では、何も描かない状態でも 1 フレームに 19 ミリ秒かかります。 ↩
  2. たとえば、ポストプロセスの PostProcessing は r183 で RenderPipeline に名前が変わりました。 ↩

← 記事一覧へ