[JavaScript] Lain Clock スマホ対応 — UI折りたたみ・カメラ自動調整・設定リアルタイムプレビュー
はじめに
Three.js製MMD時計アプリ「Lain Clock」をスマホに対応させたメモ。PCブラウザ向けに作ったUIをそのままモバイルで開くと、ツールバーが画面を圧迫し、3Dモデルが巨大に表示され、不要なボタンが散らばる。CSSメディアクエリとカメラの自動調整で解決した。
1. ツールバーのコンパクト化
問題
デスクトップ版のツールバーは、アイコン+ラベル(Model, Info, Rig, Debug, Wallpaper, Settings)が横一列に並ぶ。375px幅のスマホでは完全にはみ出す。
解決策
@media (max-width: 768px) でラベルを非表示にし、アイコンのみの表示に切り替えた。
@media (max-width: 768px) {
.tool__label {
display: none;
}
.toolbar {
top: 8px;
right: 8px;
gap: 4px;
padding: 5px;
border-radius: 10px;
}
.tool {
height: 30px;
padding: 0 7px;
border-radius: 8px;
gap: 0;
}
}
Inspectorのトグルスイッチもラベルを非表示にし、スイッチ自体を小さくした。
さらにモバイルではツールバーをデフォルトで折りたたみ状態にした。既存の is-collapsed クラスの仕組みをそのまま利用している。
const isMobile = window.matchMedia('(max-width: 768px)').matches;
if (isMobile) {
const toolbar = document.querySelector('.toolbar');
if (toolbar && !toolbar.classList.contains('is-collapsed')) {
toolbar.classList.add('is-collapsed');
}
}
2. HUDの折りたたみ機構
問題
左上のHUD(MODEL / RIG情報)がツールバーと重なる。モバイルでは常に表示する意味が薄い。
解決策
HUDにトグルボタンを追加し、折りたたみ可能にした。
<div class="hud">
<button class="hud__toggle" title="Toggle HUD">❯</button>
<div class="hud__body">
<div class="hud__row">
<span class="hud__tag">MODEL</span>
<span class="hud__text" id="hudModelText">-</span>
</div>
<div class="hud__row">
<span class="hud__tag">RIG</span>
<span class="hud__text" id="hudRigText">-</span>
</div>
</div>
</div>
CSSは width と opacity のトランジションで開閉アニメーションを実現した。
.hud__body {
display: grid;
gap: 6px;
overflow: hidden;
transition: width 0.3s ease, opacity 0.2s ease;
}
.hud.is-collapsed .hud__body {
width: 0;
opacity: 0;
pointer-events: none;
}
.hud.is-collapsed .hud__toggle {
transform: rotate(180deg);
margin-right: 0;
}
モバイルではデフォルトで折りたたみ状態にしている。展開したいときは矢印ボタンをタップすればいい。
3. 不要なボタンの非表示
方針
スマホで必要なボタンと不要なボタンを整理した。
- File — ZIPモデルや壁紙の読み込みに使う → 残す
- Chat — テキスト入力が面倒 → 非表示
- Voice — スマホでは使わない → 非表示
- VR NOT SUPPORTED — Three.jsの
VRButtonが自動生成 → 非表示
Chat・Voiceは単純にCSSで消せる。
@media (max-width: 768px) {
.voice-toggle { display: none !important; }
.chat-toggle { display: none !important; }
.chat-panel { display: none !important; }
}
VRボタンは VRButton.createButton() が生成するDOM要素に id がなかったので、まず app.js で付与する。
const btn = VRButton.createButton(config.renderer);
btn.id = "vr-button";
document.body.appendChild(btn);
あとはCSSで消すだけ。
@media (max-width: 768px) {
#vr-button { display: none !important; }
}
Three.jsの VRButton はXR APIの有無で「Enter VR」か「VR NOT SUPPORTED」を出し分けるが、どちらにせよスマホでは不要なので丸ごと非表示にしている。
4. 横画面対応
スマホを横に倒した時のレイアウトも用意した。
@media (max-width: 768px) and (orientation: landscape) {
.toolbar {
flex-direction: column;
max-height: calc(100dvh - 12px);
overflow-y: auto;
}
.toolbar__sep {
width: 100%;
height: 1px;
}
.panel {
width: min(320px, 50vw);
height: calc(100dvh - 48px);
}
.file-toggle {
right: auto;
left: 10px;
bottom: 8px;
}
}
横画面ではツールバーを縦配置(flex-direction: column)に切り替え、設定パネルは画面の右半分に制限する。Fileボタンはツールバーと被らないよう左下に移動した。
5. カメラの自動Z調整
問題
Three.jsの PerspectiveCamera は垂直方向のFOVを基準にしている。横画面では aspect > 1 なので水平方向のFOVが広がり、被写体が適切なサイズで映る。しかし縦画面では aspect < 1 になり、水平方向のFOVが極端に狭まるため、MMDモデルがドアップに表示される。
解決策
縦画面時にカメラのZ位置を自動的に後ろに引く。
function adjustCameraForAspect() {
if (config.renderer?.xr?.isPresenting) return;
const aspect = innerWidth / innerHeight;
if (aspect < 1) {
config.camera.position.z = 25 / aspect * 0.75;
} else {
config.camera.position.z = 25;
}
}
計算式は 25 / aspect * 0.75。iPhone SE(aspect ≈ 0.56)で Z ≈ 33.5 になり、約1.3倍引く。0.75 の係数は引きすぎを防ぐための緩和値で、実機テストで決めた。
この関数は初期化時とリサイズ時の両方で呼ぶ。VR中は無視する。
// 初期化時
config.camera = new THREE.PerspectiveCamera(35, innerWidth / innerHeight, 0.1, 300);
config.camera.position.set(0, 0, 25);
adjustCameraForAspect();
// リサイズ時
addEventListener("resize", () => {
config.camera.aspect = innerWidth / innerHeight;
config.camera.updateProjectionMatrix();
adjustCameraForAspect();
// ...
});
FOVを変更する方式も検討したが、FOVを変えるとパースが変わってしまう。Z位置の調整なら、見た目の印象を変えずに全体を縮小できる。
6. 設定のリアルタイムプレビュー
問題
前日のアップデートで設定パネルの値変更がリアルタイムに反映されるようになったが、時計の位置・スケール(Margin W/H, Scale)は変更しても画面に反映されなかった。色やアルファは即座に変わるのに、位置だけ動かないのは不便だった。
原因
applySettingsToRuntime() が config の値を更新するだけで、resizeClock() と resizeClockDigital() を呼んでいなかった。色やアルファはマテリアルのプロパティを直接変更するので即反映されるが、位置・スケールはリサイズ関数がconfig値を読んで再計算する設計になっている。
解決策
settings-apply.js でリサイズ関数をインポートし、設定適用後に呼び出すようにした。
import { applyUITheme, resizeClockDigital } from "./clockDegtal.js";
import { resizeClock } from "./clock.js";
export function applySettingsToRuntime(settings) {
// ... 各種config値の更新 ...
applyUITheme();
// 位置・スケール変更を即時反映(初期化前はスキップ)
if (config.camera) {
resizeClock();
resizeClockDigital();
}
}
config.camera のnullチェックが必要なのは、main() の実行順序の問題。applySettingsToRuntime() は sceneInit() よりも前に呼ばれるため、初回呼び出し時にはカメラがまだ存在しない。ガードがないと getViewSizeAtWorldZ() 内で config.camera.position にアクセスしようとしてクラッシュする。
まとめ
今回の変更一覧。
- ツールバー: モバイルでアイコンのみ表示、デフォルト折りたたみ
- HUD: トグルボタン追加、モバイルでデフォルト折りたたみ
- Chat・Voice: モバイルで非表示
- VRボタン: id付与 + モバイルで非表示
- 横画面: ツールバー縦配置、パネル幅制限
- カメラZ自動調整: 縦画面でアスペクト比に応じて引く
- 設定リアルタイムプレビュー: 位置・スケール変更の即時反映
Three.jsアプリのモバイル対応は、CSSだけで解決できる部分と3D空間側の調整が必要な部分がある。UIレイヤーはHTML/CSSのメディアクエリで処理し、3D空間側はカメラ位置の自動調整で対応する。両方をやって初めてまともに動く。