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



【更新履歴】

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

画像


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

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

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

<!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: 180px;
            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>
                    </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;">// 💡 バグを修正し、安定動作する究極版です!</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)
        // ==========================================
        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 }
            ]},
            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' ? '' : 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 {
                    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') 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(); }
        });

        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);
}
`;
            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 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`;
                }

                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>

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

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
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