GLSLシェーダーで遊ぶ「Glsl Viewer」



【更新履歴】

 ・2026/2/28 バージョン1.0公開。
  (中略)
 ・2026/2/28 バージョン2.1公開。

画像


・ダウンロードされる方はこちら。↓

https://drive.google.com/drive/folders/1I79w7SBhawE2Rq0m2po8RxdOkHeLu4aR?ths=true


《操作説明書》


・本ツールは、直感的な操作で、
 複雑なゲーム用エフェクトや弾幕を
 プログラミング不要で作成できるツールです。

ツールの概要

・本ツールは、器となる「ベース」に、
 様々な効果を持った「パラメータ」を組み合わせていくことで、
 リアルタイムにエフェクトを構築・確認できるエディターです。

・作成したエフェクトは、
 ゲーム開発用のスプライトシート(連番画像)や
 GLSLシェーダーコードとして出力できます。

画面の構成

(1) メニューバー(画面上部):
  ファイルの保存・読み込み、エフェクトの画像出力、
  操作の取り消し・やり直し、
  レイアウトやタイムラインの設定を行います。

(2) 大元リスト(画面左側):
  キャンバスに作成したベース(大元)が一覧表示されます。

  ここからキャンバスへドラッグすると、
  同じ動きをする「分身(インスタンス)」を複数配置できます。

(3) ツールバー(画面中央上):
  新しいベースや、追加したいパラメータ(効果)を選択して
  キャンバスに出現させます。

(4) キャンバス(画面中央):
  ベースやパラメータを配置し、
  つなぎ合わせてエフェクトを組み立てる作業エリアです。

(5) プレビュー(画面右側):
  作成中のエフェクトがリアルタイムに表示されます。

(6) タイムライン(画面下部):
  エフェクトの再生・一時停止、時間の確認や
  シーク(巻き戻し・早送り)が行えます。

基本操作

(1) ベースの配置:
  ツールバーの「🎇新規ベース」をクリックすると、
  キャンバスにベースが出現します。

(2) 開閉操作:
 キャンバス上のベースを「ダブルクリック」すると、
 展開(円が大きくなる)と収納を切り替えることができます。

(3) パラメータの追加と設定:
 ツールバーから「🎨色」や「➡️直線移動」などのパラメータを出し、
 展開しているベースの円の中にドラッグ&ドロップすると、
 カチッとはまり込んで効果が適用されます。

(4) パラメータの調整:
 はめ込んだパラメータ、または
 独立しているパラメータを「ダブルクリック」すると、
 詳細な数値を調整するスライダーが表示されます。

 スライダーを動かすとプレビューに即座に反映されます。

(5) ベースの階層化(親子関係):
  展開しているベースの中に、
  別のベースをドラッグ&ドロップで入れることができます。

  中に入った「子」のベースは、
  「親」の動きや色、形をそのまま受け継ぎ、
  さらに自分自身の動きを重ね合わせることができます。
  
  (例:親が回転し、子がその周囲を回りながら直進する、など)

(6) コピーと削除:
  オブジェクトを右クリックするとメニューが開き、
  コピーや削除ができます。

 DeleteキーやBackspaceキーでの削除も可能です。

パラメータの種類


【見た目】

(1) 📐形:
    円や多角形を作り、大きさを決めます。

(2) 🎨色:
    色相・彩度・明度を調整して色をつけます。

(3) 🖼️画像:
    PC内の画像ファイルを読み込んで表示します。

(4) 🎞️アニメ:
    横に繋がったスプライトシート画像を読み込み、
    パラパラアニメとして再生します。

【特殊】

(1) ⏳時間制御:

    発生を遅らせたり、
    一定時間で消滅(フェードアウト)させたり、
    ループする寿命を決めます。

(2) 💫残像:
    動いた軌跡に光の帯や残像を残します。

(3) 🌊空間の歪み:
    背景を波打つように歪ませ、
    衝撃波や陽炎を表現します。

(4) 🌟フィルター:
    ドット絵風にモザイク化したり、
    発光(グロー)させたりします。

(5) ✨パーティクル:
    指定した形や画像を、火花のように複数に分裂させて
    弾け飛ばします。

【動き】

(1) ➡️直線移動:
    上下左右に移動し続けます。

(2) ↔️反復移動:
    指定した角度に向かって、行ったり来たりします。

(3) 🪀バウンド:
    地面で跳ねるような動きをします。

(4) ⤴️放物線:
    重力に従って放物線を描いて飛びます。

(5) 🌀渦巻き:
    回転しながら徐々に広がっていきます。

(6) 🔄空間回転:
    空間そのものを回します。
   (親に付けると、子が衛星のように公転します)

(7) 🎯ターゲット追尾:
    指定した座標に向かって徐々に曲がりながら進みます。

(8) 🧲イージング:
    動きの始まりや終わりを滑らかに加減速させます。

(9) 🫨揺らぎ:
    炎の揺れのような、ランダムで不規則な動きを加えます。

(10) 🪨落下と弾み:
     重力で落下しながら回転します。

便利機能


(1) テンプレート登録:
   お気に入りのエフェクトができた際、
   ベースを選択してツールバーの「➕登録」を押すと、
   ボタンとして保存されます。

   次回以降、ワンクリックで同じものを呼び出せます。

(2) キャンバスの移動とズーム:
   キャンバスの何もない場所をドラッグすると、
   画面をスクロールできます。

   キーボードの「PageUp」「PageDown」キーで
   ズームイン・ズームアウトが可能です。

(3) グリッドスナップ:
   画面右上の「📏グリッドスナップ」にチェックを入れると、
   マス目に沿って綺麗にオブジェクトを配置できます。

(4) スプライトシート出力:
   メニューバーの「ファイル」から
   「🎬スプライトシート出力」を選ぶと、
   設定した秒数とコマ数でエフェクトを自動録画し、
   ゲーム開発ですぐに使える1枚の連番画像(PNG)として
   保存します。


・ソースコードはこちら。↓

