[Tauri #10] WiredFrame v0.2.0 公開:ノイズ6モードと、スライダーが自動で増える設定の作り方
はじめに
前回(#09)で、v0.1.0を公開しました。今回は、予告していたとおり、過去に作ったノイズ系のアニメーションを、WiredFrameのモードとして追加した v0.2.0 の記録です。
モードは6つ増えました。ただし、5つは過去作の移植です。元ネタの解説は「Noise 入門」の各回にあるので、ここでは繰り返しません。この記事に書くのは、次の3つです。
- 過去作を、スクリーンセーバーのモードとして載せるときの型
- 調整項目を1行足すと、設定パネルのスライダーが自動で増える仕組み
- 6つ目の万華鏡(今回の新作)を、何度か作り直した記録
スクリーンショット
ノイズ6(万華鏡)
色の鮮やかさを下げて、銀色に寄せた例です。
ノイズ3(ワイヤーフレーム地形)
ノイズ4(ドメインワーピング)
ノイズ5(ルーン)
設定パネルの「詳細な調整」
1. 何を足したか
v0.1.1のあと、v0.2.0までに入れたものは次のとおりです。
| 追加 | 内容 |
|---|---|
| ノイズ1 | 粒子(Curl Noise × Audio、#58)。GPUで10万個以上を動かす |
| ノイズ2 | Mandelbulb(#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つです。
src/modes/NoiseModeN.ts(モード本体)src/settings.tsのMODESindex.htmlの表示モードの選択肢- 上の
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回で済む) |
| ノイズ6 | fbmを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);
- 座標を、角度と半径の両方で鏡のように折り返す。
- 折り返した座標でノイズ(simplexのfbm)を引いて、滑らかな高さ
hを作る。 dFdx(h)/dFdy(h)(隣のピクセルとの差)から法線を作り、拡散光と鏡面光で陰影を付ける。- 色は、色相をずらした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から入手できます。使い方は、紹介ページにまとめています。
[Desktop App] WiredFrame — 画像フォルダの写真を、全モニターで動かすスクリーンセーバー
インストール、画像フォルダの登録、モニターごとの設定、起動と終了、ノイズモード、よくある質問。
lain-lab.comRelease WiredFrame v0.2.0 · fixtan/WiredFrame
ノイズモード6種と、モードごとの詳細な調整、FPS上限を追加。
github.com