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



【更新履歴】

 ・2026/2/28 バージョン1.0公開。
 ・2026/2/28 バージョン1.1公開。
 ・2026/2/28 バージョン1.2公開。
 ・2026/2/28 バージョン1.3公開。
 ・2026/2/28 バージョン1.4公開。
 ・2026/2/28 バージョン1.5公開。
 ・2026/2/28 バージョン1.6公開。
 ・2026/2/28 バージョン1.7公開。

画像

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

<!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 - Stable & Hierarchy</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 { 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: 150px; 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; }
        #status-bar { margin-left: auto; color: #666; font-size: 11px; padding-right: 10px; }

        #main-area { display: flex; flex-grow: 1; height: calc(100vh - 30px); }
        
        /* 左側パネル:オブジェクトリスト */
        #left-panel { width: 160px; background: #111; border-right: 2px solid #333; display: flex; flex-direction: column; flex-shrink: 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;}
        #object-list { display: flex; flex-direction: column; gap: 6px; padding: 8px; overflow-y: auto; flex-grow: 1; }
        .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;}
        
        #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: 150px; 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); }
        
        #ui-canvas { flex-grow: 1; background: #111118; cursor: default; touch-action: none; outline: none; }
        
        #preview { width: 40%; position: relative; background: #000; flex-shrink: 0; min-width: 300px; display: flex; flex-direction: column;}
        
        /* 修正:コードパネルのスクロールと選択を可能にする */
        #log-panel { 
            position: absolute; bottom: 0; left: 0; width: 100%; height: 200px; 
            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; }

        #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; }
        .ctx-sep { height: 1px; background: #444; margin: 4px 0; }
        
        #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; }
    </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>
        </div>
        <div id="status-bar">新規プロジェクト</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;">↑ドラッグしてキャンバスへ配置(参照コピー)</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('COLOR')" title="色(HSV)">🎨</button>
                        <button onclick="addNode('SHAPE')" title="形(多角形)">📐</button>
                    </div>
                    <div class="btn-group"><span>特殊</span>
                        <button onclick="addNode('TIME')" 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>
                    </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>
            
            <canvas id="ui-canvas" tabindex="0"></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 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;">// 💡 ダブルクリックで展開/プロパティ表示します。<br>// 💡 ベースの中にベースをドロップして階層化できます。</div>
            </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';

        // ==========================================
        // 1. ノードとパラメータの定義
        // ==========================================
        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 }
            ]},
            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 }
            ]},
            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 }
            ]},
            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 }
            ]}
        };

        // ==========================================
        // 2. 堅牢なシリアライズ・デシリアライズ (修正済)
        // ==========================================
        let nodes = [];
        let globalSelectedNode = null; 

        // 必要なプロパティだけを明示的に抽出して保存する(親子関係崩れ防止)
        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 || width/2), 
                y: y !== undefined ? y : (data.y || height/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;
        }

        const fileOps = {
            currentHandle: null,
            new: () => { if(confirm("新規作成しますか?")) { clearNodes(); fileOps.currentHandle = null; document.getElementById('status-bar').innerText = "新規プロジェクト"; } },
            open: async () => {
                try {
                    const input = document.getElementById('file-loader');
                    input.onchange = async (e) => {
                        const file = e.target.files[0];
                        loadProjectData(JSON.parse(await file.text()));
                        document.getElementById('status-bar').innerText = file.name;
                        input.value = '';
                    };
                    input.click();
                } catch(e) { console.error(e); }
            },
            saveAs: async () => {
                const data = JSON.stringify(nodes.filter(n=>!n.parent).map(serializeNode), null, 2);
                const blob = new Blob([data], {type: "application/json"});
                const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = "effect_project.json"; a.click();
            },
            save: () => fileOps.saveAs()
        };
        window.fileOps = fileOps;

        function loadProjectData(dataArray) {
            clearNodes();
            dataArray.forEach(data => deserializeNode(data, data.x, data.y, true)); // keepId=trueで完全復元
            updateState();
        }

        window.copyGLSL = () => {
            const text = document.getElementById('glsl-content').innerText;
            navigator.clipboard.writeText(text).then(() => alert('GLSLをクリップボードにコピーしました!'));
        };

        // ==========================================
        // 3. テンプレート機能
        // ==========================================
        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 container = document.getElementById('template-container');
            container.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, width/2, height/2, false); globalSelectedNode = n; updateState(); };
                btn.oncontextmenu = (e) => { e.preventDefault(); if(confirm(`削除しますか?`)){ templates.splice(i, 1); localStorage.setItem('gemTemplates', JSON.stringify(templates)); renderTemplates(); } };
                container.appendChild(btn);
            });
        }
        renderTemplates();

        // ==========================================
        // 4. 左側リスト更新とドラッグ&ドロップ(BASE_REF)
        // ==========================================
        function updateObjectList() {
            const list = document.getElementById('object-list');
            list.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(); };
                list.appendChild(div);
            });
        }

        const canvas = document.getElementById('ui-canvas');
        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 n = {
                    id: Math.random().toString(36).substr(2, 9),
                    type: 'BASE_REF', refId: refId,
                    x: e.clientX - rect.left, y: e.clientY - rect.top,
                    parent: null, children: [], isOpen: false
                };
                nodes.push(n); globalSelectedNode = n; updateState();
            }
        });

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

        // ==========================================
        // 5. ツリー構造・UIロジック 
        // ==========================================
        const ctx = canvas.getContext('2d');
        const paramPanel = document.getElementById('param-panel');
        let width, height;
        let draggedNode = null;
        let isDragging = false;
        let dragStartPos = {x:0, y:0};
        let hoverTarget = null;
        let lastClickTime = 0; let lastClickNode = null;
        let clipboardData = null; let ctxMousePos = {x:0, y:0};

        function resize() {
            width = canvas.parentElement.clientWidth; height = canvas.parentElement.clientHeight;
            canvas.width = width; canvas.height = height;
            if(window.uniforms) window.uniforms.u_aspect.value = width / height;
            updateState();
        }
        window.addEventListener('resize', resize); 

        window.addNode = (defKey) => {
            const def = NODE_DEFS[defKey];
            const vals = {}; if(def.params) def.params.forEach(p => vals[p.key] = p.def);
            let n = {
                id: Math.random().toString(36).substr(2, 9),
                ...def, vals: vals,
                x: width/2 + (Math.random()-0.5)*50, y: height/2 + (Math.random()-0.5)*50,
                parent: null, children: [], isOpen: true 
            };
            nodes.push(n); globalSelectedNode = n; updateState();
        };
        window.clearNodes = () => { nodes = []; globalSelectedNode = null; closeParamPanel(); updateState(); };

        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 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';
            document.getElementById('param-title').innerText = `${node.emoji} の調整`;
            const content = document.getElementById('param-content'); content.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 = node.vals[p.key];
                labelDiv.appendChild(nameSpan); labelDiv.appendChild(valSpan);
                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(); 
                });
                row.appendChild(labelDiv); row.appendChild(input); content.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 + 'px'; paramPanel.style.top = (node.screenY + 30) + 'px'; }
        }

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

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

            if(draggedNode) { globalSelectedNode = draggedNode; updateObjectList(); } 
            else { globalSelectedNode = null; closeParamPanel(); updateObjectList(); }
        });

        canvas.addEventListener('pointermove', (e) => {
            if(!draggedNode) return;
            const pos = getPos(e); 
            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(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; 
                        }
                    }
                }
            }
            draggedNode = null; isDragging = false; hoverTarget = null;
            updateState(); updateParamPanelPos();
        });

        window.addEventListener('keydown', (e) => {
            if((e.key === 'Delete' || e.key === 'Backspace') && e.target.tagName !== 'INPUT') {
                if(globalSelectedNode) { removeNode(globalSelectedNode); globalSelectedNode = null; updateState(); }
            }
        });

        // ==========================================
        // 6. 右クリックコンテキストメニュー
        // ==========================================
        const ctxMenu = document.getElementById('ctx-menu');
        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(); };
        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(); } 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();
            }
            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);
}

