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>

コメント