loomlet

Loomlet の設計思想

Loomlet は、結果ではなく関係を記述する。

Loomlet graph は、環境から出力を導くためのシリアライズ可能な定義である。
graph 自体は、決定論的な関係として扱う。つまり、同じ環境と同じ評価規則が与えられれば、同じ出力を生成するべきである。

言い換えると、次の原則が成り立つ。

同じ graph + 同じ environment + 同じ評価規則 = 同じ出力

この原則が Loomlet の設計判断の土台である。

ただし、Loomlet はすべての host behavior を完全に決定論的にすることを目的としない。
物理、デバイス入力、描画、AI サービス、乱数、外部 API など、host 固有または非決定的な処理は、environment input または同期済み result として扱う。

Loomlet の役割は、同期済み environment から、決定論的に記述できる振る舞いを各 runtime が再現できるようにすることである。

3 層モデル

Loomlet は振る舞いを次の 3 つの層に分離する。

  1. Graph
  2. Environment
  3. Runtime

Graph

Graph は、値、イベント、状態、出力の関係を記述する。

Graph はホスト言語のコードではなく、データである。
Graph が JSON として表現されることで、シリアライズ、送信、ランタイム編集、AI 生成、ノードとしての可視化、そして Web、Unity、Godot など複数のホスト環境での実行が可能になる。

Graph は、可能な限り決定論的で、副作用を持たない状態に保つべきである。

Environment

Environment は、graph を評価するために外部から与えられる入力を含む。

Environment には次のものが含まれる。

例として、スライダー値、ボタン押下、乗り物の発車イベント、プレイヤー操作、ページ送り、その他 graph に注入される外部シグナルがある。

Environment は graph から分離される。

この分離が重要である。Loomlet は、計算済みのすべての結果を同期する必要はない。
代わりに、Loomlet は environment を同期する。すべてのクライアントが同じ graph を同じ environment で評価すれば、各クライアントは同じ結果を独立に計算できる。

Runtime

Runtime は、graph を environment に対して評価し、その結果として得られた出力をホストシステムに適用する。

Runtime は次の責務を持つ。

Web、Unity、Godot など複数の runtime が存在し得るが、それらは同じ評価規則に従うべきである。

Scene-level graph と Object-level graph

Loomlet graph は、scene 全体に対して持つことも、個別の scene object に attach することもできる。

Scene-level graph

Scene-level graph は、scene 全体の状態や object 間の関係を記述する。

例:

Scene-level graph は、object 間の橋渡しを担当する。

たとえば、button object が押されたとき、door object を開くという関係は、button object や door object の内部に直接埋め込むのではなく、scene-level graph に記述できる。

Object-level graph

Object-level graph は、個別 object の振る舞いを記述する。

例:

Object-level graph は、基本的に自分自身の振る舞いを記述する。
他 object を直接変更するのではなく、environment event、scene-level graph、または output command を通じて関係を扱う。

同じ graph は、異なる object context と environment で評価することで、多数の object に再利用できる。

たとえば、100 個の bullet object が存在する場合でも、それぞれに同じ bullet motion graph を attach し、spawnTime、initialPosition、direction、speed などの environment だけを変えて評価できる。

このモデルにより、Loomlet core が動的に N 個の object を graph 内で直接管理する必要を減らせる。
Object の生成、削除、識別子、graph attachment は SceneSync または host 側が管理する。

同期モデル

Loomlet は、連続的な結果ではなく原因を同期する。

オブジェクトの位置、回転、アニメーション値、その他の計算済み出力を毎フレーム broadcast する代わりに、Loomlet は environment を同期することを優先する。

連続的な振る舞いは、各クライアントがローカルで計算する。

例:

これにより、通信量を減らし、振る舞いの再生、デバッグ、再現を容易にする。

Local input と Committed environment event

Loomlet が読むべき入力は、生の local input ではなく、同期対象として確定した environment event である。

たとえば、マルチプレイで一人のプレイヤーがボタンを押した場合、local device はまず local input を検出する。
しかし、door を開く、ride を開始する、score を加算するなど、scene の共有状態に影響する処理は、同期済み environment event に基づいて行うべきである。

推奨される流れは次の通りである。

  1. プレイヤーの端末が local input を検出する
  2. SceneSync または host authority に event request を送る
  3. SceneSync または host authority が committed environment event として確定する
  4. 確定済み event を全クライアントの environment に配信する
  5. Scene-level graph が object 間の関係を評価する
  6. 各 object-level graph が自分の振る舞いを計算する

Environment event には、少なくとも次の情報を含めるべきである。

同期において重要なのは、全クライアントが同じ event set、同じ event order、同じ timestamp、同じ payload を観測できることである。

ただし、local feedback は committed event を待たずに表示してもよい。
たとえば、ボタンが押された瞬間の軽いアニメーション、音、ハイライト、触覚 feedback などは local input に基づいて即時に出せる。

共有状態に影響する committed behavior と、操作感のための local feedback は分けて扱う。

評価モデル

Loomlet runtime は、object-level graph 間で評価順に依存する振る舞いを避けるべきである。

各 evaluation tick において、runtime は次の順序で処理する。

  1. environment snapshot を取得する
  2. すべての対象 Loomlet graph を、その snapshot に対して評価する
  3. host scene を即座に変更せず、output command を収集する
  4. 決定論的なルールで output の競合を解決する
  5. 収集した output を host scene に適用する

Object-level graph は、同じ tick 内で他の object-level graph が生成した output を観測しないべきである。
観測できるのは、前回 commit 済みの environment / scene state、または現在 tick の同期済み environment snapshot である。

この方式を次のように表せる。

snapshot → evaluate → collect outputs → resolve conflicts → apply

この評価モデルにより、object の評価順が結果に影響することを避ける。

Output の競合

同じ property に対して複数の graph が同時に output を生成すると、結果が曖昧になる。

例:

このような競合は避けるべきである。

基本方針として、Loomlet runtime は single writer rule を採用することが望ましい。

つまり、1 つの property に対して書き込める graph は原則として 1 つにする。

他 object に影響を与えたい場合は、その object の Transform を直接書き換えるのではなく、environment event、command、または scene-level graph を通じて依頼する。

例:

Spawn / Delete

Object の生成や削除は、graph 評価中に即座に反映しない方がよい。

推奨される扱いは次の通りである。

  1. graph は spawn / delete command を生成する
  2. runtime は command を収集する
  3. tick の commit 段階で object を生成または削除する
  4. 新しく生成された object の graph は、次の tick から評価する

これにより、同じ tick 内で生成順や評価順に依存する挙動を避けられる。

Projectile など、生成された瞬間から進行しているように見せたい object では、spawnTime を environment に含める。
Object-level graph は serverTime - spawnTime を使って現在位置を計算できる。

Loomlet tick と host frame

Loomlet runtime は、host frame ごとに 1 回だけ graph を評価する必要はない。

