loomlet

Loom SceneSync アダプタ

1. 概要

Loom SceneSync アダプタ(src/loom-scenesync.js)は、Loom のステートレスなグラフ評価を SceneSync メッセージプロトコルで制御する。

特徴

使用例

import { Loom } from "./loom.js";
import { LoomSceneSync } from "./loom-scenesync.js";

const adapter = new LoomSceneSync({
  LoomClass: Loom,
  send: (msg) => socket.send(JSON.stringify(msg)),
  getServerTime: () => syncedClock.now(),
  resolveTarget: (targetId) => objects.get(targetId) ?? null
});

// メッセージ受信
socket.on("message", (data) => {
  adapter.handleMessage(JSON.parse(data));
});

// グラフ実行開始
adapter.start();

Outbound helper usage

Loom から SceneSync 互換メッセージを組み立てる場合は、手書きの object literal ではなく adapter の helper を使う。

const graphMsg = adapter.createGraphSetMessage(
  { object: "cube1" },
  {
    nodes: [
      { id: "clock", type: "serverClock" },
      { id: "pos", type: "sceneSetPosition", params: { target: "cube1" } }
    ],
    edges: [{ from: "clock.t", to: "pos.x" }]
  }
);

socket.send(JSON.stringify(graphMsg));

adapter.sendInput("scene", "pointer.move", { x: 120, y: 80 });

2. メッセージプロトコル

scene-graph-set

グラフを設定(または上書き)する。

{
  "type": "scene-graph-set",
  "scope": "scene",
  "graph": {
    "nodes": [
      { "id": "clock", "type": "serverClock" },
      { "id": "pos", "type": "sceneSetPosition", "params": { "target": "cube1" } }
    ],
    "edges": [
      { "from": "clock.t", "to": "pos.x" }
    ]
  }
}

オブジェクト単位グラフの場合:

{
  "type": "scene-graph-set",
  "scope": { "object": "cube1" },
  "graph": { "nodes": [], "edges": [] }
}

動作:

scene-graph-clear

グラフをクリア(削除)する。

{
  "type": "scene-graph-clear",
  "scope": "scene"
}

または:

{
  "type": "scene-graph-clear",
  "scope": { "object": "cube1" }
}

動作:

scene-graph-patch

差分更新用(Phase 1 では graph フィールドがあれば scene-graph-set と等価)。

{
  "type": "scene-graph-patch",
  "scope": "scene",
  "graph": { "nodes": [], "edges": [] }
}

scene-graph-input

入力イベント broadcast(Phase 1 では no-op)。

{
  "type": "scene-graph-input",
  "scope": "scene",
  "ref": "click.event",
  "payload": { "x": 100, "y": 200 }
}

Phase 2 では、サーバから各クライアントへ入力イベントを配信する予定。


3. scope 仕様

シーン全体

"scope": "scene"

シーン全体グラフに対する操作。ブロードキャスト可能。

オブジェクト単位

"scope": { "object": "cube1" }

cube1 という ID のオブジェクト専用グラフ。複数オブジェクトの独立制御が可能。

無効な例(エラー):

"scope": { "target": "cube1" }

4. LoomSceneSync API

コンストラクタ

const adapter = new LoomSceneSync({
  LoomClass,
  send,
  getServerTime,
  resolveTarget
});

引数:

handleMessage(msg)

SceneSync メッセージを受け取って処理。

adapter.handleMessage({
  type: "scene-graph-set",
  scope: "scene",
  graph: { nodes: [...], edges: [...] }
});

対応するメッセージ型:

不正な type / scope はエラーをスロー。

start()

すべてのグラフを開始する。

adapter.start();

stop()

すべてのグラフを停止する。

adapter.stop();

sendGraph(scope, graph) (オプション)

メッセージを送信する。

adapter.sendGraph("scene", { nodes: [...], edges: [...] });
adapter.sendGraph({ object: "cube1" }, graph);

内部で send({ type: "scene-graph-set", scope, graph }) を呼ぶ。

clearGraph(scope) (オプション)

クリアメッセージを送信する。

adapter.clearGraph("scene");
adapter.clearGraph({ object: "cube1" });

patchGraph(scope, graph) / sendInput(scope, ref, payload) (オプション)

送信用 helper。どちらも送信前に scope と payload 形状を検証する。

adapter.patchGraph("scene", nextGraph);
adapter.sendInput("scene", "pointer.down", { x: 200, y: 120, button: 0 });

createGraphSetMessage(...) などの message builder

以下の helper は、送信の前にメッセージを保存・署名・batch 化したい場合に使う。

いずれも入力を検証し、graph / payload は shallow clone された object を返す。

getScopeKey(scope) / isSceneScope(scope)