<!DOCTYPE html>
<html lang="ja">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Effect Gem Crafter - Perfect Creator</title>
    <style>
        body { margin: 0; display: flex; flex-direction: column; height: 100vh; background: #0a0a10; color: #fff; font-family: 'Segoe UI', sans-serif; overflow: hidden; user-select: none; -webkit-user-select: none;}
        
        #menubar { flex-shrink: 0; height: 30px; background: #0f0f15; border-bottom: 1px solid #333; display: flex; align-items: center; padding: 0 10px; font-size: 13px; z-index: 1000; }
        .menu-item { position: relative; padding: 0 15px; cursor: pointer; height: 100%; display: flex; align-items: center; color: #ccc; }
        .menu-item:hover { background: #222; color: #00ffcc; }
        .submenu { position: absolute; top: 30px; left: 0; background: #1a1a20; border: 1px solid #444; display: none; flex-direction: column; min-width: 180px; z-index: 1000; box-shadow: 0 5px 15px rgba(0,0,0,0.8); }
        .menu-item:hover .submenu { display: flex; }
        .sub-item { padding: 10px 15px; color: #ccc; cursor: pointer; border-bottom: 1px solid #222;}
        .sub-item:hover { background: #00ffcc; color: #000; font-weight: bold; }
        .ctx-sep { height: 1px; background: #444; margin: 4px 0; }
        #status-bar { margin-left: auto; color: #666; font-size: 11px; padding-right: 10px; display: flex; align-items: center; gap: 10px;}

        #main-area { display: flex; flex-grow: 1; flex-shrink: 1; min-height: 0; overflow: hidden; } 
        
        #left-panel { width: 160px; background: #111; border-right: 2px solid #333; display: flex; flex-direction: column; flex-shrink: 0; min-height: 0; z-index: 20;}
        .panel-header { padding: 8px; font-weight: bold; background: #222; border-bottom: 1px solid #444; font-size: 11px; text-align: center; color: #aaa; letter-spacing: 1px; flex-shrink: 0;}
        #object-list { display: flex; flex-direction: column; gap: 6px; padding: 8px; overflow-y: auto; flex-grow: 1; min-height: 0; }
        .obj-list-item { padding: 8px 10px; background: #1a1a22; border: 1px solid #445; border-radius: 4px; cursor: grab; display: flex; align-items: center; gap: 8px; font-size: 12px; transition: 0.1s;}
        .obj-list-item:hover { background: #2a2a33; border-color: #00ffcc; }
        .obj-list-item:active { cursor: grabbing; }

        #editor { flex-grow: 1; display: flex; flex-direction: column; border-right: 4px solid #222; position: relative; min-width: 0; min-height: 0;}
        
        #toolbar { padding: 8px; background: #151520; display: flex; flex-direction: column; gap: 6px; z-index: 10; box-shadow: 0 4px 10px rgba(0,0,0,0.8); overflow-y: auto; max-height: 180px; flex-shrink: 0;}
        .toolbar-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
        .btn-group { display: flex; gap: 4px; border: 1px solid #333; padding: 4px; border-radius: 6px; background: #111; align-items: center; white-space: nowrap;}
        .btn-group span { font-size: 10px; font-weight: bold; color: #888; writing-mode: vertical-lr; text-align: center; margin-right: 2px;}
        button { font-size: 18px; cursor: pointer; background: #222; border: 1px solid #444; border-radius: 6px; transition: 0.1s; padding: 4px 6px; color: #fff; display: flex; align-items: center; gap: 4px;}
        button:hover { background: #333; transform: scale(1.05); }
        button:active { transform: scale(0.95); }
        
        #canvas-container { flex-grow: 1; flex-shrink: 1; min-height: 0; overflow: auto; position: relative; background: #050508; outline: none; }
        #ui-canvas { display: block; transform-origin: top left; }
        
        #preview { width: 40%; position: relative; background: #000; flex-shrink: 0; min-width: 300px; display: flex; flex-direction: column; min-height: 0; overflow: hidden;}
        
        #log-panel { 
            position: absolute; bottom: 0; left: 0; width: 100%; height: 200px; max-height: 40%;
            background: rgba(0,0,0,0.85); color: #00ffcc; font-family: 'Courier New', monospace; 
            font-size: 11px; padding: 10px; padding-top: 35px; box-sizing: border-box; 
            overflow-y: auto; pointer-events: auto; user-select: text; -webkit-user-select: text;
            border-top: 1px solid #333; z-index: 100;
        }

        #param-panel {
            position: absolute; display: none; background: rgba(15, 15, 20, 0.95);
            border: 1px solid #555; border-radius: 8px; padding: 12px;
            color: white; z-index: 100; pointer-events: auto;
            box-shadow: 0 10px 30px rgba(0,0,0,0.8); width: 220px;
            transform: translate(-50%, 20px);
        }
        #param-title { font-weight: bold; margin-bottom: 8px; border-bottom: 1px solid #444; padding-bottom: 4px; text-align: center; font-size: 12px; color: #aaa;}
        .param-row { display: flex; flex-direction: column; margin-bottom: 6px; }
        .param-row label { font-size: 10px; color: #ccc; margin-bottom: 2px; display: flex; justify-content: space-between;}
        .param-row input[type="range"] { width: 100%; cursor: pointer; margin: 0; accent-color: #00ffcc;}
        .param-row input[type="file"] { width: 100%; color: #fff; font-size: 10px; }

        #timeline-bar { flex-shrink: 0; height: 30px; background: #151520; border-top: 1px solid #333; display: flex; align-items: center; padding: 0 15px; gap: 15px;}
        #timeline-bar button { font-size: 14px; padding: 2px 8px; background: #00ffcc; color: #000; border: none; font-weight: bold;}
        #timeline-bar input[type="range"] { flex-grow: 1; cursor: pointer; accent-color: #00ffcc;}
        #time-display { font-family: monospace; font-size: 14px; color: #00ffcc; width: 40px; text-align: right;}

        #ctx-menu { position: absolute; display: none; background: rgba(25, 25, 30, 0.95); border: 1px solid #555; border-radius: 6px; padding: 4px 0; color: white; z-index: 200; box-shadow: 0 5px 15px rgba(0,0,0,0.8); min-width: 120px; font-size: 12px; }
        .ctx-item { padding: 8px 16px; cursor: pointer; transition: background 0.1s; }
        .ctx-item:hover { background: #00ffcc; color: #000; font-weight: bold; }
        
        #template-container { display: flex; gap: 4px; padding-left: 4px; flex-wrap: wrap;}
        .tpl-btn { font-size: 12px; padding: 4px 10px; background: #1a2a3a; border-color: #00ffcc; color: #00ffcc; }
        .tpl-btn:hover { background: #00ffcc; color: #000; }

        .dialog-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 3000; justify-content: center; align-items: center; }
        .dialog-box { background: #1a1a20; border: 1px solid #00ffcc; padding: 20px; border-radius: 8px; min-width: 300px; box-shadow: 0 10px 30px rgba(0,0,0,0.8); }
        .dialog-box h3 { margin-top: 0; color: #00ffcc; font-size: 16px; border-bottom: 1px solid #333; padding-bottom: 10px; }
        .dialog-row { margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
        .dialog-row input[type="number"] { width: 80px; background: #000; color: #fff; border: 1px solid #555; padding: 4px; text-align: right; }
        .dialog-btns { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
        .dialog-btns button { font-size: 14px; padding: 6px 16px; cursor: pointer;}
    </style>
</head>
<body>

    <div id="menubar">
        <div class="menu-item">ファイル
            <div class="submenu">
                <div class="sub-item" onclick="fileOps.new()">📄 新規作成</div>
                <div class="sub-item" onclick="fileOps.open()">📂 開く...</div>
                <div class="sub-item" onclick="fileOps.save()">💾 上書き保存</div>
                <div class="sub-item" onclick="fileOps.saveAs()">💾 別名で保存...</div>
                <div class="ctx-sep"></div>
                <div class="sub-item" onclick="fileOps.exportGLSL()">📜 GLSL出力</div>
                <div class="sub-item" onclick="fileOps.exportSpriteSheet()" style="color:#00ffcc; font-weight:bold;">🎬 スプライトシート出力...</div>
            </div>
        </div>
        <div class="menu-item">編集
            <div class="submenu">
                <div class="sub-item" onclick="undo()">⏪ 元に戻す (Undo)</div>
                <div class="sub-item" onclick="redo()">⏩ やり直し (Redo)</div>
            </div>
        </div>
        <div class="menu-item">設定
            <div class="submenu">
                <div class="sub-item" onclick="openDialog('dlg-layout')">📏 レイアウト...</div>
                <div class="sub-item" onclick="openDialog('dlg-timeline')">⏱️ タイムライン...</div>
            </div>
        </div>
        <div id="status-bar">
            <label style="cursor:pointer; display:flex; align-items:center; gap:4px; color:#aaa;"><input type="checkbox" id="snap-toggle">📏グリッドスナップ</label>
            <span style="color:#00ffcc;">🔍ズーム: <span id="zoom-val">100%</span></span>
            <span id="proj-name" style="margin-left: 10px;">新規プロジェクト</span>
        </div>
    </div>

    <div id="main-area">
        <div id="left-panel">
            <div class="panel-header">OBJECTS (大元)</div>
            <div id="object-list"></div>
            <div style="font-size:10px; color:#666; padding:10px; text-align:center; flex-shrink: 0;">↑ドラッグしてキャンバスへ配置</div>
        </div>

        <div id="editor">
            <div id="toolbar">
                <div class="toolbar-row">
                    <div class="btn-group"><span>ベース</span>
                        <button onclick="addNode('BASE')" title="大元の魔法陣を新規作成">🎇新規ベース</button>
                    </div>
                    <div class="btn-group"><span>見た目</span>
                        <button onclick="addNode('SHAPE')" title="形(多角形)">📐</button>
                        <button onclick="addNode('COLOR')" title="色(HSV)">🎨</button>
                        <button onclick="addNode('IMAGE')" title="画像(テクスチャ)読み込み">🖼️</button>
                        <button onclick="addNode('ANIMATION')" title="アニメ(スプライト)読み込み">🎞️</button>
                        <button onclick="addNode('CUSTOM')" title="生GLSLコード入力" style="background:#055;">💻カスタム</button>
                    </div>
                    <div class="btn-group"><span>特殊</span>
                        <button onclick="addNode('TIME')" title="時間制御(寿命/フェード)">⏳</button>
                        <button onclick="addNode('TRAIL')" title="残像・軌跡">💫</button>
                        <button onclick="addNode('FILTER_DISTORT')" title="空間の歪み(衝撃波)">🌊</button>
                        <button onclick="addNode('FILTER')" title="フィルター(発光/モザイク)">🌟</button>
                        <button onclick="addNode('PARTICLE')" title="パーティクル(爆発/破片)">✨</button>
                    </div>
                </div>
                <div class="toolbar-row">
                    <div class="btn-group"><span>動き</span>
                        <button onclick="addNode('M_LINEAR')" title="直線移動">➡️</button>
                        <button onclick="addNode('M_SINE')" title="反復移動">↔️</button>
                        <button onclick="addNode('M_BOUNCE')" title="バウンド">🪀</button>
                        <button onclick="addNode('M_PARABOLA')" title="放物線">⤴️</button>
                        <button onclick="addNode('M_SPIRAL')" title="渦巻き">🌀</button>
                        <button onclick="addNode('M_ROTATE')" title="空間回転(公転)">🔄</button>
                        <button onclick="addNode('M_HOMING')" title="ターゲット追尾">🎯</button>
                        <button onclick="addNode('M_EASING')" title="イージング(加減速)">🧲</button>
                        <button onclick="addNode('M_SHAKE')" title="揺らぎ(炎など)">🫨</button>
                        <button onclick="addNode('M_FALL')" title="落下と弾み(岩など)">🪨</button>
                    </div>
                    <button onclick="clearNodes()" style="margin-left:auto; font-size:12px;">🗑️ 全クリア</button>
                </div>
                <div class="toolbar-row" style="border-top: 1px solid #333; padding-top: 4px;">
                    <div class="btn-group"><span style="color:#00ffcc;">Template</span>
                        <button onclick="saveTemplate()" title="選択中のオブジェクトをテンプレートとして保存" style="font-size:12px; background:#005544;">➕ 登録</button>
                    </div>
                    <div id="template-container"></div>
                </div>
            </div>
            
            <div id="canvas-container">
                <canvas id="ui-canvas"></canvas>
                <div id="param-panel"><div id="param-title">調整</div><div id="param-content"></div></div>
                <div id="ctx-menu">
                    <div class="ctx-item" id="ctx-cut">✂️ カット</div><div class="ctx-item" id="ctx-copy">📄 コピー</div><div class="ctx-item" id="ctx-paste">📋 ペースト</div>
                    <div class="ctx-sep" id="ctx-sep-1"></div><div class="ctx-item" id="ctx-delete" style="color:#ff6666;">🗑️ 削除</div>
                </div>
            </div>
        </div>
        
        <div id="preview">
            <div id="log-panel" onwheel="event.stopPropagation()">
                <button onclick="copyGLSL()" style="position: absolute; top: 5px; right: 10px; font-size: 12px; background: #223; border: 1px solid #00ffcc; color: #00ffcc; padding: 4px 8px; border-radius: 4px; cursor: pointer;">📋 コードをコピー</button>
                <div id="glsl-content" style="white-space: pre-wrap; word-wrap: break-word;">// 💻「カスタムGLSL」宝石が追加されました!</div>
            </div>
        </div>
    </div>

    <div id="timeline-bar">
        <button id="play-btn" onclick="togglePlay()">⏸️</button>
        <input type="range" id="time-slider" min="0" max="10" step="0.01" value="0">
        <span id="time-display">0.00</span>
    </div>

    <div id="dlg-layout" class="dialog-overlay">
        <div class="dialog-box">
            <h3>レイアウト設定</h3>
            <div class="dialog-row"><label>幅 (px):</label> <input type="number" id="inp-ly-w" value="2000"></div>
            <div class="dialog-row"><label>高さ (px):</label> <input type="number" id="inp-ly-h" value="2000"></div>
            <div class="dialog-row"><label>グリッドサイズ:</label> <input type="number" id="inp-ly-grid" value="50"></div>
            <hr style="border-color:#444;">
            <div class="dialog-row"><label>上下左右に拡張(+px):</label> <input type="number" id="inp-ly-add" value="0"></div>
            <div style="text-align:right;"><button onclick="addLayoutSize()" style="background:#005544; color:#fff; border:none; padding:4px 8px; border-radius:4px;">↑拡張を実行</button></div>
            <div class="dialog-btns">
                <button onclick="applyLayoutSettings()" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">OK</button>
                <button onclick="closeDialog('dlg-layout')" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
            </div>
        </div>
    </div>

    <div id="dlg-timeline" class="dialog-overlay">
        <div class="dialog-box">
            <h3>タイムライン設定</h3>
            <div class="dialog-row"><label>長さ (秒):</label> <input type="number" id="inp-tl-len" step="0.1" value="10"></div>
            <div class="dialog-row"><label>再生速度 (倍):</label> <input type="number" id="inp-tl-spd" step="0.1" value="1.0"></div>
            <div class="dialog-row"><label>ループ再生:</label> <input type="checkbox" id="inp-tl-loop" checked style="width:auto;"></div>
            <div class="dialog-btns">
                <button onclick="applyTimelineSettings()" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">OK</button>
                <button onclick="closeDialog('dlg-timeline')" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
            </div>
        </div>
    </div>

    <input type="file" id="file-loader" accept=".json" style="display:none">

    <script type="importmap">
        { "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
    </script>

    <script type="module">
        import * as THREE from 'three';

        // ==========================================
        // 0. DOM参照・グローバル変数の初期化
        // ==========================================
        const previewContainer = document.getElementById('preview');
        const canvas = document.getElementById('ui-canvas');
        const container = document.getElementById('canvas-container');
        const ctx = canvas.getContext('2d');
        const paramPanel = document.getElementById('param-panel');
        const paramContent = document.getElementById('param-content');
        const paramTitle = document.getElementById('param-title');
        const ctxMenu = document.getElementById('ctx-menu');
        const timeSlider = document.getElementById('time-slider');
        const timeDisplay = document.getElementById('time-display');
        const playBtn = document.getElementById('play-btn');
        const objList = document.getElementById('object-list');
        const glslContent = document.getElementById('glsl-content');
        const zoomValDisplay = document.getElementById('zoom-val');

        let nodes = [];
        let globalSelectedNode = null; 
        let history = []; let historyIndex = -1; let isRestoring = false;

        let layoutW = 2000; let layoutH = 2000; let gridSz = 50; let zoom = 1.0;
        let tDuration = 10.0; let tSpeed = 1.0; let tLoop = true;
        let isPlaying = true; let currentTime = 0.0;

        let draggedNode = null; let isDragging = false; let dragStartPos = {x:0, y:0}; let hoverTarget = null;
        let isPanning = false; let panStart = {x:0, y:0}; let scrollStart = {x:0, y:0};
        let lastClickTime = 0; let lastClickNode = null;
        let clipboardData = null; let ctxMousePos = {x:0, y:0};

        // ==========================================
        // 1. Three.js エンジンの初期化
        // ==========================================
        const scene = new THREE.Scene();
        const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
        const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true }); 
        
        window.uniforms = { u_time: { value: 0.0 }, u_aspect: { value: 1.0 } };
        
        let pw = previewContainer.clientWidth || 300; let ph = previewContainer.clientHeight || 300;
        renderer.setSize(pw, ph); window.uniforms.u_aspect.value = pw / ph;
        previewContainer.appendChild(renderer.domElement);
        
        const geometry = new THREE.PlaneGeometry(2, 2);
        let mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({color: 0x000000}));
        scene.add(mesh);
        
        const clock = new THREE.Clock();

        const resizeObserver = new ResizeObserver(() => {
            const w = previewContainer.clientWidth; const h = previewContainer.clientHeight;
            if(w > 0 && h > 0) { renderer.setSize(w, h); if(window.uniforms) window.uniforms.u_aspect.value = w / h; }
        });
        resizeObserver.observe(previewContainer);

        const textureLoader = new THREE.TextureLoader(); const textures = {}; 
        function loadTexture(id, dataUrl) {
            if(!dataUrl) return;
            textureLoader.load(dataUrl, tex => { tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter; textures[id] = tex; updateShader(); });
        }

        // ==========================================
        // 2. ノードの定義 (NODE_DEFS) - CUSTOM宝石追加
        // ==========================================
        const NODE_DEFS = {
            BASE: { type: 'BASE', emoji: '🎇', color: '#444455', radiusClosed: 16, radiusOpen: 60 },
            // 見た目
            COLOR: { type: 'PARAM', subtype: 'COLOR', emoji: '🎨', color: '#ff55aa', radius: 14, params: [
                { key: 'h', label: '色相 (Hue)', min: 0, max: 1, def: 0.1, step: 0.01 },
                { key: 's', label: '彩度 (Sat)', min: 0, max: 1, def: 0.8, step: 0.01 },
                { key: 'v', label: '明度 (Val)', min: 0, max: 2, def: 1.5, step: 0.01 }
            ]},
            SHAPE: { type: 'PARAM', subtype: 'SHAPE', emoji: '📐', color: '#55aaff', radius: 14, params: [
                { key: 'n', label: '何角形? (0=円)', min: 0, max: 8, def: 4, step: 1 },
                { key: 'size', label: '大きさ', min: 0.01, max: 0.5, def: 0.1, step: 0.01 }
            ]},
            IMAGE: { type: 'PARAM', subtype: 'IMAGE', emoji: '🖼️', color: '#ffaaaa', radius: 14, params: [
                { key: 'img', label: '画像', type: 'image', def: '' },
                { key: 'size', label: '大きさ', min: 0.01, max: 1.0, def: 0.2, step: 0.01 }
            ]},
            ANIMATION: { type: 'PARAM', subtype: 'ANIMATION', emoji: '🎞️', color: '#ff88aa', radius: 14, params: [
                { key: 'img', label: '横並び画像', type: 'image', def: '' },
                { key: 'frames', label: 'フレーム総数', min: 1, max: 64, def: 4, step: 1 },
                { key: 'speed', label: '1Fの表示(秒)', min: 0.01, max: 1.0, def: 0.1, step: 0.01 },
                { key: 'size', label: '大きさ', min: 0.01, max: 1.0, def: 0.2, step: 0.01 }
            ]},
            CUSTOM: { type: 'PARAM', subtype: 'CUSTOM', emoji: '💻', color: '#00ffaa', radius: 14, params: [
                { key: 'code', label: 'GLSL(変数:p, t_tr を使用)', type: 'textarea', def: '// 円を波打たせるサンプル\nlocal_d = length(p) - 0.2 + sin(p.y*20.0 + t_tr*10.0)*0.05;\nlocal_col = vec3(0.0, 1.0, 0.5);' }
            ]},
            // 動き
            M_LINEAR: { type: 'PARAM', subtype: 'MOTION_LINEAR', emoji: '➡️', color: '#aaff55', radius: 14, params: [
                { key: 'vx', label: 'X移動 (右/左)', min: -5, max: 5, def: 0.5, step: 0.01 },
                { key: 'vy', label: 'Y移動 (上/下)', min: -5, max: 5, def: 0.0, step: 0.01 }
            ]},
            M_SINE: { type: 'PARAM', subtype: 'MOTION_SINE', emoji: '↔️', color: '#55ffaa', radius: 14, params: [
                { key: 'amp', label: '振幅', min: 0, max: 2, def: 0.5, step: 0.01 },
                { key: 'angle', label: '角度(斜め)', min: 0, max: 360, def: 0, step: 1 },
                { key: 'freq', label: '速さ', min: 0, max: 10, def: 2.0, step: 0.1 }
            ]},
            M_BOUNCE: { type: 'PARAM', subtype: 'MOTION_BOUNCE', emoji: '🪀', color: '#ddff55', radius: 14, params: [
                { key: 'height', label: '跳ね高さ', min: 0, max: 1, def: 0.3, step: 0.01 },
                { key: 'speed', label: '速さ', min: 0, max: 10, def: 4.0, step: 0.1 }
            ]},
            M_PARABOLA: { type: 'PARAM', subtype: 'MOTION_PARABOLA', emoji: '⤴️', color: '#aaddff', radius: 14, params: [
                { key: 'vx', label: 'X速度', min: -2, max: 2, def: 0.5, step: 0.01 },
                { key: 'vy', label: '初期Yジャンプ', min: 0, max: 3, def: 1.5, step: 0.01 },
                { key: 'g', label: '重力', min: 0, max: 5, def: 3.0, step: 0.1 }
            ]},
            M_SPIRAL: { type: 'PARAM', subtype: 'MOTION_SPIRAL', emoji: '🌀', color: '#55ffdd', radius: 14, params: [
                { key: 'radius', label: '広がる速さ', min: 0, max: 1, def: 0.3, step: 0.01 },
                { key: 'spin', label: '回転速度', min: -10, max: 10, def: 5.0, step: 0.1 },
                { key: 'limit', label: '最大範囲', min: 0.1, max: 5.0, def: 2.0, step: 0.1 } 
            ]},
            M_ROTATE: { type: 'PARAM', subtype: 'MOTION_ROTATE', emoji: '🔄', color: '#cc88ff', radius: 14, params: [
                { key: 'speed', label: '公転/自転速度', min: -10, max: 10, def: 2.0, step: 0.1 }
            ]},
            M_EASING: { type: 'PARAM', subtype: 'MOTION_EASING', emoji: '🧲', color: '#55aa55', radius: 14, params: [
                { key: 'mode', label: '1:In 2:Out 3:Smooth', min: 1, max: 3, def: 3, step: 1 }
            ]},
            M_HOMING: { type: 'PARAM', subtype: 'MOTION_HOMING', emoji: '🎯', color: '#aa5555', radius: 14, params: [
                { key: 'tx', label: 'ターゲット X', min: -5, max: 5, def: 0.0, step: 0.01 },
                { key: 'ty', label: 'ターゲット Y', min: -5, max: 5, def: 0.0, step: 0.01 },
                { key: 'speed', label: '追尾の強さ', min: 0, max: 5, def: 1.0, step: 0.01 }
            ]},
            M_SHAKE: { type: 'PARAM', subtype: 'MOTION_SHAKE', emoji: '🫨', color: '#dd8855', radius: 14, params: [
                { key: 'amp', label: '揺れの強さ', min: 0, max: 1, def: 0.1, step: 0.01 },
                { key: 'speed', label: '速さ', min: 0, max: 50, def: 15.0, step: 0.1 }
            ]},
            M_FALL: { type: 'PARAM', subtype: 'MOTION_FALL', emoji: '🪨', color: '#888888', radius: 14, params: [
                { key: 'g', label: '落下速度', min: 0, max: 5, def: 2.0, step: 0.1 },
                { key: 'spin', label: '回転', min: -5, max: 5, def: 1.0, step: 0.1 }
            ]},
            // 特殊
            TIME: { type: 'PARAM', subtype: 'TIME', emoji: '⏳', color: '#ffaa00', radius: 14, params: [
                { key: 'delay', label: '発生遅延(秒)', min: 0, max: 5, def: 0.0, step: 0.1 },
                { key: 'life', label: '寿命(ループ秒数)', min: 0.1, max: 10, def: 3.0, step: 0.1 },
                { key: 'fade', label: '時間で消滅(0=無 1=有)', min: 0, max: 1, def: 1.0, step: 1 }
            ]},
            FILTER: { type: 'PARAM', subtype: 'FILTER', emoji: '🌟', color: '#ddddff', radius: 14, params: [
                { key: 'pixel', label: 'ドット化(粗さ)', min: 0, max: 50, def: 0, step: 1 },
                { key: 'glow', label: '発光(Glow)強度', min: 0, max: 2, def: 0.0, step: 0.01 }
            ]},
            PARTICLE: { type: 'PARAM', subtype: 'PARTICLE', emoji: '✨', color: '#ffcc55', radius: 14, params: [
                { key: 'count', label: '破片の数', min: 1, max: 30, def: 10, step: 1 },
                { key: 'spread', label: '散らばり幅', min: 0, max: 2, def: 0.5, step: 0.01 },
                { key: 'speed', label: '弾ける速さ', min: 0, max: 5, def: 1.0, step: 0.1 }
            ]},
            TRAIL: { type: 'PARAM', subtype: 'TRAIL', emoji: '💫', color: '#ffdd55', radius: 14, params: [
                { key: 'count', label: '残像の数', min: 1, max: 20, def: 5, step: 1 },
                { key: 'interval', label: '間隔(秒)', min: 0.01, max: 0.5, def: 0.05, step: 0.01 },
                { key: 'fade', label: '減衰率(0-1)', min: 0.1, max: 1.0, def: 0.6, step: 0.01 }
            ]},
            FILTER_DISTORT: { type: 'PARAM', subtype: 'FILTER_DISTORT', emoji: '🌊', color: '#5555ff', radius: 14, params: [
                { key: 'freq', label: '波の細かさ', min: 1, max: 20, def: 5.0, step: 0.1 },
                { key: 'power', label: '歪みの強さ', min: 0, max: 1.0, def: 0.1, step: 0.01 },
                { key: 'speed', label: '波の速さ', min: 0, max: 10, def: 2.0, step: 0.1 }
            ]}
        };

        // ==========================================
        // 3. データ処理・UIヘルパー関数群
        // ==========================================
        function serializeNode(node) {
            let clone = { id: node.id, x: node.x, y: node.y, type: node.type, subtype: node.subtype, refId: node.refId, emoji: node.emoji, color: node.color, radiusClosed: node.radiusClosed, radiusOpen: node.radiusOpen, radius: node.radius, params: node.params, vals: node.vals, isOpen: node.isOpen };
            if(node.children) clone.children = node.children.map(c => serializeNode(c));
            return clone;
        }

        function deserializeNode(data, x, y, keepId = false) {
            let newId = keepId && data.id ? data.id : Math.random().toString(36).substr(2, 9);
            let n = { id: newId, x: x !== undefined ? x : (data.x || layoutW/2), y: y !== undefined ? y : (data.y || layoutH/2), type: data.type, subtype: data.subtype, refId: data.refId, emoji: data.emoji, color: data.color, radiusClosed: data.radiusClosed, radiusOpen: data.radiusOpen, radius: data.radius, params: data.params, vals: data.vals, isOpen: data.isOpen, parent: null, children: [] };
            nodes.push(n);
            if(data.children) { data.children.forEach(cData => { let child = deserializeNode(cData, cData.x, cData.y, keepId); child.parent = n; n.children.push(child); }); }
            return n;
        }

        function pushHistory() {
            if(isRestoring) return;
            history = history.slice(0, historyIndex + 1);
            let state = { nodes: nodes.filter(n=>!n.parent).map(serializeNode), layout: { w: layoutW, h: layoutH, g: gridSz } };
            history.push(JSON.stringify(state)); historyIndex++;
        }
        
        function applyState(state) {
            clearNodesCore();
            layoutW = state.layout.w; layoutH = state.layout.h; gridSz = state.layout.g;
            document.getElementById('inp-ly-w').value = layoutW; document.getElementById('inp-ly-h').value = layoutH; document.getElementById('inp-ly-grid').value = gridSz;
            state.nodes.forEach(data => deserializeNode(data, data.x, data.y, true)); 
            let texNodes = getAllNodes(nodes).filter(n => n.subtype === 'IMAGE' || n.subtype === 'ANIMATION');
            texNodes.forEach(n => { if(n.vals.img) loadTexture(n.id, n.vals.img); });
            resizeCanvas(); updateState();
        }

        window.undo = () => { if(historyIndex > 0) { historyIndex--; isRestoring = true; applyState(JSON.parse(history[historyIndex])); isRestoring = false; } };
        window.redo = () => { if(historyIndex < history.length - 1) { historyIndex++; isRestoring = true; applyState(JSON.parse(history[historyIndex])); isRestoring = false; } };
        window.openDialog = (id) => { document.getElementById(id).style.display = 'flex'; };
        window.closeDialog = (id) => { document.getElementById(id).style.display = 'none'; };

        window.applyLayoutSettings = () => {
            layoutW = parseInt(document.getElementById('inp-ly-w').value); layoutH = parseInt(document.getElementById('inp-ly-h').value); gridSz = parseInt(document.getElementById('inp-ly-grid').value);
            resizeCanvas(); closeDialog('dlg-layout'); pushHistory();
        };

        window.addLayoutSize = () => {
            let add = parseInt(document.getElementById('inp-ly-add').value);
            if(isNaN(add) || add === 0) return;
            layoutW += add * 2; layoutH += add * 2;
            document.getElementById('inp-ly-w').value = layoutW; document.getElementById('inp-ly-h').value = layoutH;
            nodes.filter(n => !n.parent).forEach(root => { root.x += add; root.y += add; });
            resizeCanvas(); pushHistory(); alert("拡張しました");
        };

        window.applyTimelineSettings = () => {
            tDuration = parseFloat(document.getElementById('inp-tl-len').value); tSpeed = parseFloat(document.getElementById('inp-tl-spd').value); tLoop = document.getElementById('inp-tl-loop').checked;
            timeSlider.max = tDuration; closeDialog('dlg-timeline');
        };

        window.togglePlay = () => { isPlaying = !isPlaying; playBtn.innerText = isPlaying ? '⏸️' : '▶️'; };
        timeSlider.addEventListener('input', (e) => { currentTime = parseFloat(e.target.value); timeDisplay.innerText = currentTime.toFixed(2); window.uniforms.u_time.value = currentTime; });

        window.copyGLSL = () => { navigator.clipboard.writeText(glslContent.innerText).then(() => alert('コピーしました!')); };

        let templates = JSON.parse(localStorage.getItem('gemTemplates') || '[]');
        window.saveTemplate = () => {
            if(!globalSelectedNode) return alert("対象を選択してください。");
            let name = prompt("テンプレート名を入力:"); if(!name) return;
            templates.push({ name: name, data: serializeNode(globalSelectedNode) });
            localStorage.setItem('gemTemplates', JSON.stringify(templates)); renderTemplates();
        };
        function renderTemplates() {
            const tc = document.getElementById('template-container'); tc.innerHTML = '';
            templates.forEach((tpl, i) => {
                let btn = document.createElement('button'); btn.className = 'tpl-btn'; btn.innerText = tpl.name;
                btn.onclick = () => { 
                    let n = deserializeNode(tpl.data, layoutW/2, layoutH/2, false); 
                    let texNodes = getAllNodes([n]).filter(x => x.subtype === 'IMAGE' || x.subtype === 'ANIMATION');
                    texNodes.forEach(x => { if(x.vals.img) loadTexture(x.id, x.vals.img); });
                    globalSelectedNode = n; updateState(); pushHistory();
                };
                btn.oncontextmenu = (e) => { e.preventDefault(); if(confirm(`削除しますか?`)){ templates.splice(i, 1); localStorage.setItem('gemTemplates', JSON.stringify(templates)); renderTemplates(); } };
                tc.appendChild(btn);
            });
        }

        const fileOps = {
            currentHandle: null,
            new: () => { if(confirm("新規作成しますか?")) { clearNodes(); fileOps.currentHandle = null; document.getElementById('proj-name').innerText = "新規プロジェクト"; pushHistory(); } },
            open: async () => {
                const input = document.getElementById('file-loader');
                input.onchange = async (e) => {
                    const file = e.target.files[0];
                    let state = JSON.parse(await file.text());
                    if(Array.isArray(state)) state = { nodes: state, layout: {w:2000, h:2000, g:50} }; 
                    applyState(state);
                    document.getElementById('proj-name').innerText = file.name; input.value = ''; pushHistory();
                };
                input.click();
            },
            saveAs: async () => {
                let state = { nodes: nodes.filter(n=>!n.parent).map(serializeNode), layout: { w: layoutW, h: layoutH, g: gridSz } };
                const blob = new Blob([JSON.stringify(state, null, 2)], {type: "application/json"});
                const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = "effect_project.json"; a.click();
            },
            save: () => fileOps.saveAs(),
            exportGLSL: () => window.copyGLSL(),
            exportSpriteSheet: () => {
                let durationStr = prompt("アニメーションの総時間(秒)を入力してください", "2.0"); if(!durationStr) return;
                let fpsStr = prompt("フレームレート(FPS)を入力してください\n※高いと画像が巨大になります", "30"); if(!fpsStr) return;

                let duration = parseFloat(durationStr); let fps = parseInt(fpsStr);
                let totalFrames = Math.ceil(duration * fps);
                let cols = Math.ceil(Math.sqrt(totalFrames)); let rows = Math.ceil(totalFrames / cols);
                let frameW = 256; let frameH = 256;
                
                let outCanvas = document.createElement('canvas'); outCanvas.width = cols * frameW; outCanvas.height = rows * frameH;
                let outCtx = outCanvas.getContext('2d');
                
                let oldTime = window.uniforms.u_time.value; 
                let oldW = previewContainer.clientWidth; let oldH = previewContainer.clientHeight;
                renderer.setSize(frameW, frameH); window.uniforms.u_aspect.value = frameW / frameH;
                
                for(let i=0; i<totalFrames; i++) {
                    window.uniforms.u_time.value = i / fps; renderer.render(scene, camera);
                    let col = i % cols; let row = Math.floor(i / cols);
                    outCtx.drawImage(renderer.domElement, 0, 0, frameW, frameH, col * frameW, row * frameH, frameW, frameH);
                }
                
                window.uniforms.u_time.value = oldTime; 
                renderer.setSize(oldW, oldH); window.uniforms.u_aspect.value = oldW / oldH;
                
                let a = document.createElement('a'); a.href = outCanvas.toDataURL('image/png'); a.download = 'spritesheet.png'; a.click();
            }
        };
        window.fileOps = fileOps;

        // ==========================================
        // 4. 左側リスト更新とドラッグ&ドロップ(BASE_REF)
        // ==========================================
        function updateObjectList() {
            objList.innerHTML = '';
            let rootBases = nodes.filter(n => n.type === 'BASE' && !n.parent);
            rootBases.forEach((root, idx) => {
                let div = document.createElement('div'); div.className = 'obj-list-item'; div.innerText = `${root.emoji} Base ${idx + 1}`;
                if(globalSelectedNode === root) div.style.borderColor = '#00ffcc';
                div.draggable = true; div.ondragstart = (e) => { e.dataTransfer.setData('text/plain', root.id); };
                div.onclick = () => { globalSelectedNode = root; updateObjectList(); };
                objList.appendChild(div);
            });
        }

        canvas.addEventListener('dragover', e => { e.preventDefault(); });
        canvas.addEventListener('drop', e => {
            e.preventDefault(); const refId = e.dataTransfer.getData('text/plain');
            if(refId) {
                const rect = canvas.getBoundingClientRect();
                let nx = (e.clientX - rect.left) / zoom; let ny = (e.clientY - rect.top) / zoom;
                if(document.getElementById('snap-toggle').checked) { nx = Math.round(nx/gridSz)*gridSz; ny = Math.round(ny/gridSz)*gridSz; }
                let n = { id: Math.random().toString(36).substr(2, 9), type: 'BASE_REF', refId: refId, x: nx, y: ny, parent: null, children: [], isOpen: false };
                nodes.push(n); globalSelectedNode = n; updateState(); pushHistory();
            }
        });

        function updateState() { updateObjectList(); updateShader(); }

        // ==========================================
        // 5. ツリー構造・UIロジック (スクロール/ズーム対応)
        // ==========================================
        function resizeCanvas() {
            canvas.width = layoutW * zoom; canvas.height = layoutH * zoom;
            canvas.style.width = `${layoutW * zoom}px`; canvas.style.height = `${layoutH * zoom}px`;
            zoomValDisplay.innerText = `${Math.round(zoom * 100)}%`;
            updateState();
        }

        function clearNodesCore() { nodes = []; globalSelectedNode = null; closeParamPanel(); }
        window.clearNodes = () => { clearNodesCore(); updateState(); pushHistory(); };

        window.addNode = (defKey) => {
            const def = NODE_DEFS[defKey]; const vals = {}; if(def.params) def.params.forEach(p => vals[p.key] = p.def);
            let cx = (container.scrollLeft + container.clientWidth/2) / zoom; let cy = (container.scrollTop + container.clientHeight/2) / zoom;
            let n = { id: Math.random().toString(36).substr(2, 9), ...def, vals: vals, x: cx, y: cy, parent: null, children: [], isOpen: true };
            nodes.push(n); globalSelectedNode = n; updateState(); pushHistory();
        };

        function isBaseOpen(node) {
            if(node.type !== 'BASE' && node.type !== 'BASE_REF') return false;
            if(node.isOpen) return true;
            if(node.children && node.children.some(c => (c.type === 'BASE' || c.type === 'BASE_REF') && isBaseOpen(c))) return true;
            return false;
        }

        function getAllNodes(nodeList, visited = new Set()) {
            let result = [];
            nodeList.forEach(n => { if(visited.has(n)) return; visited.add(n); result.push(n); if(n.children) result = result.concat(getAllNodes(n.children, visited)); });
            return result;
        }

        function getVisibleNodes(nodeList, visited = new Set()) {
            let result = [];
            nodeList.forEach(n => {
                if(visited.has(n)) return; visited.add(n); result.push(n);
                if(n.type === 'BASE' || n.type === 'BASE_REF') { if(n.isOpen && n.children) result = result.concat(getVisibleNodes(n.children, visited)); } 
                else if(n.children) { result = result.concat(getVisibleNodes(n.children, visited)); }
            });
            return result;
        }

        function getHitTarget(pos, ignoreNode = null) {
            let visibleAll = getVisibleNodes(nodes.filter(n => !n.parent)).filter(n => n !== ignoreNode);
            let target = null; let minR = 9999;
            visibleAll.forEach(n => {
                if(n.screenX === undefined) return;
                let r = (n.type === 'BASE' || n.type === 'BASE_REF') ? (n.isOpen ? n.radiusOpen : n.radiusClosed) : n.radius;
                if(Math.hypot(n.screenX - pos.x, n.screenY - pos.y) < r + 5) { if (r < minR) { minR = r; target = n; } }
            });
            return target;
        }

        function isDescendant(parent, child) {
            if(!parent.children) return false; if(parent.children.includes(child)) return true;
            return parent.children.some(c => isDescendant(c, child));
        }
        function getDepth(node) { let d = 0; let curr = node; while(curr.parent) { d++; curr = curr.parent; } return d; }

        function getDropTarget(dragNode) {
            let target = null; let maxDepth = -1;
            let visibleAll = getVisibleNodes(nodes.filter(n => !n.parent)).filter(n => n !== dragNode && !isDescendant(dragNode, n));
            visibleAll.forEach(n => {
                if(n.screenX === undefined || (n.type !== 'BASE' && n.type !== 'BASE_REF')) return;
                let r = n.isOpen ? n.radiusOpen : n.radiusClosed;
                if(Math.hypot(n.screenX - dragNode.x, n.screenY - dragNode.y) < r + 15) {
                    let depth = getDepth(n); if(depth > maxDepth) { maxDepth = depth; target = n; }
                }
            });
            return target;
        }

        function openParamPanel(node) {
            if (node.type === 'BASE' || node.type === 'BASE_REF') return;
            paramPanel.style.display = 'block'; paramTitle.innerText = `${node.emoji} の調整`;
            paramContent.innerHTML = '';
            
            node.params.forEach(p => {
                let row = document.createElement('div'); row.className = 'param-row';
                let labelDiv = document.createElement('label'); let nameSpan = document.createElement('span'); nameSpan.innerText = p.label;
                let valSpan = document.createElement('span'); valSpan.innerText = (p.type === 'image' || p.type === 'textarea') ? '' : node.vals[p.key];
                labelDiv.appendChild(nameSpan); labelDiv.appendChild(valSpan);
                
                if(p.type === 'image') {
                    let input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*';
                    let thumb = document.createElement('img'); thumb.style.width = '100%'; thumb.style.height = '40px'; thumb.style.objectFit = 'contain'; 
                    thumb.style.display = node.vals[p.key] ? 'block' : 'none'; if(node.vals[p.key]) thumb.src = node.vals[p.key];
                    input.addEventListener('change', (e) => {
                        const file = e.target.files[0]; if(!file) return; const reader = new FileReader();
                        reader.onload = (re) => { node.vals[p.key] = re.target.result; thumb.src = re.target.result; thumb.style.display = 'block'; loadTexture(node.id, re.target.result); pushHistory();};
                        reader.readAsDataURL(file);
                    });
                    row.appendChild(labelDiv); row.appendChild(input); row.appendChild(thumb);
                } else if(p.type === 'textarea') {
                    let input = document.createElement('textarea'); 
                    input.style.width = '100%'; input.style.height = '80px'; 
                    input.style.background = '#050508'; input.style.color = '#00ffcc'; 
                    input.style.border = '1px solid #555'; input.style.fontFamily = 'monospace'; 
                    input.style.fontSize = '10px'; input.style.resize = 'vertical';
                    input.value = node.vals[p.key];
                    input.addEventListener('input', (e) => { node.vals[p.key] = e.target.value; updateState(); });
                    input.addEventListener('change', () => { pushHistory(); });
                    row.appendChild(labelDiv); row.appendChild(input); 
                } else {
                    let input = document.createElement('input'); input.type = 'range'; input.min = p.min; input.max = p.max; input.step = p.step; input.value = node.vals[p.key];
                    input.addEventListener('input', (e) => { let val = parseFloat(e.target.value); node.vals[p.key] = val; valSpan.innerText = val.toFixed(2); updateState(); });
                    input.addEventListener('change', () => { pushHistory(); });
                    row.appendChild(labelDiv); row.appendChild(input); 
                }
                paramContent.appendChild(row);
            });
            updateParamPanelPos(node);
        }
        function closeParamPanel() { paramPanel.style.display = 'none'; }
        function updateParamPanelPos(node = globalSelectedNode) {
            if(!node || paramPanel.style.display === 'none') return;
            if(node.screenX !== undefined) { 
                paramPanel.style.left = (node.screenX * zoom) + 'px'; 
                paramPanel.style.top = ((node.screenY + 30) * zoom) + 'px'; 
            }
        }

        const getPos = (e) => { const rect = canvas.getBoundingClientRect(); return { x: ((e.touches ? e.touches[0].clientX : e.clientX) - rect.left) / zoom, y: ((e.touches ? e.touches[0].clientY : e.clientY) - rect.top) / zoom }; };

        canvas.addEventListener('pointerdown', (e) => {
            if(e.button === 2) return; canvas.setPointerCapture(e.pointerId); ctxMenu.style.display = 'none'; 
            const pos = getPos(e); dragStartPos = pos; isDragging = false; isPanning = false; hoverTarget = null; 
            draggedNode = getHitTarget(pos);

            if(draggedNode) { 
                globalSelectedNode = draggedNode; updateObjectList(); 
            } else { 
                globalSelectedNode = null; closeParamPanel(); updateObjectList(); 
                isPanning = true; panStart = {x: e.clientX, y: e.clientY}; scrollStart = {x: container.scrollLeft, y: container.scrollTop};
            }
        });

        canvas.addEventListener('pointermove', (e) => {
            if(isPanning) { container.scrollLeft = scrollStart.x - (e.clientX - panStart.x); container.scrollTop = scrollStart.y - (e.clientY - panStart.y); return; }

            if(!draggedNode) return;
            const pos = getPos(e); 
            if(document.getElementById('snap-toggle').checked) { pos.x = Math.round(pos.x/gridSz)*gridSz; pos.y = Math.round(pos.y/gridSz)*gridSz; }
            
            if(!isDragging && Math.hypot(pos.x - dragStartPos.x, pos.y - dragStartPos.y) > 4) {
                isDragging = true;
                if(draggedNode.parent) { draggedNode.parent.children = draggedNode.parent.children.filter(c => c !== draggedNode); draggedNode.parent = null; draggedNode.x = pos.x; draggedNode.y = pos.y; updateState(); }
            }
            if(isDragging) { draggedNode.x = pos.x; draggedNode.y = pos.y; hoverTarget = getDropTarget(draggedNode); updateParamPanelPos(); if(draggedNode.type === 'BASE') updateState(); }
        });

        canvas.addEventListener('pointerup', () => {
            if(isPanning) { isPanning = false; return; }
            if(draggedNode) {
                if(!isDragging) {
                    let now = Date.now();
                    if(now - lastClickTime < 300 && lastClickNode === draggedNode) {
                        if(draggedNode.type === 'BASE' || draggedNode.type === 'BASE_REF') { draggedNode.isOpen = !draggedNode.isOpen; closeParamPanel(); } 
                        else if(draggedNode.type === 'PARAM') { openParamPanel(draggedNode); }
                        lastClickTime = 0; 
                    } else { lastClickTime = now; lastClickNode = draggedNode; }
                } else {
                    if(hoverTarget) {
                        if(hoverTarget.type === 'BASE_REF' && draggedNode.type === 'PARAM') {
                            let actualBase = nodes.find(n => n.id === hoverTarget.refId);
                            if(actualBase) { draggedNode.parent = actualBase; actualBase.children.push(draggedNode); actualBase.isOpen = true; }
                        } else { draggedNode.parent = hoverTarget; hoverTarget.children.push(draggedNode); hoverTarget.isOpen = true; }
                    }
                    pushHistory(); 
                }
            }
            draggedNode = null; isDragging = false; hoverTarget = null; updateState(); updateParamPanelPos();
        });

        window.addEventListener('keydown', (e) => {
            if(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
            if(e.key === 'Delete' || e.key === 'Backspace') {
                if(globalSelectedNode) { removeNode(globalSelectedNode); globalSelectedNode = null; updateState(); pushHistory(); }
            }
            if(e.key === 'PageUp') { zoom = Math.min(zoom + 0.1, 3.0); resizeCanvas(); e.preventDefault(); }
            if(e.key === 'PageDown') { zoom = Math.max(zoom - 0.1, 0.2); resizeCanvas(); e.preventDefault(); }
        });

        // ==========================================
        // 6. 右クリックコンテキストメニュー
        // ==========================================
        canvas.addEventListener('contextmenu', (e) => {
            e.preventDefault(); const pos = getPos(e); ctxMousePos = { x: pos.x, y: pos.y };
            let target = getHitTarget(pos); if(target) globalSelectedNode = target;
            ctxMenu.style.display = 'block'; ctxMenu.style.left = e.clientX + 'px'; ctxMenu.style.top = e.clientY + 'px';
            document.getElementById('ctx-cut').style.display = globalSelectedNode ? 'block' : 'none';
            document.getElementById('ctx-copy').style.display = globalSelectedNode ? 'block' : 'none';
            document.getElementById('ctx-delete').style.display = globalSelectedNode ? 'block' : 'none';
            document.getElementById('ctx-paste').style.display = clipboardData ? 'block' : 'none';
            if(!globalSelectedNode && !clipboardData) ctxMenu.style.display = 'none';
        });
        window.addEventListener('click', (e) => { if(!e.target.closest('#ctx-menu')) ctxMenu.style.display = 'none'; });

        function removeNode(node) {
            if(node.parent) node.parent.children = node.parent.children.filter(c => c !== node);
            function getFlat(nList) { let res = []; nList.forEach(nx => { res.push(nx); if(nx.children) res = res.concat(getFlat(nx.children)); }); return res; }
            let toDelete = getFlat([node]); nodes = nodes.filter(n => !toDelete.includes(n));
            if(globalSelectedNode === node) { closeParamPanel(); globalSelectedNode = null; }
        }

        document.getElementById('ctx-delete').onclick = () => { if(!globalSelectedNode) return; removeNode(globalSelectedNode); ctxMenu.style.display = 'none'; updateState(); pushHistory(); };
        document.getElementById('ctx-copy').onclick = () => { if(globalSelectedNode) clipboardData = serializeNode(globalSelectedNode); ctxMenu.style.display = 'none'; };
        document.getElementById('ctx-cut').onclick = () => { if(globalSelectedNode) { clipboardData = serializeNode(globalSelectedNode); removeNode(globalSelectedNode); updateState(); pushHistory();} ctxMenu.style.display = 'none'; };
        document.getElementById('ctx-paste').onclick = () => {
            if(clipboardData) { let newNode = deserializeNode(clipboardData, ctxMousePos.x + (Math.random()*20-10), ctxMousePos.y + (Math.random()*20-10), false); globalSelectedNode = newNode; updateState(); pushHistory(); }
            ctxMenu.style.display = 'none';
        };

        // ==========================================
        // 7. GLSL生成エンジン
        // ==========================================
        function getInheritedModifier(node, subtype) {
            let path = []; let curr = node; while(curr) { path.unshift(curr); curr = curr.parent; }
            let result = null;
            path.forEach(n => {
                let actualN = n.type === 'BASE_REF' ? nodes.find(x => x.id === n.refId) : n;
                if(actualN && actualN.children) { let found = actualN.children.find(c => c.subtype === subtype); if(found) result = found; }
            });
            return result;
        }

        function updateShader() {
            let glsl = `
#define PI 3.14159265359
#define TWO_PI 6.28318530718

uniform float u_time;
uniform float u_aspect;
varying vec2 vUv;

vec3 hsv2rgb(vec3 c) {
    vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
    vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
    return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}

float sdPolygon(vec2 p, int n, float size) {
    float a = atan(p.x, p.y) + PI;
    float r = TWO_PI / float(n);
    return cos(floor(0.5 + a / r) * r - a) * length(p) - size;
}

vec2 hash2(float n) {
    float x = fract(sin(n) * 43758.5453123);
    float y = fract(sin(n + 1.0) * 43758.5453123);
    return vec2(x, y);
}

float noise3D(vec3 p) {
    vec3 i = floor(p);
    vec4 a = dot(i, vec3(1.0, 57.0, 21.0)) + vec4(0.0, 57.0, 21.0, 78.0);
    vec3 f = cos((p-i)*acos(-1.0))*(-0.5)+0.5;
    a = mix(sin(cos(a)*a),sin(cos(1.0+a)*(1.0+a)), f.x);
    a.xy = mix(a.xz, a.yw, f.y);
    return mix(a.x, a.y, f.z);
}

float flame3D(vec3 p, float time) {
    float d = length(p * vec3(1.0, 0.5, 1.0) - vec3(0.0, -1.0, 0.0)) - 1.0;
    return d + (noise3D(p + vec3(0.0, time * 2.0, 0.0)) + noise3D(p * 3.0) * 0.5) * 0.25 * (p.y);
}
`;
            let texNodes = getAllNodes(nodes).filter(n => n.subtype === 'IMAGE' || n.subtype === 'ANIMATION');
            texNodes.forEach((tn, i) => {
                glsl += `uniform sampler2D u_tex${i};\n`;
                if(tn.vals.img && !textures[tn.id]) loadTexture(tn.id, tn.vals.img);
                window.uniforms[`u_tex${i}`] = { value: textures[tn.id] || null };
            });

            function getAllBases(nList) { let res = []; nList.forEach(n => { if(n.type==='BASE' || n.type==='BASE_REF') {res.push(n);} if(n.children) res=res.concat(getAllBases(n.children)); }); return res; }
            let allBases = getAllBases(nodes.filter(n => !n.parent));
            
            allBases.forEach((base, idx) => {
                if(base.type === 'BASE_REF' && !nodes.find(x => x.id === base.refId)) return;
                
                glsl += `\nvec2 getPos_base${idx}(vec2 st, float t) {\n`;
                glsl += `    vec2 p = st;\n    p.x *= u_aspect;\n`;
                
                let path = []; let curr = base; while(curr) { path.unshift(curr); curr = curr.parent; }
                
                path.forEach((n, level) => {
                    let isRoot = (level === 0);
                    if (isRoot) {
                        let uvX = n.x / layoutW; let uvY = 1.0 - (n.y / layoutH);
                        glsl += `    p -= vec2(${uvX.toFixed(4)} * u_aspect, ${uvY.toFixed(4)});\n`;
                    } else {
                        let r = n.parent.radiusOpen; let cidx = n.parent.children.indexOf(n);
                        let angle = (cidx / n.parent.children.length) * Math.PI * 2;
                        let dx = Math.cos(angle) * (r * 0.65) / layoutW; let dy = -(Math.sin(angle) * (r * 0.65) / layoutH); 
                        glsl += `    p -= vec2(${dx.toFixed(4)} * u_aspect, ${dy.toFixed(4)});\n`;
                    }

                    let actualN = n.type === 'BASE_REF' ? nodes.find(x => x.id === n.refId) : n;
                    let motions = actualN && actualN.children ? actualN.children.filter(c => c.subtype && c.subtype.startsWith('MOTION_')) : [];
                    
                    let easing = motions.find(m => m.subtype === 'MOTION_EASING');
                    glsl += `    float t_local_${level} = t;\n`;
                    if(easing) {
                        let mode = easing.vals.mode;
                        if(mode === 1) glsl += `    t_local_${level} = pow(fract(t), 2.0) + floor(t);\n`;
                        else if(mode === 2) glsl += `    t_local_${level} = (1.0 - pow(1.0 - fract(t), 2.0)) + floor(t);\n`;
                        else glsl += `    t_local_${level} = smoothstep(0.0, 1.0, fract(t)) + floor(t);\n`;
                    }

                    let rotMotion = motions.find(m => m.subtype === 'MOTION_ROTATE');
                    if(rotMotion) {
                        glsl += `    {\n        float a = -(t_local_${level} * ${rotMotion.vals.speed.toFixed(2)});\n        float s = sin(a), c = cos(a);\n        p = mat2(c, -s, s, c) * p;\n    }\n`;
                    }

                    glsl += `    vec2 offset_${level} = vec2(0.0);\n`;
                    motions.forEach(m => {
                        let v = m.vals;
                        if(m.subtype === 'MOTION_LINEAR') glsl += `    offset_${level} += vec2(${v.vx.toFixed(2)}, ${-v.vy.toFixed(2)}) * t_local_${level};\n`;
                        else if(m.subtype === 'MOTION_SINE') { let rad = v.angle * Math.PI / 180; glsl += `    offset_${level} += vec2(${Math.cos(rad).toFixed(3)}, ${-Math.sin(rad).toFixed(3)}) * sin(t_local_${level} * ${v.freq.toFixed(2)}) * ${v.amp.toFixed(2)};\n`; }
                        else if(m.subtype === 'MOTION_BOUNCE') glsl += `    offset_${level}.y += abs(sin(t_local_${level} * ${v.speed.toFixed(2)})) * ${v.height.toFixed(2)};\n`;
                        else if(m.subtype === 'MOTION_PARABOLA') glsl += `    offset_${level} += vec2(${v.vx.toFixed(2)} * t_local_${level}, ${v.vy.toFixed(2)} * t_local_${level} - 0.5 * ${v.g.toFixed(2)} * t_local_${level} * t_local_${level});\n`;
                        else if(m.subtype === 'MOTION_SPIRAL') {
                            glsl += `    float r_sp_${level} = min(${v.radius.toFixed(2)} * t_local_${level}, ${v.limit.toFixed(2)});\n`;
                            glsl += `    offset_${level} += vec2(cos(t_local_${level} * ${v.spin.toFixed(2)}), -sin(t_local_${level} * ${v.spin.toFixed(2)})) * r_sp_${level};\n`;
                        }
                        else if(m.subtype === 'MOTION_HOMING') glsl += `    offset_${level} += mix(vec2(0.0), vec2(${v.tx.toFixed(2)}, ${-v.ty.toFixed(2)}), clamp(t_local_${level} * ${v.speed.toFixed(2)}, 0.0, 1.0));\n`;
                        else if(m.subtype === 'MOTION_SHAKE') glsl += `    offset_${level} += vec2(sin(t_local_${level} * ${v.speed.toFixed(2)} + 1.0)*0.5 + sin(t_local_${level} * ${v.speed.toFixed(2)} * 1.7)*0.5, cos(t_local_${level} * ${v.speed.toFixed(2)} * 1.3)*0.5 + cos(t_local_${level} * ${v.speed.toFixed(2)} * 0.8)*0.5) * ${v.amp.toFixed(3)};\n`;
                        else if(m.subtype === 'MOTION_FALL') glsl += `    offset_${level}.y -= 0.5 * ${v.g.toFixed(2)} * t_local_${level} * t_local_${level};\n`;
                    });
                    glsl += `    p -= vec2(offset_${level}.x * u_aspect, offset_${level}.y);\n`;
                    
                    let distort = actualN && actualN.children ? actualN.children.find(c => c.subtype === 'FILTER_DISTORT') : null;
                    if(distort) {
                        let dv = distort.vals;
                        glsl += `    p += vec2(sin(p.y * ${dv.freq.toFixed(2)} + t * ${dv.speed.toFixed(2)}), cos(p.x * ${dv.freq.toFixed(2)} + t * ${dv.speed.toFixed(2)})) * ${dv.power.toFixed(3)};\n`;
                    }
                });
                glsl += `    return p;\n}\n`;
            });

            glsl += `
void main() {
    vec3 finalColor = vec3(0.0);
    vec2 st = vUv;
`;
            allBases.forEach((base, idx) => {
                if(base.type === 'BASE_REF' && !nodes.find(x => x.id === base.refId)) return;
                glsl += `    // --- Base Object ${idx} ---\n    {\n`;
                
                let timeNode = getInheritedModifier(base, 'TIME');
                glsl += `        float t_base = u_time;\n        float alpha = 1.0;\n`;
                if(timeNode) {
                    let v = timeNode.vals;
                    glsl += `        t_base = max(0.0, u_time - ${v.delay.toFixed(2)});\n`;
                    glsl += `        t_base = mod(t_base, ${v.life.toFixed(2)} + 0.01);\n`;
                    if(v.fade > 0.5) glsl += `        alpha = clamp(1.0 - (t_base / ${v.life.toFixed(2)}), 0.0, 1.0);\n`;
                } else {
                    glsl += `        t_base = mod(u_time, 3.0);\n`;
                }

                let filterNode = getInheritedModifier(base, 'FILTER');
                let st_used = 'st';
                if(filterNode && filterNode.vals.pixel > 0) {
                    let pScale = 200.0 / filterNode.vals.pixel;
                    glsl += `        vec2 st_mod = floor(st * ${pScale.toFixed(2)}) / ${pScale.toFixed(2)};\n`;
                    st_used = 'st_mod';
                }

                let trailNode = getInheritedModifier(base, 'TRAIL');
                let trailCount = trailNode ? trailNode.vals.count : 1;
                let trailInterval = trailNode ? trailNode.vals.interval : 0.05;
                let trailFade = trailNode ? trailNode.vals.fade : 1.0;

                let customNode = getInheritedModifier(base, 'CUSTOM');
                let shape = getInheritedModifier(base, 'SHAPE');
                let imageNode = getInheritedModifier(base, 'IMAGE');
                let animNode = getInheritedModifier(base, 'ANIMATION');
                let targetNode = animNode || imageNode || shape;
                let n_gon = shape ? shape.vals.n : 0;
                let shape_size = targetNode ? targetNode.vals.size : 0.05;
                
                let particleNode = getInheritedModifier(base, 'PARTICLE');
                let hasTex = (animNode || imageNode) ? true : false;
                let texIdx = hasTex ? texNodes.indexOf(animNode || imageNode) : -1;
                
                let color = getInheritedModifier(base, 'COLOR');
                let colorCode = color ? `hsv2rgb(vec3(${color.vals.h.toFixed(3)}, ${color.vals.s.toFixed(2)}, ${color.vals.v.toFixed(2)}))` : `vec3(1.0)`;

                let fallNode = getInheritedModifier(base, 'MOTION_FALL');

                glsl += `        vec3 base_accum_col = vec3(0.0);\n`;
                glsl += `        for(int tr = 0; tr < 30; tr++) {\n`;
                glsl += `            if(tr >= ${Math.floor(trailCount)}) break;\n`;
                glsl += `            float t_tr = max(0.0, t_base - float(tr) * ${trailInterval.toFixed(3)});\n`;
                glsl += `            float currentFade = pow(${trailFade.toFixed(3)}, float(tr));\n`;
                
                glsl += `            vec2 p = getPos_base${idx}(${st_used}, t_tr);\n`;

                if(fallNode) {
                    glsl += `            {\n                float fa = t_tr * ${fallNode.vals.spin.toFixed(2)};\n                float fs = sin(fa), fc = cos(fa);\n                p = mat2(fc, -fs, fs, fc) * p;\n            }\n`;
                }

                if (customNode) {
                    glsl += `            vec3 base_color = ${colorCode};\n`;
                    if (particleNode) {
                        let pv = particleNode.vals; let p_spread = pv.spread.toFixed(3);
                        glsl += `            float out_d = 999.0;\n            vec3 out_col = vec3(0.0);\n`;
                        glsl += `            for(int i=0; i<30; i++) {\n                if(float(i) >= ${pv.count.toFixed(1)}) break;\n`;
                        glsl += `                vec2 poff = (hash2(float(i)) * 2.0 - 1.0) * (${p_spread} + t_tr * ${pv.speed.toFixed(2)});\n                poff.x *= u_aspect;\n`;
                        glsl += `                vec2 orig_p = p;\n                p = orig_p - poff;\n`;
                        glsl += `                float local_d = 999.0;\n                vec3 local_col = vec3(1.0);\n`;
                        glsl += `                // --- Custom GLSL ---\n`;
                        glsl += `                ${customNode.vals.code}\n`;
                        glsl += `                if(local_d < out_d) { out_d = local_d; out_col = local_col; }\n`;
                        glsl += `                p = orig_p;\n`;
                        glsl += `            }\n`;
                        glsl += `            float local_d = out_d;\n`;
                        glsl += `            vec3 local_col = out_col * base_color;\n`;
                        glsl += `            float local_mask = smoothstep(0.015, 0.0, local_d);\n`;
                    } else {
                        glsl += `            float local_d = 999.0;\n            vec3 local_col = vec3(1.0);\n`;
                        glsl += `            // --- Custom GLSL ---\n`;
                        glsl += `            ${customNode.vals.code}\n`;
                        glsl += `            local_col *= base_color;\n`;
                        glsl += `            float local_mask = smoothstep(0.015, 0.0, local_d);\n`;
                    }
                } else {
                    glsl += `            float local_d = 999.0;\n            vec4 localTexCol = vec4(0.0);\n`;
                    let animCode = "";
                    if(animNode) {
                        animCode = `float fIdx = floor(mod(t_tr / ${animNode.vals.speed.toFixed(3)}, ${animNode.vals.frames.toFixed(1)})); texUv.x = (texUv.x + fIdx) / ${animNode.vals.frames.toFixed(1)};`;
                    }

                    if(particleNode) {
                        let pv = particleNode.vals; let p_spread = pv.spread.toFixed(3);
                        glsl += `            for(int i=0; i<30; i++) {\n                if(float(i) >= ${pv.count.toFixed(1)}) break;\n`;
                        glsl += `                vec2 poff = (hash2(float(i)) * 2.0 - 1.0) * (${p_spread} + t_tr * ${pv.speed.toFixed(2)});\n                poff.x *= u_aspect;\n                vec2 pp = p - poff;\n`;
                        if (hasTex) {
                            glsl += `                vec2 absp = abs(pp);\n                float pd = max(absp.x, absp.y) - ${shape_size.toFixed(3)};\n                local_d = min(local_d, pd);\n`;
                            glsl += `                vec2 texUv = clamp((pp / ${shape_size.toFixed(3)}) * 0.5 + 0.5, 0.0, 1.0);\n                ${animCode}\n`; 
                            glsl += `                vec4 tCol = texture2D(u_tex${texIdx}, texUv);\n                localTexCol += tCol * smoothstep(0.015, 0.0, pd);\n`;
                        } else {
                            if(n_gon < 3) glsl += `                local_d = min(local_d, length(pp) - ${shape_size.toFixed(3)});\n`;
                            else glsl += `                local_d = min(local_d, sdPolygon(pp, ${n_gon}, ${shape_size.toFixed(3)}));\n`;
                        }
                        glsl += `            }\n`;
                        if(hasTex) glsl += `            localTexCol = clamp(localTexCol, 0.0, 1.0);\n`;
                    } else {
                        if (hasTex) {
                            glsl += `            vec2 absp = abs(p);\n            local_d = max(absp.x, absp.y) - ${shape_size.toFixed(3)};\n`;
                            glsl += `            vec2 texUv = clamp((p / ${shape_size.toFixed(3)}) * 0.5 + 0.5, 0.0, 1.0);\n            ${animCode}\n`; 
                            glsl += `            localTexCol = texture2D(u_tex${texIdx}, texUv);\n`;
                        } else {
                            if(n_gon < 3) glsl += `            local_d = length(p) - ${shape_size.toFixed(3)};\n`;
                            else glsl += `            local_d = sdPolygon(p, ${n_gon}, ${shape_size.toFixed(3)});\n`;
                        }
                    }

                    glsl += `            float local_mask = smoothstep(0.015, 0.0, local_d);\n`;
                    glsl += `            vec3 local_col = ${colorCode};\n`;
                    if (hasTex) { glsl += `            local_col *= localTexCol.rgb;\n            local_mask *= localTexCol.a;\n`; }
                }
                
                if(filterNode && filterNode.vals.glow > 0) {
                    glsl += `            local_mask += max(0.0, ${filterNode.vals.glow.toFixed(3)}) * (0.01 / (local_d*local_d + 0.001));\n`;
                }

                glsl += `            base_accum_col += local_col * local_mask * currentFade * alpha;\n`;
                glsl += `        }\n`; 
                glsl += `        finalColor += base_accum_col;\n    }\n`;
            });

            glsl += `\n    gl_FragColor = vec4(finalColor, 1.0);\n}`;
            glslContent.innerText = glsl.trim();

            if(mesh) {
                const newMat = new THREE.ShaderMaterial({
                    vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
                    fragmentShader: glsl, uniforms: window.uniforms, blending: THREE.AdditiveBlending, transparent: true
                });
                if(mesh.material) mesh.material.dispose(); mesh.material = newMat;
            }
        }

        // ==========================================
        // 8. キャンバス描画ループ (論理座標とズーム適用)
        // ==========================================
        function calcPos(node, cx, cy) {
            node.screenX = cx; node.screenY = cy;
            if((node.type === 'BASE' || node.type === 'BASE_REF') && node.isOpen && node.children) {
                let r = node.radiusOpen;
                node.children.forEach((child, i) => {
                    let angle = (i / node.children.length) * Math.PI * 2;
                    let childX = cx + Math.cos(angle) * (r * 0.65);
                    let childY = cy + Math.sin(angle) * (r * 0.65);
                    if(draggedNode === child) { childX = child.x; childY = child.y; }
                    calcPos(child, childX, childY);
                });
            } 
        }

        function animateCanvas() {
            ctx.clearRect(0, 0, canvas.width, canvas.height);
            
            ctx.save();
            ctx.scale(zoom, zoom);
            
            ctx.fillStyle = '#111118';
            ctx.fillRect(0, 0, layoutW, layoutH);
            
            ctx.strokeStyle = '#1a1a25';
            ctx.lineWidth = 1;
            ctx.beginPath();
            for(let x=0; x<=layoutW; x+=gridSz) { ctx.moveTo(x, 0); ctx.lineTo(x, layoutH); }
            for(let y=0; y<=layoutH; y+=gridSz) { ctx.moveTo(0, y); ctx.lineTo(layoutW, y); }
            ctx.stroke();

            ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
            
            nodes.filter(n => !n.parent).forEach(root => calcPos(root, root.x, root.y));
            let visibleNodes = getVisibleNodes(nodes.filter(n => !n.parent));

            ctx.strokeStyle = '#334455'; ctx.lineWidth = 2;
            visibleNodes.filter(n => (n.type === 'BASE' || n.type === 'BASE_REF') && n.parent).forEach(b => {
                ctx.beginPath(); ctx.moveTo(b.parent.screenX, b.parent.screenY); ctx.lineTo(b.screenX, b.screenY); ctx.stroke();
            });

            if(hoverTarget && hoverTarget.screenX !== undefined) {
                ctx.beginPath(); ctx.arc(hoverTarget.screenX, hoverTarget.screenY, hoverTarget.radiusOpen + 8, 0, Math.PI * 2);
                ctx.strokeStyle = `rgba(255, 200, 0, ${0.4 + 0.6 * Math.sin(Date.now() / 100)})`; ctx.lineWidth = 4; ctx.stroke();
            }

            visibleNodes.forEach(n => {
                let sx = n.screenX; let sy = n.screenY;
                if(n.type === 'BASE') {
                    let r = n.isOpen ? n.radiusOpen : n.radiusClosed;
                    ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2);
                    ctx.fillStyle = n.isOpen ? '#1a1a25' : n.color; ctx.fill();
                    ctx.strokeStyle = n.isOpen ? '#00ffcc' : '#555'; ctx.lineWidth = n.isOpen ? 2 : 1;
                    if(n.isOpen) ctx.setLineDash([5, 5]); else ctx.setLineDash([]);
                    ctx.stroke(); ctx.setLineDash([]);
                    if(!n.isOpen) { ctx.font = '14px sans-serif'; ctx.fillText(n.emoji, sx, sy + 1); }
                    else { ctx.beginPath(); ctx.arc(sx, sy, 4, 0, Math.PI*2); ctx.fillStyle='#00ffcc'; ctx.fill(); }
                } 
                else if(n.type === 'BASE_REF') {
                    let actual = nodes.find(x => x.id === n.refId);
                    let emoji = actual ? actual.emoji : '❓'; let color = actual ? actual.color : '#555';
                    let r = n.isOpen ? n.radiusOpen : n.radiusClosed;
                    ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2);
                    ctx.fillStyle = n.isOpen ? '#111' : '#222'; ctx.fill();
                    ctx.strokeStyle = color; ctx.lineWidth = n.isOpen ? 2 : 1;
                    if(n.isOpen) ctx.setLineDash([3, 5]); else ctx.setLineDash([2, 2]);
                    ctx.stroke(); ctx.setLineDash([]);
                    if(!n.isOpen) { ctx.font = '14px sans-serif'; ctx.fillText(emoji, sx, sy + 1); }
                    else { ctx.beginPath(); ctx.arc(sx, sy, 4, 0, Math.PI*2); ctx.fillStyle=color; ctx.fill(); }
                }
                else if(n.type === 'PARAM') {
                    ctx.beginPath(); ctx.arc(sx, sy, n.radius, 0, Math.PI * 2);
                    ctx.fillStyle = n.color; ctx.fill();
                    ctx.strokeStyle = '#222'; ctx.lineWidth = 2; ctx.stroke();
                    ctx.font = '14px sans-serif'; ctx.fillText(n.emoji, sx, sy + 1);
                }
            });

            if(globalSelectedNode && globalSelectedNode.screenX !== undefined && visibleNodes.includes(globalSelectedNode)) {
                let t = Date.now() / 150; let sx = globalSelectedNode.screenX; let sy = globalSelectedNode.screenY;
                let r = (globalSelectedNode.type === 'BASE' || globalSelectedNode.type === 'BASE_REF') ? (globalSelectedNode.isOpen ? globalSelectedNode.radiusOpen : globalSelectedNode.radiusClosed) : globalSelectedNode.radius;
                ctx.beginPath(); ctx.arc(sx, sy, r + 5 + Math.sin(t)*2, 0, Math.PI*2);
                ctx.strokeStyle = `rgba(0, 255, 204, ${0.4 + 0.4 * Math.sin(t)})`; ctx.lineWidth = 3; ctx.stroke();
            }
            
            ctx.restore(); 
            requestAnimationFrame(animateCanvas);
        }
        
        setTimeout(() => { resizeCanvas(); animateCanvas(); }, 100);

        // ==========================================
        // 9. アニメーションループ
        // ==========================================
        function animateThree() { 
            requestAnimationFrame(animateThree); 
            let delta = clock.getDelta();
            if(isPlaying) {
                currentTime += delta * tSpeed;
                if(currentTime >= tDuration) {
                    if(tLoop) currentTime = 0.0;
                    else { currentTime = tDuration; isPlaying = false; playBtn.innerText = '▶️'; }
                }
                timeSlider.value = currentTime;
                timeDisplay.innerText = currentTime.toFixed(2);
                window.uniforms.u_time.value = currentTime;
            }
            renderer.render(scene, camera); 
        }
        animateThree();
    </script>
</body>
</html>



・カスタムのサンプル。(炎)↓


vec3 org = vec3(0.0, -2.0, 4.0);
vec3 dir = normalize(vec3(p.x * 2.0, -p.y * 2.0, -1.5));

float d_ray = 0.0, glow = 0.0, eps = 0.02;
vec3 rp = org;
bool glowed = false;

for(int i = 0; i < 64; i++) {
    float f_dist = flame3D(rp, t_tr);
    d_ray = min(100.0 - length(rp), abs(f_dist)) + eps;
    rp += d_ray * dir;
    
    if( d_ray > eps ) {
        if(f_dist < 0.0) glowed = true;
        if(glowed) glow = float(i) / 64.0;
    }
}

vec3 col_base = mix(vec3(1.0, 0.5, 0.1), vec3(0.1, 0.5, 1.0), rp.y * 0.02 + 0.4);
vec3 final_rgb = mix(vec3(0.0), col_base, pow(glow * 2.0, 4.0));

local_col = final_rgb;
local_d = -1.0;

いいなと思ったら応援しよう!

ピックアップされています

便利なツール

  • 73本

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
GLSLシェーダーで遊ぶ「Glsl Viewer」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1