Host の frame loop と Loomlet の evaluation tick は、異なる周期で動作してよい。
決定論的な振る舞いのために、Loomlet は同期された時刻に基づく固定 timestep で評価されるべきである。

Runtime は、現在の同期時刻に追いつくために、1 つの host frame 内で Loomlet を複数回評価してよい。

Unity の場合、Unity Update と Loomlet tick は次のように分離できる。

推奨される評価モデルは次の通りである。

  1. 入力値と入力イベントを timestamp 付きで収集する
  2. 固定 timestep で Loomlet を進める
  3. immutable な environment snapshot に対して graph を評価する
  4. output command を収集する
  5. host main thread 上で、最新または補間された output を host scene に適用する

Loomlet の graph 評価中に host object を直接変更してはならない。
Host の変更は、収集された output command を適用する段階でのみ行うべきである。

Unity の Transform、GameObject、Renderer などの Unity API は、原則として Unity main thread 上で扱う。
Loomlet の評価を worker thread 化する場合でも、worker thread は純粋な graph 評価と output command 生成に限定し、Unity API への反映は main thread で行う。

決定論の要件

Environment 同期モデルが成立するためには、Loomlet の評価が決定論的である必要がある。

そのため、Loomlet は次のルールに従うべきである。

この中心原則をより厳密に書くと、次のようになる。

同じ graph + 同じ environment + 同じ評価規則 = 同じ出力

State と副作用

Loomlet の多くのノードは、純粋な関係ノードであるべきである。

state を持つ振る舞いは許可するが、それらは明示的でなければならない。
例として、delay、previous-value、accumulator、low-pass filter、state-machine ノードなどがある。

副作用は output 境界に隔離するべきである。

つまり Loomlet は、次のものを区別するべきである。

Graph は、何が起きるべきかを記述する。
Runtime は、それを host 環境にどう適用するかを決定する。

結果同期を fallback として扱う

すべてを Loomlet の決定論的モデルに押し込むべきではない。

一部の振る舞いは、host 固有のシステム、物理エンジン、外部サービス、AI 生成、乱数、デバイス固有データ、その他の非決定的な処理に依存する場合がある。

そのような場合、SceneSync または別の host 同期レイヤーが、結果を直接同期してよい。

実用上、Loomlet と SceneSync は次の 2 種類の同期戦略を併用できる。

  1. 決定論的な Loomlet の振る舞いには environment 同期を使う
  2. host 固有または非決定的な振る舞いには result 同期を使う

Loomlet は、決定論的な関係として記述できる振る舞いに使う。
SceneSync は、外部結果として同期する必要がある振る舞いを扱う。

例:

これらは Loomlet graph 内に隠すのではなく、environment input または同期済み result として扱う。

まとめ

Loomlet は、Arrowized FRP に着想を得た、シリアライズ可能な振る舞い graph システムである。

Loomlet は関係を JSON として記述し、environment を分離し、その environment をクライアント間で同期し、各 runtime が結果を独立に計算する。

Scene-level graph は object 間の関係を記述する。
Object-level graph は個別 object の振る舞いを記述する。
Runtime は immutable な environment snapshot に対して graph を評価し、output command を収集し、host scene に適用する。

Loomlet の役割は、同期可能な振る舞いを記述することである。
SceneSync の役割は、environment を同期し、object の生成・削除・識別子・graph attachment を管理し、必要に応じて外部結果を同期することである。

Arrowized FRP や Yampa などの背景概念については、Appendix: Influences を参照する。

Loomlet 仕様書

現在の実装状態

Loomlet はまだ experimental だが、以下のワークフローは実装済みである。

ただし、API とデータ形式はまだ変更される可能性がある。

アーキテクチャ

レイヤー構成

Loomlet は、Core、拡張パック、統合プロダクトの 3 層で構成される。

Layer 1: Core

Core はホストに依存しない言語処理系である。

Core に含めるもの:

Core に含めないもの:

Core は、外部世界への副作用を直接実行しない。

Core は @afjk/loomlet package として共有できるように public exports を整理中である。

現在の主な export 対象:

ただし、npm 公開と package 安定化はまだ進行中である。

Layer 2: 拡張パック

拡張パックは、ホスト固有の source / sink / adapter を提供する薄いレイヤーである。

例:

拡張パックは、scene.setPositiondom.setText のような使いやすいノードを提供してよい。ただし、それらは Core そのものではなく、ホスト I/O への変換として扱う。

Layer 3: 統合プロダクト

統合プロダクトは、Core と拡張パックを組み合わせてユーザーが使える形にしたものである。

例:

統合プロダクトは、UI、保存、ネットワーク、デプロイ、ホスト固有の UX を持ってよい。

表現パイプライン

Loomlet は、.loom テキスト、ノードエディタ、ランタイム実行、ホスト連携を同じ形式で無理に扱わない。

用途ごとに次の表現へ分ける。

DSL Source
  ↓ parse
Source AST
  ↓ lower / normalize
Graph AST
  ↓ compile
Runtime Graph
  ↓ adapt
Target Graph

Node Editor は Graph AST を表示・編集し、座標や選択状態などの UI 情報は EditorModel / Node Editor ViewModel として別に持つ。

表現 主な役割 人間 AI ランタイム ノードエディタ
DSL Source .loom テキスト。人間・AI・Git が扱う正本 ×
Source AST DSL の構文情報。コメント、raw literal、source range を保持 × ×
Graph AST ノード、ポート、エッジ、params、source map を持つ編集向け中間表現
Runtime Graph 実行に必要な最小グラフ。評価器が読む形式
Target Graph Scene Sync / Unity / Web など各ホスト向けに変換された形式 host 側
EditorModel / Node Editor ViewModel ノード位置、選択、zoom、pan など UI 状態 × ×

各表現の役割

DSL Source は正本である。人間と AI が読み書きしやすく、Git diff でも扱いやすい。

Source AST は、DSL を安全に編集するための構文表現である。コメント、元の数値表記、名前付き引数、source range などを保持する。

Graph AST は、ノードエディタと AI が構造を理解するための中間表現である。ノード、ポート、エッジ、params、source map を持つ。

Runtime Graph は、実行に必要な情報だけを持つ最小表現である。Loomlet runtime は基本的にこれを評価する。

Target Graph は、Scene Sync、Unity、Web runtime など、実行先の世界に合わせた形式である。

EditorModel / Node Editor ViewModel は、表示上の状態である。ノード座標、選択状態、zoom、pan などはプログラムの意味とは別なので分離する。

現在の実装では、ノードエディタ向けの共有表現として EditorModel を使用する。
EditorModelRuntime Graph とは異なり、ノードの編集に必要な情報を含む。
ただし、ノード位置・label・comment などの実行に不要な情報は hidden editor metadata として保存し、Runtime Graph の意味には含めない。

ホストI/Oモデル

ホストとは、Loomlet Core を載せて動かす外側の実行環境である。

例:

Core はホスト固有の副作用を直接実行しない。