ownership や controller lock の registry キーを作るための軽量 helper。


5. オブジェクト単位グラフの利用

複数オブジェクトを独立に制御したい場合、scope: { object: "targetId" } を使う。

例:複数立方体の個別制御

// キューブ A のグラフ
adapter.handleMessage({
  type: "scene-graph-set",
  scope: { object: "cubeA" },
  graph: {
    nodes: [
      { id: "clock", type: "serverClock" },
      { id: "rot", type: "sceneSetRotation", params: { target: "cubeA" } }
    ],
    edges: [
      { from: "clock.t", to: "rot.y" }
    ]
  }
});

// キューブ B のグラフ
adapter.handleMessage({
  type: "scene-graph-set",
  scope: { object: "cubeB" },
  graph: {
    nodes: [
      { id: "clock", type: "serverClock" },
      { id: "sine", type: "sine", params: { freq: 2, amplitude: 1 } },
      { id: "scale", type: "sceneSetScale", params: { target: "cubeB" } }
    ],
    edges: [
      { from: "clock.t", to: "sine.t" },
      { from: "sine.out", to: "scale.x" },
      { from: "sine.out", to: "scale.y" },
      { from: "sine.out", to: "scale.z" }
    ]
  }
});

6. 統合責務と source of truth

Loom SceneSync 統合では、同じ target / 同じ property を「Loom が毎 frame 書く状態」と「remote sync が直接書く状態」に同時にしない方が安全。 Loom のグラフは評価結果の source of truth であり、SceneSync はその source of truth を配布・切り替え・一時停止するための transport と考える。

推奨ルール:

例: remote override 中だけ Loom movement を止める

const scopeKey = adapter.getScopeKey({ object: "cube1" });
ownership.set(scopeKey, "remote-drag");

adapter.clearGraph({ object: "cube1" });
applyRemoteTransform("cube1", incomingTransform);

adapter.sendGraph({ object: "cube1" }, authoredMotionGraph);
ownership.delete(scopeKey);

この切り替え責務は transport 層または SceneSync セッション管理側が持ち、LoomSceneSync 自体は protocol 変換と graph 実行に専念させるのが最小で安全。 将来の collaborative editing では、ここに per-scope lock、lease、revision、conflict resolution を追加するのが自然だが、現段階では first-writer/controller モデルを前提にするのが無難。

各オブジェクトグラフは独立して評価される。


6. ノード仕様

serverClock

category: source

入力: なし

出力: t (number, kind: behavior)

パラメータ: adapterId (string, auto-injected)

説明: 全クライアント同期済みのサーバ時刻。getServerTime() の戻り値を返す。

例:

{
  "id": "clock",
  "type": "serverClock"
}

sceneSetPosition

category: sink

入力:

パラメータ:

説明: オブジェクトの位置を設定。obj.position.set(x, y, z)

sceneSetRotation

category: sink

入力:

パラメータ:

説明: オブジェクトの回転を設定。obj.rotation.set(x, y, z)

sceneSetScale

category: sink

入力:

パラメータ:

説明: オブジェクトのスケールを設定。obj.scale.set(x, y, z)

sceneSetColor

category: sink

入力:

パラメータ:

説明: マテリアルの色を設定。material.color.setRGB(r, g, b)material が配列の場合は最初の要素のみ更新。

sceneSetVisible

category: sink

入力:

パラメータ:

説明: オブジェクトの表示・非表示を切り替え。obj.visible = Boolean(visible)


7. エラーハンドリング

以下のエラーが発生する可能性がある。

INVALID_MESSAGE

// msg が object でない
// type フィールドが不明

INVALID_SCOPE

// scope が "scene" でも { object: string } でもない
adapter.handleMessage({
  type: "scene-graph-set",
  scope: { target: "cube1" },  // ❌ INVALID_SCOPE
  graph: { nodes: [], edges: [] }
});

INVALID_GRAPH

// graph フィールドがない、または不正な形式

8. 複数アダプタの管理

同じ LoomClass に対して複数の LoomSceneSync インスタンスを作成できる。

各アダプタは独立した getServerTime / resolveTarget を保持する。

const adapter1 = new LoomSceneSync({
  LoomClass: Loom,
  send: (msg) => client1.send(JSON.stringify(msg)),
  getServerTime: () => time1,
  resolveTarget: (id) => objects1.get(id)
});

const adapter2 = new LoomSceneSync({
  LoomClass: Loom,
  send: (msg) => client2.send(JSON.stringify(msg)),
  getServerTime: () => time2,
  resolveTarget: (id) => objects2.get(id)
});

ノード型登録は冪等であり、複数インスタンスでも重複登録エラーは発生しない。


9. 未対応事項

以下は Phase 2 以降での実装予定。


10. 参考リンク