[Tauri #10] WiredFrame v0.2.0 公開:ノイズ6モードと、スライダーが自動で増える設定の作り方

[Tauri #10] WiredFrame v0.2.0 公開:ノイズ6モードと、スライダーが自動で増える設定の作り方

はじめに

前回(#09)で、v0.1.0を公開しました。今回は、予告していたとおり、過去に作ったノイズ系のアニメーションを、WiredFrameのモードとして追加した v0.2.0 の記録です。

モードは6つ増えました。ただし、5つは過去作の移植です。元ネタの解説は「Noise 入門」の各回にあるので、ここでは繰り返しません。この記事に書くのは、次の3つです。

  • 過去作を、スクリーンセーバーのモードとして載せるときの型
  • 調整項目を1行足すと、設定パネルのスライダーが自動で増える仕組み
  • 6つ目の万華鏡(今回の新作)を、何度か作り直した記録

スクリーンショット

ノイズ6(万華鏡)

[Tauri #10] ノイズ6(万華鏡)。ピンクの花びらのような模様

色の鮮やかさを下げて、銀色に寄せた例です。

[Tauri #10] ノイズ6(万華鏡)を、色の鮮やかさを下げて銀色に寄せた例

ノイズ3(ワイヤーフレーム地形)

[Tauri #10] ノイズ3(ワイヤーフレーム地形)

ノイズ4(ドメインワーピング)

[Tauri #10] ノイズ4(ドメインワーピング)

ノイズ5(ルーン)

[Tauri #10] ノイズ5(ルーン)

設定パネルの「詳細な調整」

[Tauri #10] 設定パネルの「詳細な調整」。モードごとのスライダーが並ぶ

1. 何を足したか

v0.1.1のあと、v0.2.0までに入れたものは次のとおりです。

追加内容
ノイズ1粒子(Curl Noise × Audio、#58)。GPUで10万個以上を動かす
ノイズ2Mandelbulb(#60)。レイマーチング
ノイズ3ワイヤーフレーム地形(#57)。頂点をノイズで持ち上げる
ノイズ4ドメインワーピング(#56)。全画面シェーダー
ノイズ5ルーン(#55)。3Dボロノイの全画面シェーダー
ノイズ6万華鏡。元ネタなしの新作
共通詳細な調整、描画解像度、FPS上限、無操作起動をESCのみ終了にする設定

モードはモニターごとに選べます。「メインはタイル、サブはノイズ」のような組み合わせも、設定だけでできます。

2. モードを足す型

タイルも、ノイズも、同じインターフェースを実装しています。

export interface IMode {
  init(scene, camera, renderer?): void;
  update(delta: number, time: number): void;
  dispose(): void;
  // 以下は任意
  setBrightness?(percent: number): void;
  setParams?(values: Record<string, number>): void; // 調整スライダーの値
  getRenderScale?(): number;                         // 描画解像度の倍率
  render?(renderer): void;                           // 描画を自前で行う
  onResize?(): void;
}

必須なのは3つだけです。ポストエフェクトが要るモードは render を、重いモードは getRenderScale を、それぞれ足します。

モードの作り方は、main.ts の1つの表にまとめました。

// ノイズ系モード(設定の mode 名 → 作り方)。新しいノイズはここに 1 行足す
const NOISE_MODES: Record<string, (s: AppSettings) => IMode> = {
  noise1: (s) => new NoiseMode1(s.noiseSize),
  noise2: () => new NoiseMode2(),
  noise3: () => new NoiseMode3(),
  noise4: () => new NoiseMode4(),
  noise5: () => new NoiseMode5(),
  noise6: () => new NoiseMode6(),
};

最初は、モードが増えるたびに if の分岐を足していました。ノイズ5まで来たところで、表に直しました。いまは、新しいモードを足すときに触る場所は、次の4つです。

  1. src/modes/NoiseModeN.ts(モード本体)
  2. src/settings.ts の MODES
  3. index.html の表示モードの選択肢
  4. 上の NOISE_MODES に1行

モードを切り替えるときは、古いモードを dispose してから新しく作ります。dispose で、geometry・material・テクスチャ・render targetを必ず解放します。6枚のモニターで、何時間も動かすものなので、ここを忘れると確実にメモリが増えます。

3. 調整スライダーが自動で増える

過去作には、lil-guiの調整パネルが付いていました。WiredFrameでは、これを設定パネルの「詳細な調整」に置き換えました。ただし、モードごとに、パネルのコードを書くのは避けたかったのです。

そこで、調整できる項目をデータとして定義して、パネルはそこからスライダーを作るようにしました。

export type ParamDef = {
  key: string;
  label: string;
  min: number;
  max: number;
  step: number;
  default: number;
};

export const NOISE6_PARAMS: ParamDef[] = [
  { key: 'renderScale', label: '描画の解像度(小さいほど軽い)', min: 0.25, max: 1, step: 0.05, default: 0.75 },
  { key: 'segments', label: '鏡の数(花びらの数)', min: 2, max: 24, step: 1, default: 8 },
  { key: 'warp', label: '歪みの強さ(大きいほど渦巻く)', min: 0, max: 3, step: 0.1, default: 1.4 },
  // …
];

export const MODE_PARAMS: Record<string, ParamDef[]> = {
  noise1: NOISE1_PARAMS,
  // …
  noise6: NOISE6_PARAMS,
};

設定パネルは、選ばれたモードの MODE_PARAMS を読んで、range 入力を並べるだけです。モード側は setParams(values) を受け取って反映します。

機能どこで動くか
スライダーの生成共通(MODE_PARAMS から)
0.4秒止まったらプレビュー共通
モニターごとの保存共通(modeParams[モード名])
初期値に戻す共通(paramDefaults)
範囲外・欠けた値の補正共通(clampParams)

モード側で書くのは、ParamDef の配列と、setParams だけです。保存された値の検証は、clampParams が、不足を既定値で補い、範囲外を丸めます。古い設定が残っていても、壊れません。

ここで1つ、気をつけた点があります。時間は、積算で持つことです。

update(delta: number, _time: number) {
  const p = this.p;
  this.flow += delta * p.speed;         // 「現在時刻 × 速さ」ではなく、加算する
  this.rot += delta * p.rotation;
  if (p.colorCycle > 0) this.huePhase = (this.huePhase + delta / p.colorCycle) % 1;
  // …
}

時刻 × 速さ で位相を決めると、スライダーで速さを変えた瞬間に、絵が飛びます。delta × 速さ を足していく形なら、速さを変えても、いまの位置から続きます。調整しながら見るものなので、これは大事でした。

4. 6枚で動かすための負荷対策

6枚のモニター(うち1枚は3840×1600)で、全画面のシェーダーを動かすと、重さが問題になります。使った手は2つです。

描画解像度(renderScale)

内部の解像度だけを下げて、画面には引き伸ばします。CSSの大きさは変えません。

public setRenderScale(scale: number) {
  const s = Math.min(1, Math.max(0.25, scale));
  this.renderScale = s;
  this.renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2) * s);
  this.renderer.setSize(window.innerWidth, window.innerHeight);
  this.currentMode?.onResize?.();
}

0.5にすると、計算量は約1/4になります。ぼかしの多い絵(ノイズ4や6)なら、見た目はほとんど変わりません。逆に、細い線で描くノイズ5は、下げると線が点々になります。モードごとに、既定値を変えてあります。

FPS上限(15 / 30 / 60 / 120)

これはモニターごとの設定です。間引いても動く速さが変わらないよう、ノイズ1は u_dt(60fps基準の経過時間)で補正しています。

モードごとの重さの傾向は、次のとおりです。

モード負荷の理由軽くする手
ノイズ1粒子数(noiseSize²)。1粒子ごとに毎フレームsimplexを18回粒子数、FPS
ノイズ2全ピクセルでレイを飛ばす描画解像度、ステップ数、反復回数
ノイズ3頂点数(分割数²)に比例分割数、範囲
ノイズ4全ピクセルでfbmを3回描画解像度、細部の層
ノイズ5全ピクセルで3Dボロノイを3回(色収差)描画解像度、色収差を0に(1回で済む)
ノイズ6fbmを3回+陰影描画解像度、細部の層

ノイズ5の色収差は、R/G/Bをずらして3回ボロノイを引いています。0にすると1回だけになり、3分の1の重さです。

5. カメラのない全画面シェーダー

ノイズ4・5・6は、3D空間に何も置かない、2Dの絵です。この種のモードでは、カメラを使わないほうが素直でした。

void main() {
  vUv = uv;
  gl_Position = vec4(position.xy, 0.0, 1.0); // カメラに関係なく画面全体
}

2×2の板を、クリップ座標のまま画面全体に貼ります。カメラの位置や画角に関係なく全画面になり、OrbitControlsも要りません。回しても意味のない絵なので、マウス操作も付けていません(ノイズ1〜3は、ドラッグで回せます)。

縦横比は、u_aspect(ウィンドウの幅 / 高さ)を座標に掛けて補正します。縦置きの1200×1600でも、模様が歪みません。

6. 万華鏡(ノイズ6):何度か作り直した

ここまでは移植でした。6つ目だけは、元ネタなしの新作です。

きっかけは、「新しいオリジナルを足してみよう」という話でした。画像検索で出てくる万華鏡の画像(ピンクの花びら、青い蓮、白と青の宝石)を見て、あの質感を目指しました。

最終的な仕組み

// 角度の鏡: [0, π/N] に折り返す
float halfSeg = 3.14159265 / u_seg;
float a = atan(uv.y, uv.x) + u_rot;
a = abs(mod(a, 2.0 * halfSeg) - halfSeg);

// 半径の鏡: 三角波でリング状に折り返す
float rm = abs(fract(r * u_ringFreq - u_ringZoom) - 0.5) * 2.0 / u_ringFreq;

vec2 p = rm * vec2(cos(a), sin(a)) * u_scale;

// ドメインワーピングで、滑らかな凹凸の高さ h を作る
vec2 w = vec2(fbm(p + u_time * 0.2), fbm(p + vec2(5.2, 1.3) - u_time * 0.15));
float h = fbm(p + w * u_warp);
  1. 座標を、角度と半径の両方で鏡のように折り返す。
  2. 折り返した座標でノイズ(simplexのfbm)を引いて、滑らかな高さ h を作る。
  3. dFdx(h) / dFdy(h)(隣のピクセルとの差)から法線を作り、拡散光と鏡面光で陰影を付ける。
  4. 色は、色相をずらした2色を、高さで混ぜる。

鏡の継ぎ目では値が飛ぶので、そこが花びらの縁のようにくっきり出ます。これは狙った効果です。外側を暗く落とすと、奥行きが出て、中心に穴を開けると、ブラックホールのようになります。

没にしたもの

最初から、この形だったわけではありません。

  • Kali型フラクタル(q = |q|/(q·q) − c の繰り返し):模様はできるものの、「数式の線」で、写真のような質感になりませんでした。
  • 2D KIFS(折り返し・回転・拡大の繰り返し):派手な虹色になって、これも没にしました。

どちらも、画像検索で見た万華鏡の、滑らかな質感とは別物でした。そこで、方針を変えました。滑らかなノイズ+鏡+陰影+2色に絞ったところ、ようやく近づきました。

失敗の記録

次の3つは、同じ失敗を繰り返さないよう、docs/ にも残しました。

  • 凹凸を強くすると、レコード盤になる。凹凸の強さやリングの折り返しを増やすと、細かい同心円の縞になります。既定は凹凸0.5、リング1.2です。
  • half は、GLSL ESの予約語。変数名に使ったら、シェーダーのコンパイルが失敗して、画面が真っ黒になりました。エラーの出るコンソールを見ないと、原因が分かりません。いまは halfSeg にしています。
  • 色の鮮やかさは、下げたほうが面白い。色の鮮やかさを0.05まで落とすと、銀色の金属のような万華鏡になりました。これは設定で変えただけで、コードは同じです。

7. 無操作起動のセーバーを、ESCのみ終了にした

v0.1.0では、無操作で自動起動したセーバーは、どのキー・マウス操作でも終了していました。席を離れている間だけ動かす使い方のためです。

ただ、ノイズ1〜3は、マウスで画面を回せます。マウスを動かした瞬間に終了すると、せっかくの操作が使えません。そこで、設定を足しました。

  • 「自動で起動したセーバーは、キー・マウス操作で終了する」(初期値 OFF)
  • OFFのときは、ESCだけで終了する。マウスを動かしても終了しない

ここで、問題が1つありました。自動起動では、ウィンドウにキーボードのフォーカスが来ないことがあります。画面側でESCを待っても、キーが届きません。

そこで、ESCはRust側で見張ることにしました。GetAsyncKeyState(VK_ESCAPE) を、50msおきに読みます。フォーカスに関係なく、システム全体のキーの状態が取れます。

/// ESC キーが今押されているか(どのウィンドウにフォーカスがあっても分かる)。
#[cfg(windows)]
fn esc_down() -> bool {
    use windows_sys::Win32::UI::Input::KeyboardAndMouse::{GetAsyncKeyState, VK_ESCAPE};
    let state = unsafe { GetAsyncKeyState(VK_ESCAPE as i32) };
    (state as u16) & 0x8000 != 0 // 最上位ビットが「今押されている」
}

見張るスレッド(watch_esc)は、自動起動したセーバーが動いているあいだだけ判断します。起動から2秒の猶予を過ぎて、ESCが押されていたら終了します。

ONにしたときは、従来どおり、GetLastInputInfo で操作を見て終了します。

8. 開発の進め方

実装はClaude Codeに書かせて、自分は実機でのデバッグ係でした。前回と同じ流れです。今回は、1つだけ違うことがありました。

シェーダーは、AIの環境にも、ヘッドレスのChromiumがあります。ソフトウェア描画(SwiftShader)なので、速さは測れませんが、絵が出るか、真っ黒でないか、縦横比が崩れていないかは確認できます。実機に渡す前に、横長と縦長の2サイズで、既定値と、いくつかのスライダー値の画面を撮って、見た目を確かめてもらいました。half の件も、この確認で、真っ黒になることから見つかりました。

一方で、次のことは実機でしか分かりません。

  • 6枚のモニターを同時に動かしたときの負荷
  • 3840×1600での動き
  • 実際に動いたときの、色や速さの印象

ノイズ6は、実機で動かしてみて、ようやく良さが分かりました。スクリーンショットでは、動きの滑らかさが伝わりません。

決めたことと、試して分かったことは、これまでどおり docs/ のMarkdownに残しています(docs/noise-mode.md、docs/new-mode.md)。

まとめ

  • モードは IMode で統一し、作り方は NOISE_MODES の表に集める。足す場所は4つ
  • 調整項目は ParamDef というデータで書く。設定パネル・プレビュー・保存・初期値に戻す・値の検証は、すべて共通処理で動く
  • 時間は、現在時刻 × 速さ ではなく、delta × 速さ を積算する。スライダーで絵が飛ばない
  • 重さは、描画解像度とFPS上限で逃がす。細い線のモードは解像度を下げすぎない
  • 2Dの絵は、カメラを使わず、板を画面全体に貼る
  • 万華鏡は、数式の線ではなく、滑らかなノイズ+鏡+陰影+2色で、質感が出た
  • 自動起動のESCは、フォーカスに頼らず、Rustでシステム全体のキー状態を見る

v0.2.0はGitHubのReleasesから入手できます。使い方は、紹介ページにまとめています。