Core は次のような境界 API を通じてホストとやり取りする。

setInput(channel, value)
emitEvent(channel, event)
getOutput(channel)
onOutput(channel, callback)

入力

ホストは、現在値やセンサー値を setInput で Core に渡す。

例:

イベント

ホストは、一回性のイベントを emitEvent で Core に渡す。

例:

出力

Core は、外の世界に反映したい結果を output として生成する。

例:

{
  "channel": "scene.position",
  "objectId": "cube",
  "value": [1, 0, 0]
}

実際の副作用は Core ではなく、拡張パックまたは host adapter が実行する。

例:

このモデルにより、同じ Loomlet graph を複数のホストへ移植しやすくする。

ノードエディタの編集方針

.loom の DSL Source を正本とする。

ノードエディタは、DSL から生成された Graph AST を表示・編集する UI である。ノードエディタ独自の保存形式を正本にはしない。

基本方針

編集レベル

最初から完全な双方向変換を目指さず、編集可能範囲を段階的に広げる。

Level 1: 数値リテラル編集
Level 2: 文字列 / boolean / objectId 編集
Level 3: ノード名 / 変数名変更
Level 4: sink ノード追加
Level 5: compute ノード追加
Level 6: edge 再接続
Level 7: 任意の Graph AST から DSL を再生成

当初は Level 1〜2 から始める方針だったが、現在の Editor Studio では、param 編集、node 追加/削除/rename、connection 編集、canonical DSL 再生成、Graph → DSL auto sync まで実験的に実装している。

ただし、任意の DSL 構文を完全に保持したまま双方向編集することは、まだ保証しない。
Graph 側の編集は、必要に応じて canonical DSL として再生成される。

source map を使った最小差分 patch(Level 1〜6 相当)は設計思想として残すが、現在の実装では canonical regeneration を主経路としている。

例:

x = math.sine(t, freq: 0.2, amplitude: 2, offset: 0)

freq をノードエディタで 0.5 に変更した場合、現在の実装では canonical DSL を再生成する(source map patch ではなく)。

x = math.sine(t, freq: 0.5, amplitude: 2, offset: 0)

また、DSL 変更時には layout preservation により、可能な範囲でノード位置を保持する。

この方針により、DSL の可読性、Git diff、AI 編集、ノードエディタの操作性を両立する。

Hidden editor metadata

Editor Studio は、ノード位置・label・comment など、実行意味に影響しない編集情報を hidden editor metadata として .loom ファイル内に保存する。

この metadata は Runtime Graph の意味には含めない。

現在の方針:

metadata の詳細形式は今後変更される可能性があるため、固定しすぎない。

1. 概要

Loomlet は、ブラウザで動くステートレスなデータフロー実行エンジンです。JSON でグラフを定義し、毎フレーム値を計算・更新します。

英語での一行説明:

A stateless dataflow engine for the browser. Build reactive visual, audio, and 3D content by composing pure functions.

日本語での説明:

ブラウザで動くステートレスなデータフロー実行エンジン。純粋な関数の合成により、リアクティブな視覚・音響・3D コンテンツを構築します。

Loomlet の核は、以下の仕組みです:

  1. グラフ状に配置された複数の「ノード」を定義(JSON で記述)
  2. ノードどうしを「エッジ」で接続(値の流れを表現)
  3. エンジンが毎フレーム、グラフを評価し、各ノードの出力値を計算
  4. 外部から engine.getValue() で任意のノードの出力値を取得し、画面や音響に反映

2. 設計原則

原則 1:ステートレスを基本とする

状態(過去を引きずる値)を持たず、現在時刻と入力だけから出力が決まる純粋なデータフローを基盤とします。

意図:

原則 2:状態が必要な部分は明示的に局在化する

「状態部品」という限定された種類の部品にだけ状態を持たせ、グラフ上で目に見える形で管理します。

意図:

原則 3:副作用を出口側に集める

外部への副作用(位置や色を変える、音を鳴らす、メッセージを送るなど)は「シンク」専用部品でのみ行います。中間の計算は副作用を持ちません。

意図:

原則 4:テキスト表現とビジュアル表現の二重持ち

内部はテキストの専用記法(DSL)で持ち、UI はそれを視覚化します。両方向に変換可能で、人間も AI も自由に行き来できます。

注意: 当初(第ゼロ段階)は DSL とビジュアル UI を実装せず JSON 直書きから始めたが、現在は DSL parser/compiler と Editor Studio が実装済みである。

意図:

3. データフローモデル

3.1 値の二種類

すべての値は次のどちらかに分類されます。

連続値(Behavior)

常に何らかの値が流れている「川」のようなもの。時間的に途切れません。例:

当初(第ゼロ段階)は連続値のみを扱っていたが、現在はイベント型も実装済みである。

イベント(Event)

時々瞬間的に発生する「雷」のようなもの。時間的に離散的です。例:

イベントは第一段階(実装済み)で導入された。

3.2 ノード(部品)の 5 カテゴリ

部品は次の 5 つのカテゴリに分かれます。

ソース部品

入力なしで値を生み出すノード。

例: clock(時刻)、constant(定数)

入力部品

外界から値を受け取るノード。ユーザーの操作やネットワーク通信などが対象。

例: pointerPosition(マウス位置)、pointerClick(クリック)、webhook(HTTP リクエスト)

バージョン 0.3.0 では smoothLerplowpassdelay1integrate を実装済み。

変換部品

純粋な計算で値を変換する。状態を持ちません。

例: add(足し算)、multiply(掛け算)、sine(正弦波)、map(配列変換)

状態部品

内部に状態を持つ唯一のカテゴリ。過去の値を記憶し、それに基づいて出力を決定します。

例: smoothLerp(easing follow)、lowpass(平滑化)、delay1(1フレーム遅延)、integrate(積分)

状態部品は実装済みである(smoothLerplowpassdelay1integrate)。

シンク部品

外部への副作用を持つノード。値を受け取り、画面・音響・ネットワークなどに影響を与えます。

例: setPosition(位置変更)、setText(テキスト変更)、setStyle(スタイル変更)

DOM シンク部品および SceneSync / Three.js アダプタ経由のシンク部品は実装済みである。

3.3 イベントの伝播モデル

3.4 接続の型ルール

例外: sample ノードの value 入力ポート(Behavior 型)には Event 型の上流から接続することが許される唯一のケース。これは Behavior 値をイベントトリガでサンプリングするための設計である。

4. 第ゼロ段階のスコープ

実装対象

実装外(第一段階以降)

4.5 第一段階のスコープ

実装対象(第一段階で追加)

実装外(第二段階以降)

5. ノード仕様

ノード型のメタデータ構造

各ノード型は、内部的に以下のメタデータを持つオブジェクトとして定義されます。

{
  category: "source" | "transform" | "state" | "sink" | "input",
  inputs: [
    { name: "t", type: "number", default: 0 },
    ...
  ],
  outputs: [
    { name: "out", type: "number" }
  ],
  params: [
    { name: "freq", type: "number", default: 1 },
    ...
  ],
  evaluate: (inputs, params, ctx) => outputs
}

