Loom SceneSync アダプタ(src/loom-scenesync.js)は、Loom のステートレスなグラフ評価を SceneSync メッセージプロトコルで制御する。
send 関数)serverClock と 5 種の SceneSync sink ノードを提供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();
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 });
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": [] }
}
動作:
scope: "scene" 時:既存シーングラフを停止し、新しいグラフに差し替え。adapter が start 済みなら新グラフを即座に start。scope: { "object": "targetId" } 時:該当オブジェクトグラフを停止し新グラフに差し替え。scene-graph-clearグラフをクリア(削除)する。
{
"type": "scene-graph-clear",
"scope": "scene"
}
または:
{
"type": "scene-graph-clear",
"scope": { "object": "cube1" }
}
動作:
scope: "scene" 時:シーングラフを停止して空グラフに差し替え。scope: { "object": "targetId" } 時:該当オブジェクトグラフを停止して削除。存在しないなら無視。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 では、サーバから各クライアントへ入力イベントを配信する予定。
"scope": "scene"
シーン全体グラフに対する操作。ブロードキャスト可能。
"scope": { "object": "cube1" }
cube1 という ID のオブジェクト専用グラフ。複数オブジェクトの独立制御が可能。
無効な例(エラー):
"scope": { "target": "cube1" }
LoomSceneSync APIconst adapter = new LoomSceneSync({
LoomClass,
send,
getServerTime,
resolveTarget
});
引数:
LoomClass (Class): Loom クラス本体。インスタンスではなくクラスを渡すこと。send (Function): メッセージ送信関数。(msg: Object) => voidgetServerTime (Function): サーバ同期済み時刻を秒で返す。() => numberresolveTarget (Function): targetId から対象オブジェクトを取得。(targetId: string) => Object | nullhandleMessage(msg)SceneSync メッセージを受け取って処理。
adapter.handleMessage({
type: "scene-graph-set",
scope: "scene",
graph: { nodes: [...], edges: [...] }
});
対応するメッセージ型:
scene-graph-setscene-graph-clearscene-graph-patchscene-graph-input不正な type / scope はエラーをスロー。
start()すべてのグラフを開始する。
adapter.start();
start() を呼ぶstart() されるstop()すべてのグラフを停止する。
adapter.stop();
stop() を呼ぶstart() されない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 化したい場合に使う。
createGraphSetMessage(scope, graph)createGraphPatchMessage(scope, graph)createGraphClearMessage(scope)createGraphInputMessage(scope, ref, payload)いずれも入力を検証し、graph / payload は shallow clone された object を返す。
getScopeKey(scope) / isSceneScope(scope)ownership や controller lock の registry キーを作るための軽量 helper。
getScopeKey("scene") は "scene" を返すgetScopeKey({ object: "cube1" }) は "cube1" を返すisSceneScope(scope) は scope === "scene" のとき true複数オブジェクトを独立に制御したい場合、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" }
]
}
});
Loom SceneSync 統合では、同じ target / 同じ property を「Loom が毎 frame 書く状態」と「remote sync が直接書く状態」に同時にしない方が安全。 Loom のグラフは評価結果の source of truth であり、SceneSync はその source of truth を配布・切り替え・一時停止するための transport と考える。
sceneSetPosition / sceneSetRotation などを含む graph を SceneSync 経由で配布し、各 client が同じ graph を評価する。アニメーションや procedural motion 向き。推奨ルール:
clear するか、patch で sink を外して一時停止する。drag 完了後に必要なら graph を戻す。scene-graph-input は「状態そのもの」ではなく、「graph evaluation に必要な event」を配るために使う。scope: "scene" または scope: { object: "targetId" } ごとに分ける。LoomSceneSync#getScopeKey(scope) を session 側の lock/registry キーに使える。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 モデルを前提にするのが無難。
各オブジェクトグラフは独立して評価される。
serverClockcategory: source
入力: なし
出力: t (number, kind: behavior)
パラメータ: adapterId (string, auto-injected)
説明: 全クライアント同期済みのサーバ時刻。getServerTime() の戻り値を返す。
例:
{
"id": "clock",
"type": "serverClock"
}
sceneSetPositioncategory: sink
入力:
x (number, default: 0)y (number, default: 0)z (number, default: 0)パラメータ:
target (string): オブジェクト IDadapterId (string, auto-injected)説明: オブジェクトの位置を設定。obj.position.set(x, y, z)
sceneSetRotationcategory: sink
入力:
x (number, default: 0) - オイラー角 X(ラジアン)y (number, default: 0) - オイラー角 Y(ラジアン)z (number, default: 0) - オイラー角 Z(ラジアン)パラメータ:
target (string): オブジェクト IDadapterId (string, auto-injected)説明: オブジェクトの回転を設定。obj.rotation.set(x, y, z)
sceneSetScalecategory: sink
入力:
x (number, default: 1)y (number, default: 1)z (number, default: 1)パラメータ:
target (string): オブジェクト IDadapterId (string, auto-injected)説明: オブジェクトのスケールを設定。obj.scale.set(x, y, z)
sceneSetColorcategory: sink
入力:
r (number, default: 1) - Red (0..1)g (number, default: 1) - Green (0..1)b (number, default: 1) - Blue (0..1)パラメータ:
target (string): オブジェクト IDadapterId (string, auto-injected)説明: マテリアルの色を設定。material.color.setRGB(r, g, b)。material が配列の場合は最初の要素のみ更新。
sceneSetVisiblecategory: sink
入力:
visible (boolean, default: true)パラメータ:
target (string): オブジェクト IDadapterId (string, auto-injected)説明: オブジェクトの表示・非表示を切り替え。obj.visible = Boolean(visible)
以下のエラーが発生する可能性がある。
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 フィールドがない、または不正な形式
同じ 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)
});
ノード型登録は冪等であり、複数インスタンスでも重複登録エラーは発生しない。
以下は Phase 2 以降での実装予定。
scene-graph-input でサーバからクライアントへのイベント配信scene-graph-patch の効率的な差分更新