void main() {
    vec3 finalColor = vec3(0.0);
    vec2 st = vUv;
`;
            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 += `\n    // --- Base Object ${idx} ---\n    {\n`;
                
                let timeNode = getInheritedModifier(base, 'TIME');
                glsl += `        float t = u_time;\n`;
                glsl += `        float alpha = 1.0;\n`;
                if(timeNode) {
                    let v = timeNode.vals;
                    glsl += `        t = max(0.0, u_time - ${v.delay.toFixed(2)});\n`;
                    glsl += `        t = mod(t, ${v.life.toFixed(2)} + 0.01);\n`;
                    if(v.fade > 0.5) glsl += `        alpha = clamp(1.0 - (t / ${v.life.toFixed(2)}), 0.0, 1.0);\n`;
                } else {
                    glsl += `        t = mod(u_time, 3.0);\n`;
                }

                let path = []; let curr = base;
                while(curr) { path.unshift(curr); curr = curr.parent; }
                
                glsl += `        vec2 p = st;\n`;
                glsl += `        p.x *= u_aspect;\n`;
                
                path.forEach((n, level) => {
                    let isRoot = (level === 0);
                    if (isRoot) {
                        let uvX = n.x / width; let uvY = 1.0 - (n.y / height);
                        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) / width;
                        let dy = -(Math.sin(angle) * (r * 0.65) / height); 
                        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 rotMotion = motions.find(m => m.subtype === 'MOTION_ROTATE');
                    if(rotMotion) {
                        glsl += `        {\n`;
                        glsl += `            float a = -(t * ${rotMotion.vals.speed.toFixed(2)});\n`;
                        glsl += `            float s = sin(a), c = cos(a);\n`;
                        glsl += `            p = mat2(c, -s, s, c) * p;\n`;
                        glsl += `        }\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;\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 * ${v.freq.toFixed(2)}) * ${v.amp.toFixed(2)};\n`;
                        }
                        else if(m.subtype === 'MOTION_BOUNCE') glsl += `        offset_${level}.y += abs(sin(t * ${v.speed.toFixed(2)})) * ${v.height.toFixed(2)};\n`;
                        else if(m.subtype === 'MOTION_PARABOLA') glsl += `        offset_${level} += vec2(${v.vx.toFixed(2)} * t, ${v.vy.toFixed(2)} * t - 0.5 * ${v.g.toFixed(2)} * t * t);\n`;
                        else if(m.subtype === 'MOTION_SPIRAL') glsl += `        offset_${level} += vec2(cos(t * ${v.spin.toFixed(2)}), -sin(t * ${v.spin.toFixed(2)})) * (${v.radius.toFixed(2)} * t);\n`;
                    });
                    glsl += `        p -= vec2(offset_${level}.x * u_aspect, offset_${level}.y);\n`;
                });

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

                let shape = getInheritedModifier(base, 'SHAPE');
                let n_gon = shape ? shape.vals.n : 0;
                let shape_size = shape ? shape.vals.size : 0.05;
                
                let particleNode = getInheritedModifier(base, 'PARTICLE');
                glsl += `        float d = 999.0;\n`;
                if(particleNode) {
                    let pv = particleNode.vals; let p_spread = pv.spread.toFixed(3);
                    glsl += `        for(int i=0; i<30; i++) {\n`;
                    glsl += `            if(float(i) >= ${pv.count.toFixed(1)}) break;\n`;
                    glsl += `            vec2 h = hash2(float(i)) * 2.0 - 1.0;\n`;
                    glsl += `            vec2 poff = h * (${p_spread} + t * ${pv.speed.toFixed(2)});\n`;
                    glsl += `            poff.x *= u_aspect;\n`;
                    glsl += `            vec2 pp = p - poff;\n`;
                    if(n_gon < 3) glsl += `            d = min(d, length(pp) - ${shape_size.toFixed(3)});\n`;
                    else glsl += `            d = min(d, sdPolygon(pp, ${n_gon}, ${shape_size.toFixed(3)}));\n`;
                    glsl += `        }\n`;
                } else {
                    if(n_gon < 3) glsl += `        d = length(p) - ${shape_size.toFixed(3)};\n`;
                    else glsl += `        d = sdPolygon(p, ${n_gon}, ${shape_size.toFixed(3)});\n`;
                }

                let color = getInheritedModifier(base, 'COLOR');
                if(color) {
                    glsl += `        vec3 col = hsv2rgb(vec3(${color.vals.h.toFixed(3)}, ${color.vals.s.toFixed(2)}, ${color.vals.v.toFixed(2)}));\n`;
                } else {
                    glsl += `        vec3 col = vec3(1.0);\n`;
                }

                glsl += `        float mask = smoothstep(0.015, 0.0, d);\n`;

                if(filterNode && filterNode.vals.glow > 0) {
                    let glow_val = filterNode.vals.glow.toFixed(3);
                    glsl += `        mask += max(0.0, ${glow_val}) * (0.01 / (d*d + 0.001));\n`;
                }

                glsl += `        finalColor += col * mask * alpha;\n    }\n`;
            });

            glsl += `\n    gl_FragColor = vec4(finalColor, 1.0);\n}`;
            document.getElementById('glsl-content').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, width, height);
            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();
            }
            requestAnimationFrame(animateCanvas);
        }
        animateCanvas();

        // ==========================================
        // 9. Three.js セットアップ
        // ==========================================
        const previewContainer = document.getElementById('preview');
        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 });
        
        window.uniforms = { u_time: { value: 0.0 }, u_aspect: { value: 1.0 } };
        renderer.setSize(previewContainer.clientWidth, previewContainer.clientHeight);
        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);
        
        resize();
        const clock = new THREE.Clock();
        function animateThree() { requestAnimationFrame(animateThree); window.uniforms.u_time.value = clock.getElapsedTime(); 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