このメタデータは以下の目的で利用されます。

5.0 入力・パラメータの統一ルール

すべてのノードは、入力ポートとパラメータについて次の優先順位で値を決定します。

  1. 入力ポートにエッジが接続されていれば、その値を使う
  2. エッジが接続されていなければ、params の同名フィールドの値を使う
  3. それも指定されていなければ、ノード型ごとのデフォルト値を使う

このルールにより、すべてのノードの設定値は「グラフ上で動的に変えたければエッジで接続、静的に固定したければ params で指定」という形で統一的に扱えます。

5.1 clock

カテゴリ: ソース部品

入力: なし

出力:

パラメータ: なし

説明:

エンジン起動からの経過時間(秒)を常に出力します。

例:

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

5.2 constant

カテゴリ: ソース部品

入力: なし

出力:

パラメータ:

説明:

パラメータで指定した定数値を常に出力します。変わりません。

例:

{
  "id": "freq_source",
  "type": "constant",
  "params": {
    "value": 2.5
  }
}

5.3 sine

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

時刻 t を入力として、正弦波を計算します。周波数、振幅、位相、オフセットでカスタマイズ可能です。

例:

{
  "id": "oscillator",
  "type": "sine",
  "params": {
    "freq": 2.0,
    "amplitude": 1.5,
    "phase": 0,
    "offset": 0
  }
}

5.4 add

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

2 つの数値を足し合わせます。入力が未接続の場合、対応する params の値が使われます。

例:

{
  "id": "summer",
  "type": "add",
  "params": {
    "b": 5
  }
}

5.5 multiply

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

2 つの数値を掛け合わせます。入力が未接続の場合、対応する params の値が使われます。

例:

{
  "id": "scaler",
  "type": "multiply",
  "params": {
    "b": 0.5
  }
}

5.5a subtract

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

2 つの数値の差を計算します。a から b を引きます。

5.5b divide

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

2 つの数値の商を計算します。除数が 0 の場合は 0 を返します。

5.5c mod

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

ab で割った剰余を返します。負数に対応し、常に非負の値を返します(数学的な正のモジュロ)。除数が 0 の場合は 0 を返します。

5.5d negate

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

数値の符号を反転させます。正を負に、負を正に変えます。

5.5e abs

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

数値の絶対値を返します。負の値は正に、正の値はそのまま返します。

5.5f clamp

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

入力値を minmax の範囲に挟みます。min > max の場合は min を返します。

5.5g lerp

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

2 つの値 abt で線形補間します。t=0at=1b が得られます。t が 0~1 の範囲外でも外挿(クランプされない)します。

5.5h smoothstep

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

GLSL の smoothstep に準拠した関数です。xedge0 より小さい場合は 0、edge1 より大きい場合は 1 を返します。その間では、エルミート補間で滑らかに遷移する値を返します。edge0 === edge1 の場合は、x < edge0 なら 0、そうでなければ 1 を返します。

5.5i map

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

入力範囲 [inMin, inMax] から出力範囲 [outMin, outMax] へ値をリマップします。TouchDesigner の Math CHOP の Range/Map 機能に相当します。inMin === inMax の場合は outMin を返します。clamp パラメータは 入力ポートではなくパラメータのみ です。

5.5j cosine

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

コサイン波を出力します。sine と同じシグネチャですが、Math.cos を使用します。Lissajous 曲線など、複数の異なる周波数のトリゴノメトリック関数を組み合わせるのに利用します。

5.5k smoothLerp

カテゴリ: 状態部品

入力:

出力:

パラメータ:

説明:

時間ベースの指数追従。dt を使って prevOut から目標値へ滑らかに収束する。評価式は prevOut + (value - prevOut) * (1 - exp(-rate * dt))

5.5l lowpass

カテゴリ: 状態部品

入力:

出力:

パラメータ:

説明:

時定数ベースの一次ローパスフィルタ。評価式は prevOut + (value - prevOut) * dt / (tau + dt)tau=0 の場合は即時追従になる。

5.5m delay1

カテゴリ: 状態部品

入力:

出力:

パラメータ:

説明:

1 フレーム前の入力値を返す。出力 out は現在の prevOut、次フレームに保存される内部状態は現在フレームの入力値。実装上は evaluate の戻り値で _newState を明示する唯一の標準ノード。

5.5n integrate

カテゴリ: 状態部品

入力:

出力:

パラメータ:

説明:

prevOut + value * dt を評価し、必要なら min / max でクランプする。ゲージ、累積、減衰量の管理などに使う。

5.6 pointerClick

カテゴリ: 入力部品

入力: なし

出力:

パラメータ:

説明:

ブラウザの pointer down イベントを購読し、クリックがあったフレームに event ポートからイベントを発生させる。engine.dispatchEvent を経由する経路(後述)か、ノード型が start() 内で自動購読する形のいずれかで実装される(実装は第一段階プロトタイプで決定)。

ペイロード座標系:

将来 Unity 等の非 DOM 環境で実装する場合は、当該プラットフォームの「画面座標系」相当(左上原点、ピクセル単位)にマップする。

5.7 pointerPosition

カテゴリ: 入力部品

入力: なし

出力:

パラメータ: なし

説明:

現在のマウス/タッチ位置を Behavior 型として常時出力する。値は最後に観測された位置で、初回観測前は {x: 0, y: 0}

座標系:

非 DOM 環境での解釈は pointerClick に準ずる。

5.8 keyDown

カテゴリ: 入力部品

入力: なし

出力:

パラメータ:

説明:

keydown を購読し、該当フレームに event ポートからイベントを発生させる。

5.9 keyUp

カテゴリ: 入力部品

入力: なし

出力:

パラメータ:

説明:

keyup を購読し、該当フレームに event ポートからイベントを発生させる。

5.10 filter

カテゴリ: 変換部品

入力:

出力:

パラメータ:

制限式 DSL の文法:

predicate は以下の文法に限定される。new Functioneval での評価は禁止。

禁止事項:

例:

実装方針:

predicate は load() 時にパースして抽象構文木(AST)に変換し、評価は AST のインタプリタで行う。これにより JavaScript 環境と他環境(C#、Unity 等)で同一の評価結果を保証する。パース失敗時は INVALID_GRAPH エラーを投げ、details: { reason: "filter.predicate", nodeId, error } を含める。

実装メモ

バージョン 0.2.0 では new Function ベースの評価を廃止し、制限式 DSL のパーサ・インタプリタで predicate を評価する。これにより、複数環境での一貫性が保証される。

5.11 sample

カテゴリ: 変換部品

入力:

出力:

パラメータ: なし

説明:

trigger イベントが発火したフレームに、その時点の value の値をペイロードとして含むイベントを出力する。クリックした瞬間のマウス位置を取得する、といった用途に使う。

5.12 merge

カテゴリ: 変換部品

入力:

出力:

パラメータ: なし

説明:

複数の Event ストリームを1本にまとめる。同一フレームに両方発生した場合、出力配列は a の全ペイロード、その後に b の全ペイロード、という順序で連結される。下流ノードはこの順序を前提にしてよい。

5.12.1 greaterThan

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

value > threshold を評価し、真偽値を返す。しきい値判定をシンプルに記述するためのノード。

5.12.2 lessThan

カテゴリ: 変換部品

入力:

出力:

パラメータ:

説明:

value < threshold を評価し、真偽値を返す。greaterThan と対で使える比較ノード。

5.13 setText

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

DOM 要素のテキスト内容を更新する。document.querySelector(target) で要素を取得し、その textContentvalue を文字列化して設定します。要素が見つからない場合、何もしない(エラーにしない)。

5.14 setStyle

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

DOM 要素の CSS スタイルを更新する。el.style[property] = String(value) + unit として設定されます。例えば value=50property="width"unit="px" なら、el.style.width = "50px" となります。要素が見つからない場合、何もしない(エラーにしない)。

5.14.1 setClass

カテゴリ: シンク部品

入力:

出力: なし(シンクノードは副作用専用のため出力を持たない)

パラメータ:

説明:

element.classList.toggle(className, Boolean(enabled)) を実行してクラスを付与/削除する。対象要素がない場合、className が空の場合は何もしない。

5.14.2 setCssVar

カテゴリ: シンク部品

入力:

出力: なし(シンクノードは副作用専用のため出力を持たない)

パラメータ:

説明:

CSS custom property を更新する。name-- で始まらない場合は自動で -- を補う。valuenull / undefined の場合や対象要素がない場合は何もしない。

5.14.3 setTransform2D

カテゴリ: シンク部品

入力:

出力: なし(シンクノードは副作用専用のため出力を持たない)

パラメータ:

説明:

style.transformtranslate(...) scale(...) rotate(...) 形式でまとめて設定する。setStyle でも transform は設定できるが、setTransform2D は 2D 変形を分かりやすく扱うための専用シンク。対象要素がない場合は何もしない。

5.15 setAttr

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

DOM 要素の HTML 属性を更新する。el.setAttribute(name, String(value)) として設定されます。例えば name="data-count" なら data-count 属性が更新されます。要素が見つからない場合、何もしない(エラーにしない)。

5.16 log

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

ブラウザコンソールにメッセージを出力する。console.log(label || "log", value) として実行されます。デバッグ用。

5.17-5.21 Three.js アダプタノード

注: 以下のノードは、src/loom-three.js のアダプタを通じて登録されます。コアには含まれず、registerThreeNodes(Loom, objects) 呼び出しで利用可能になります。

5.17 setPosition

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

Three.js Object3D の位置を設定します。registerThreeNodes(Loom, { objectKey: mesh }) で登録されたオブジェクトに対して、target: "objectKey" でアクセスできます。position.set(x, y, z) が呼ばれます。

5.18 setRotation

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

Three.js Object3D の回転をオイラー角(ラジアン)で設定します。rotation.set(x, y, z) が呼ばれます。

5.19 setScale

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

Three.js Object3D のスケールを設定します。scale.set(x, y, z) が呼ばれます。

5.20 setColor

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

Three.js Object3D のマテリアルの色を RGB(0..1 範囲)で設定します。material.color.setRGB(r, g, b) が呼ばれます。

マテリアル配列対応: material が配列の場合、第一実装では 最初の要素のみ を更新します。material[0].color.setRGB(r, g, b) が対象。

5.21 setVisible

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

Three.js Object3D の表示・非表示を切り替えます。visible = !!inputs.visible として設定されます。

5.22-5.27 SceneSync アダプタノード

注: 以下のノードは、src/loom-scenesync.js のアダプタを通じて登録されます。コアには含まれず、new LoomSceneSync(...) で利用可能になります。

5.22 serverClock

カテゴリ: ソース部品

入力: なし

出力:

パラメータ:

説明:

全クライアント同期済みのサーバ時刻を出力します。コンストラクタで渡された getServerTime() の戻り値を返します。

5.23 sceneSetPosition

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

オブジェクトの位置を設定します。resolveTarget(target) で取得したオブジェクトの position.set(x, y, z) を呼びます。

5.24 sceneSetRotation

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

オブジェクトの回転をオイラー角(ラジアン)で設定します。

5.25 sceneSetScale

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

オブジェクトのスケールを設定します。

5.26 sceneSetColor

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

オブジェクトのマテリアル色を RGB で設定します。material が配列の場合は最初の要素のみ更新します。

5.27 sceneSetVisible

カテゴリ: シンク部品

入力:

出力: なし

パラメータ:

説明:

オブジェクトの表示・非表示を設定します。

6. グラフ定義の JSON フォーマット

基本構造

グラフは、nodesedges を持つオブジェクトで表現されます。loom および meta はオプションです。

{
  "loom": "0.0.1",
  "meta": {
    "name": "sample-graph",
    "author": "afjk",
    "description": "Sin 波を出力するサンプル"
  },
  "nodes": [ ... ],
  "edges": [ ... ]
}

nodes 配列

各ノードは以下の構造を持ちます:

{
  "id": "unique_node_id",
  "type": "node_type_name",
  "params": { ... }
}

edges 配列

各エッジは、値の流れを表現します:

{
  "from": "sourceNodeId.portName",
  "to": "targetNodeId.portName"
}

例:Sin 波の値を取り出すグラフ

時刻を取得し、その時刻に対する正弦波を計算するグラフです:

{
  "nodes": [
    {
      "id": "timer",
      "type": "clock"
    },
    {
      "id": "wave",
      "type": "sine",
      "params": {
        "freq": 1.0,
        "amplitude": 1.0,
        "phase": 0,
        "offset": 0
      }
    }
  ],
  "edges": [
    {
      "from": "timer.t",
      "to": "wave.t"
    }
  ]
}

このグラフでは:

  1. clock ノードが時刻 t を生成
  2. その時刻が sine ノードに入力
  3. sine ノードが正弦波 out を出力

外部から engine.getValue("wave.out") を呼ぶと、現在の正弦波の値が得られます。

別の例:加算と乗算の組み合わせ

定数 10 と時刻を足し、さらに 2 倍にするグラフ:

{
  "nodes": [
    {
      "id": "const10",
      "type": "constant",
      "params": {
        "value": 10
      }
    },
    {
      "id": "timer",
      "type": "clock"
    },
    {
      "id": "adder",
      "type": "add"
    },
    {
      "id": "doubler",
      "type": "multiply",
      "params": {
        "b": 2
      }
    }
  ],
  "edges": [
    {
      "from": "const10.out",
      "to": "adder.a"
    },
    {
      "from": "timer.t",
      "to": "adder.b"
    },
    {
      "from": "adder.out",
      "to": "doubler.a"
    }
  ]
}

このグラフの最終結果は、engine.getValue("doubler.out") で取得できます。

7. 公開 API

Loomlet の評価モデルは「指定された時刻のグラフ状態を計算する」ことを中核とします。エンジン本体は時刻を内部で進めるのではなく、外部から evaluateAt(time) を呼ぶことで、その時刻におけるすべてのノード出力を確定させます。start() / stop()requestAnimationFrame を使って evaluateAt を毎フレーム呼ぶ便利ラッパーであり、テストや決定論的再生では evaluateAt を直接呼ぶ運用が想定されています。

Loom クラス

コンストラクタ:new Loom(graph)

const engine = new Loom(graph);

引数:

説明:

グラフ定義を受け取り、エンジンを初期化します。この時点では評価ループは開始されていません。

グラフにサイクル(循環参照)が存在する場合、このコンストラクタはエラーを投げます。

例:

const graph = { nodes: [...], edges: [...] };
const engine = new Loom(graph);

engine.evaluateAt(time)

engine.evaluateAt(time);

引数:

説明:

指定された時刻におけるグラフ全体を一度評価し、すべてのノードの出力値を内部に保存します。呼び出し後、getValue() でその時刻における任意のノード出力を取得できます。

evaluateAt は Loomlet の中核 API であり、start() / stop() はこれを requestAnimationFrame で繰り返し呼ぶ便利ラッパーです。テストや、外部の時刻ソース(サーバ時刻、録画再生など)に同期したい場合は、evaluateAt を直接呼ぶ運用が想定されています。

呼び出し時、保留中のグラフ(load() で渡されたもの)があれば、評価開始前に切り替えが行われます。

例:

const engine = new Loom(graph);
engine.evaluateAt(0);
console.log(engine.getValue("wave.out"));
engine.evaluateAt(0.5);
console.log(engine.getValue("wave.out"));

engine.dispatchEvent(ref, payload)

engine.dispatchEvent("click1.event", { x: 100, y: 200 });

引数:

説明:

外部からの非同期イベント(DOM のクリック等)をエンジンに注入する。呼び出し時点でキューに積まれ、次の evaluateAt 呼び出しの中で該当ノードのその出力ポートからイベントが発生し、下流ノードへ伝播する。

入力ノードの実装は通常、コンストラクタや start() 内で DOM イベントリスナを登録し、その中で dispatchEvent を呼ぶ形になる。

engine.getValue(ref)

const value = engine.getValue("nodeId.portName");

引数:

戻り値:

説明:

グラフ上の任意のノードの現在の出力値を取得します。

指定されたノードやポートが存在しない場合、またはまだ評価されていない場合の動作は未定義です。

Behavior ポート参照時の戻り値:

Event ポート参照時の戻り値:

getValue("nodeId.eventPort") で Event 型ポートを参照した場合の戻り値は以下のとおり:

これは内部の Event 伝播メカニズムにおいて、同一 tick 内で複数の payload が同一ポートを通過し得るため、配列形式で統一する。下流の Event ノード(filtermergesample)はこの配列を要素ごとに処理する。

注意:

evaluateAt() を呼んだ直後にのみ Event の発火状態が反映される。start() ループ外で getValue() を呼んでも、最後の evaluateAt() 時点の Event 配列が返る。次の evaluateAt() 呼び出しで Event 配列はクリアされる。

補足: 出力ポート名はノード型によって異なります。clock の出力ポートは tpointerPosition の出力ポートは pos、それ以外のノード(状態ノードを含む)のデフォルト出力ポートは out です。

例:

const current_time = engine.getValue("timer.t");
const wave_value = engine.getValue("wave.out");
const keyEvents = engine.getValue("kd.event"); // 配列 or 空配列

engine.load(graph)

engine.load(graph);

引数:

説明:

グラフを差し替えます。動作は以下の三段階です。

  1. 即時バリデーション:渡されたグラフをただちに検証し、サイクルや構造エラーがあればこの時点でエラーをスローします。スローされた場合、現在のグラフはそのまま維持されます。
  2. 保留状態として保持:検証に成功したグラフは「保留中(pending)」として内部に保持されます。この時点ではまだ切り替えは行われていません。
  3. 次の evaluateAt 呼び出し開始時に切り替え:次に evaluateAt(time) が呼ばれた瞬間、保留中のグラフが新しい現行グラフになり、評価はそのグラフに対して行われます。
  4. 状態ノードの内部状態は ID ベースで再利用:同じ id の state ノードが新グラフにも残っていれば、その prevOut は引き継がれます。消えた id の state は破棄されます。新規 id の state は params.initial から始まります。

評価ループ実行中に呼ばれた場合も、フレーム途中でグラフがすり替わることはありません。同一フレーム内で load() 直後に getValue() を呼ぶと、古いグラフの値が返ります。

例:

engine.load(newGraph);
console.log(engine.getValue("wave.out")); // 古いグラフの値
engine.evaluateAt(1.0);                   // ここで切り替え
console.log(engine.getValue("wave.out")); // 新しいグラフの値

engine.start()

engine.start();

説明:

リアルタイム評価ループを開始します。これは便利機能であり、内部では requestAnimationFrame を使い、毎フレーム evaluateAt(t) を呼び出しているだけです(t はエンジン起動からの経過秒)。

開始時には dt 計算用の前回タイムスタンプをリセットするため、再開直後の最初のフレームは dt=0 になります。state ノードの内部状態そのものは保持されます。

リアルタイム実行を必要としない場合(テスト、サーバサイド、手動制御)は、evaluateAt() を直接呼び出すことを推奨します。

呼び出し後、engine.stop() が呼ばれるまで評価が続きます。

例:

engine.start();

engine.stop()

engine.stop();

説明:

start() で開始したリアルタイム評価ループを停止します。state ノードの内部状態は保持されるため、再度 start() した場合は続きから動作します。

呼び出し後も getValue() で最後の値を取得することはできます。手動で evaluateAt() を呼ぶこともできます。

例:

engine.stop();

Loom.registerNodeType(name, definition)(静的メソッド)

Loom.registerNodeType('setPosition', {
  category: 'sink',
  inputs: [...],
  outputs: [],
  params: [...],
  evaluate: (inputs, params, ctx) => ({})
});

引数:

説明:

外部からノード型を追加する。アダプタライブラリ向けの拡張ポイント。

同じ name で既に登録済みの場合、LoomError (code: 'DUPLICATE_NODE_TYPE')をスロー。

例:

import { Loom } from './src/loom.js';
import { registerThreeNodes } from './src/loom-three.js';

registerThreeNodes(Loom, objectsMap);  // 内部で Loom.registerNodeType を呼ぶ
const engine = new Loom(graph);

8. 評価モデル

初期化(グラフ読み込み時)

  1. グラフの nodes 配列を検証(重複した ID がないか確認)
  2. グラフの edges 配列を検証(存在しないノードやポートへのエッジがないか確認)
  3. トポロジカルソート を実行し、ノード評価の順序を決定
  4. サイクル(循環参照)が検出された場合、エラーを投げる

評価コンテキスト

毎フレーム、以下の情報をコンテキストとして保持します:

このコンテキストは、ノード評価時に参照されます(例:clock ノードが time を出力し、state ノードが dtprevOut を参照する)。

フレームごとの評価

毎フレーム、以下の処理が行われます:

  1. 保留中のグラフ(engine.load() で渡されたもの)があれば、現行グラフに切り替え
  2. timeevaluateAt(time) の引数で更新し、前回フレームとの差から dt を計算する(最大 0.1 秒にクランプ)
  3. dispatchEvent で積まれた保留イベントを、対応する入力ノードの出力に反映する
  4. ソース側からシンク側へ向けて、トポロジカルソートされた順序 に従い、各ノードを順次評価
    • Behavior 型入力:エッジ → params → デフォルトの3段階で値を決定
    • Event 型入力:上流からイベントが届いていればその配列、届いていなければ「発生していない」
    • state ノード:evaluate(inputs, params, { time, dt, prevOut, engine, ... }) を呼ぶ。戻り値に _newState があればそれを、なければ out を次フレーム用の内部状態として保存する
    • state ノードで例外が発生した場合はエラーログを出し、内部状態は更新しない
    • 計算結果を Behavior 型出力ポートに書く(毎フレーム)
    • 必要なら Event 型出力ポートにイベントを書く(発生フレームのみ)
  5. 評価終了後、Event 型ポートに溜まったイベントは破棄。次フレームに持ち越さない

状態ノードの安全性

未接続ポートの扱い

入力エッジが接続されていないポートの値は undefined として扱われます。各ノード型は、未接続ポートに対して以下のデフォルト値で処理します。

これにより、エンジンは未接続ポートがあっても安全に評価を継続できます。

9. 配布形態

ファイル形式

ES Module(ESM)形式の単一 JavaScript ファイルとして配布されます。

<script type="module">
  import { Loom } from './src/loom.js';
  const engine = new Loom(graph);
  engine.start();
</script>

ファイルパス

リポジトリのルートではなく、src/ ディレクトリに配置されます。

ビルドツール

ビルドツール(webpack、esbuild など)を使いません。人が読める単一ファイルのまま配布されます。

依存ライブラリ

ゼロです。Three.js や d3.js などの外部ライブラリに依存しません。

これにより、ファイルサイズが最小化され、読み込みが高速化されます。

アダプタ層

コアエンジン(src/loom.js)は依存ゼロを保つが、特定ライブラリとの連携は別ファイルのアダプタとして提供する。

アダプタは Loom コアに依存し、コアの Loom.registerNodeType(name, definition) 経由でノード型を登録する。

10. エラー仕様

Loomlet がスローするエラーは、以下の構造を持つ Error オブジェクトです。

{
  name: "LoomError",
  message: "...",
  code: "ERROR_CODE",
  details: { ... }
}

code フィールドにより、利用側はエラーの種類をプログラム的に判別できます。

10.1 エラー種別一覧

code 発生タイミング details の内容 説明
DUPLICATE_NODE_ID コンストラクタ、load() { nodeId } 同じ ID のノードが複数存在する
UNKNOWN_NODE_TYPE コンストラクタ、load() { nodeId, type } 未知のノード型が指定された
UNKNOWN_NODE コンストラクタ、load() { nodeId } エッジが存在しないノードを参照している
UNKNOWN_PORT コンストラクタ、load() { nodeId, port, side } エッジが存在しないポートを参照している(side"input" または "output"
DUPLICATE_INPUT_EDGE コンストラクタ、load() { nodeId, port } 同じ入力ポートに複数のエッジが接続されている
CYCLE コンストラクタ、load() { nodeIds } グラフにサイクルが存在する。第ゼロ段階では一律エラー扱い
TYPE_MISMATCH コンストラクタ、load() { from, to, fromType, toType } エッジが Behavior と Event を混ぜている、またはペイロード型が一致しない
DUPLICATE_NODE_TYPE Loom.registerNodeType() { name } 同じ名前のノード型が既に登録されている
INVALID_GRAPH コンストラクタ、load() { reason } 上記以外の構造的問題(nodes が配列でない等)

10.2 エラーが投げられるタイミング

12. クロスプラットフォーム評価セマンティクス

Loomlet は単一の JSON グラフ表現を真の単一ソースとし、複数の評価環境(JavaScript / C# / その他)で同一の入力に対し同一の出力を返すことを保証する。

12.1 評価決定論性

ステートレスなノードのみで構成されたグラフは、以下を入力として与えれば全環境で同一結果を返す:

ステートを持つノード(将来追加される accumsmooth 等)はノード自身に状態保持責任があり、状態の初期値・更新規則は当該ノード仕様で定義する。

12.2 浮動小数点演算

数値演算は IEEE 754 倍精度(double / float64)を使用する。sincossqrt 等の超越関数の最終ビットは環境依存となる場合があるが、視覚的・聴覚的同期に問題のないレベルとする(厳密一致が必要な用途では deterministic math ライブラリの導入を将来検討)。

12.3 制限式 DSL の評価

filter.predicate で使用される制限式 DSL(5.10 節)は、全環境で同一の AST に変換され、同一のインタプリタ規則で評価される。各環境のホスト言語の構文や評価規則に依存しない。

12.4 入力ノードの実装

入力ノード(pointerClickpointerPositionkeyDownkeyUp)は環境依存の API を利用するため、環境ごとに同等の機能を提供するアダプタが必要となる:

各環境で発火タイミング・ペイロード形式は本仕様(5.6〜5.9)に準拠する。

12.5 SceneSync 連携時の時刻同期

複数クライアントで結果を揃えるには、共有された時刻ソースが必要となる。これは serverClock ノード(SceneSync アダプタの src/loom-scenesync.js で実装済み)で実現する。evaluateAt(time)time を全クライアントで揃えれば、ステートレスグラフの結果は揃う。

13. ロードマップ

第一段階:イベント型と入力ノード(実装完了・仕様 0.2.0 準拠化完了)

近期タスク:vec2 型スカラ変換ノード

第二段階:状態部品と同期ポリシー

第三段階:デバッグ・検査機能

第四段階:DSL とパッチ形式(実装済み)

第五段階:ビジュアルエディタとプリセット(実装済み)

第六段階:マルチクライアント同期と各種アダプタ

Phase 1.5:SceneSync アダプタ(実装済み)

現在の Loomlet CLI では、Scene Sync link code の redeem、session 保存、room/object の確認、.loom から Scene Sync behavior graph へのcompile/run/dev workflow を実験的に実装している。

Loomlet の主方向は、Scene Sync 上のオブジェクトに時間変化する振る舞いを与える behavior layer である。

Phase 1.6:Unity 対応(C# 再実装)

Phase 2 以降:拡張と安定化


仕様書のバージョン: 0.2.0(クロスプラットフォーム仕様確定版)


Unity C# ランタイム補足仕様(v0.1.0)

概要

JavaScript 版 Loomlet と同じ JSON グラフ形式を、Unity C# ランタイムでも評価できる。

実装は unity/com.afjk.loom/ に配置された Unity Package として提供される。

評価モデル

filter.predicate

sceneSetRotation の角度換算

Unity 向け未対応事項

以下は現バージョンでは Unity 側に実装しない:

State nodes (explicit temporal state)

動機

Loomlet の通常ノードはステートレスな純粋関数であり、evaluate(inputs, params) の結果は入力のみで決まる。これは graph を「時刻 t の関数 f(t)」として扱える純度の高い性質を生むが、一方で smoothing / delay / integrate / easing follow のような「前フレーム値を必要とする挙動」は表現できない。

これらを graph 外の JS に逃がすと、Loomlet graph から挙動が見えなくなり、Loomlet の「graph に挙動を閉じ込める」という思想からむしろ外れてしまう。そこで Loomlet は state を禁止するのでも無制限に許すのでもなく、明示的に隔離されたカテゴリとして導入する。

設計原則

  1. 通常ノードは純粋関数である。
  2. 状態を持てるのは category: "state" のノードだけである。
  3. state は node id に紐づく。
  4. state は graph JSON には保存されない runtime state である。
  5. engine.load() 時、同じ id の state ノードは state を引き継ぐ。
  6. id が変わった state ノードは params.initial から始まる。
  7. state ノードは同期・再現性に影響するため、必要最小限に使う。
  8. AI が graph を生成する場合、state ノードの使用は時間的挙動(smooth / delay / integrate / easing follow)が必要な場合に限定する。

用語

エンジン契約

同期(SceneSync 等)に関する注意

Loomlet の標準的な同期モデルは「全クライアントが同じ graph JSON を受け取り、それぞれ独自に評価する」というものである。state ノードはこのモデル上、各クライアントで独立に進行する。

標準 state ノード

Node 用途 主なパラメータ
smoothLerp 目標値への指数的収束(easing follow) rate (1/sec), initial out = prevOut + (value - prevOut) * (1 - exp(-rate * dt))
lowpass ノイズ除去・平滑化 tau (sec), initial out = prevOut + (value - prevOut) * (dt / (tau + dt))
delay1 1 フレーム前の入力を出力 initial out = prevOut、内部状態として現在の入力を次フレームへ
integrate 入力の時間積分 min, max, initial out = clamp(prevOut + value * dt, min, max)

AST(Abstract Syntax Tree)

動機

DSL を「書ける言語」から「編集・生成・変換できる言語」へ拡張するため、Loomlet は Source AST を中間表現として公開する。

二層構造

公開 API

関数 役割
parseDSLToAST(source) DSL を Source AST に変換。throw せず errors を返す。
compileToGraph(ast) Source AST を graph JSON に lower する。throw せず errors を返す。
formatDSL(ast, options?) Source AST を整形済み DSL に変換。決定的。

Source AST スキーマ(抜粋)

type SourceAST = Program;
interface Program { type: "Program"; body: Statement[]; span: Span; }
type Statement = AssignmentStatement | RenderStatement | CommentStatement;
interface AssignmentStatement { type: "AssignmentStatement"; target: Identifier; value: Expression; span: Span; leadingComments?: Comment[]; trailingComment?: Comment; }
interface RenderStatement { type: "RenderStatement"; call: CallExpression; span: Span; leadingComments?: Comment[]; trailingComment?: Comment; }
interface CommentStatement { type: "CommentStatement"; comment: Comment; span: Span; }
type Expression = CallExpression | PipeExpression | Identifier | NumberLiteral | StringLiteral | BooleanLiteral | NullLiteral | ArrayLiteral | ObjectLiteral;
interface CallExpression { type: "CallExpression"; callee: Identifier; args: Argument[]; span: Span; }
interface PipeExpression { type: "PipeExpression"; left: Expression; right: CallExpression; span: Span; }
type Argument = PositionalArg | NamedArg;
interface PositionalArg { type: "PositionalArg"; value: Expression; span: Span; }
interface NamedArg { type: "NamedArg"; name: Identifier; value: Expression; span: Span; }
interface Identifier { type: "Identifier"; name: string; span: Span; }
interface NumberLiteral { type: "NumberLiteral"; value: number; raw: string; span: Span; }
interface StringLiteral { type: "StringLiteral"; value: string; raw: string; span: Span; }
interface BooleanLiteral { type: "BooleanLiteral"; value: boolean; span: Span; }
interface NullLiteral { type: "NullLiteral"; span: Span; }
interface ArrayLiteral { type: "ArrayLiteral"; elements: Expression[]; span: Span; }
interface ObjectLiteral { type: "ObjectLiteral"; entries: ObjectEntry[]; span: Span; }
interface ObjectEntry { type: "ObjectEntry"; key: Identifier | StringLiteral; value: Expression; span: Span; }
interface Comment { type: "Comment"; text: string; variant: "line"; span: Span; }
interface Span { start: { line: number; column: number; offset: number }; end: { line: number; column: number; offset: number }; }

round-trip 契約

parseDSLToAST → formatDSL → parseDSLToAST で得られる AST は、初回 AST と span を除いて等価。コメント・引数順・キー順・リテラル raw 表現・パイプ式の構造が保持される。

安定性

本章で定義した Source AST 型は後方互換を意識したバージョニングの対象。_internal プレフィックスの type は予告なく変更され得る(本バージョンでは未使用)。

エラーハンドリング(現状)

現在の parser 実装は最初の 1 件の ParseError を errors 配列で返す。将来、error recovery 実装により複数件収集を予定。

Future work

Editor Model

Loomlet は次の 3 つの truth を分離して扱う。

これらを橋渡しする関数として parseDSLToAST / compileToGraph / graphToEditorModel / editorModelToGraph / applyEditorOperation を提供する。Source AST と Editor Model は構造が異なる(配列ベース vs Map ベース)ため、相互変換は GraphJSON を中継して行うのが基本。

API

layoutFallback 仕様

現在の実装状態

Function values

Loomlet supports single-expression function literals: fn(x) => math.multiply(x, 2).

Most Loomlet calls allow only the first argument to be positional. Later arguments must be named.

Common binary operator nodes are an exception and may use two positional arguments: math.add(1, 2) math.subtract(10, 3) math.multiply(x, 2) math.divide(x, 2) math.mod(x, 3)

More descriptive or multi-argument calls still require named arguments: logic.greaterThan(x, other: 2) math.map(x, inMin: 0, inMax: 1, outMin: 0, outMax: 100)

Functions can be assigned to variables: double = fn(x) => math.multiply(x, 2).

Functions can capture values from outer scope: base = 10 and addBase = fn(x) => math.add(x, base).

Functions can be passed to list nodes: list.map(numbers, fn: double).

Current limitations: