GLSLシェーダーで遊ぶ「Glsl Viewer」
【更新履歴】
・2026/2/28 バージョン1.0公開。
(中略)
・2026/2/28 バージョン2.1公開。
・ダウンロードされる方はこちら。↓
https://drive.google.com/drive/folders/1I79w7SBhawE2Rq0m2po8RxdOkHeLu4aR?ths=true
《操作説明書》
・本ツールは、直感的な操作で、
複雑なゲーム用エフェクトや弾幕を
プログラミング不要で作成できるツールです。
ツールの概要
・本ツールは、器となる「ベース」に、
様々な効果を持った「パラメータ」を組み合わせていくことで、
リアルタイムにエフェクトを構築・確認できるエディターです。
・作成したエフェクトは、
ゲーム開発用のスプライトシート(連番画像)や
GLSLシェーダーコードとして出力できます。
画面の構成
(1) メニューバー(画面上部):
ファイルの保存・読み込み、エフェクトの画像出力、
操作の取り消し・やり直し、
レイアウトやタイムラインの設定を行います。
(2) 大元リスト(画面左側):
キャンバスに作成したベース(大元)が一覧表示されます。
ここからキャンバスへドラッグすると、
同じ動きをする「分身(インスタンス)」を複数配置できます。
(3) ツールバー(画面中央上):
新しいベースや、追加したいパラメータ(効果)を選択して
キャンバスに出現させます。
(4) キャンバス(画面中央):
ベースやパラメータを配置し、
つなぎ合わせてエフェクトを組み立てる作業エリアです。
(5) プレビュー(画面右側):
作成中のエフェクトがリアルタイムに表示されます。
(6) タイムライン(画面下部):
エフェクトの再生・一時停止、時間の確認や
シーク(巻き戻し・早送り)が行えます。
基本操作
(1) ベースの配置:
ツールバーの「🎇新規ベース」をクリックすると、
キャンバスにベースが出現します。
(2) 開閉操作:
キャンバス上のベースを「ダブルクリック」すると、
展開(円が大きくなる)と収納を切り替えることができます。
(3) パラメータの追加と設定:
ツールバーから「🎨色」や「➡️直線移動」などのパラメータを出し、
展開しているベースの円の中にドラッグ&ドロップすると、
カチッとはまり込んで効果が適用されます。
(4) パラメータの調整:
はめ込んだパラメータ、または
独立しているパラメータを「ダブルクリック」すると、
詳細な数値を調整するスライダーが表示されます。
スライダーを動かすとプレビューに即座に反映されます。
(5) ベースの階層化(親子関係):
展開しているベースの中に、
別のベースをドラッグ&ドロップで入れることができます。
中に入った「子」のベースは、
「親」の動きや色、形をそのまま受け継ぎ、
さらに自分自身の動きを重ね合わせることができます。
(例:親が回転し、子がその周囲を回りながら直進する、など)
(6) コピーと削除:
オブジェクトを右クリックするとメニューが開き、
コピーや削除ができます。
DeleteキーやBackspaceキーでの削除も可能です。
パラメータの種類
【見た目】
(1) 📐形:
円や多角形を作り、大きさを決めます。
(2) 🎨色:
色相・彩度・明度を調整して色をつけます。
(3) 🖼️画像:
PC内の画像ファイルを読み込んで表示します。
(4) 🎞️アニメ:
横に繋がったスプライトシート画像を読み込み、
パラパラアニメとして再生します。
【特殊】
(1) ⏳時間制御:
発生を遅らせたり、
一定時間で消滅(フェードアウト)させたり、
ループする寿命を決めます。
(2) 💫残像:
動いた軌跡に光の帯や残像を残します。
(3) 🌊空間の歪み:
背景を波打つように歪ませ、
衝撃波や陽炎を表現します。
(4) 🌟フィルター:
ドット絵風にモザイク化したり、
発光(グロー)させたりします。
(5) ✨パーティクル:
指定した形や画像を、火花のように複数に分裂させて
弾け飛ばします。
【動き】
(1) ➡️直線移動:
上下左右に移動し続けます。
(2) ↔️反復移動:
指定した角度に向かって、行ったり来たりします。
(3) 🪀バウンド:
地面で跳ねるような動きをします。
(4) ⤴️放物線:
重力に従って放物線を描いて飛びます。
(5) 🌀渦巻き:
回転しながら徐々に広がっていきます。
(6) 🔄空間回転:
空間そのものを回します。
(親に付けると、子が衛星のように公転します)
(7) 🎯ターゲット追尾:
指定した座標に向かって徐々に曲がりながら進みます。
(8) 🧲イージング:
動きの始まりや終わりを滑らかに加減速させます。
(9) 🫨揺らぎ:
炎の揺れのような、ランダムで不規則な動きを加えます。
(10) 🪨落下と弾み:
重力で落下しながら回転します。
便利機能
(1) テンプレート登録:
お気に入りのエフェクトができた際、
ベースを選択してツールバーの「➕登録」を押すと、
ボタンとして保存されます。
次回以降、ワンクリックで同じものを呼び出せます。
(2) キャンバスの移動とズーム:
キャンバスの何もない場所をドラッグすると、
画面をスクロールできます。
キーボードの「PageUp」「PageDown」キーで
ズームイン・ズームアウトが可能です。
(3) グリッドスナップ:
画面右上の「📏グリッドスナップ」にチェックを入れると、
マス目に沿って綺麗にオブジェクトを配置できます。
(4) スプライトシート出力:
メニューバーの「ファイル」から
「🎬スプライトシート出力」を選ぶと、
設定した秒数とコマ数でエフェクトを自動録画し、
ゲーム開発ですぐに使える1枚の連番画像(PNG)として
保存します。
・ソースコードはこちら。↓
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Effect Gem Crafter - Perfect Creator</title>
<style>
body { margin: 0; display: flex; flex-direction: column; height: 100vh; background: #0a0a10; color: #fff; font-family: 'Segoe UI', sans-serif; overflow: hidden; user-select: none; -webkit-user-select: none;}
#menubar { flex-shrink: 0; height: 30px; background: #0f0f15; border-bottom: 1px solid #333; display: flex; align-items: center; padding: 0 10px; font-size: 13px; z-index: 1000; }
.menu-item { position: relative; padding: 0 15px; cursor: pointer; height: 100%; display: flex; align-items: center; color: #ccc; }
.menu-item:hover { background: #222; color: #00ffcc; }
.submenu { position: absolute; top: 30px; left: 0; background: #1a1a20; border: 1px solid #444; display: none; flex-direction: column; min-width: 180px; z-index: 1000; box-shadow: 0 5px 15px rgba(0,0,0,0.8); }
.menu-item:hover .submenu { display: flex; }
.sub-item { padding: 10px 15px; color: #ccc; cursor: pointer; border-bottom: 1px solid #222;}
.sub-item:hover { background: #00ffcc; color: #000; font-weight: bold; }
.ctx-sep { height: 1px; background: #444; margin: 4px 0; }
#status-bar { margin-left: auto; color: #666; font-size: 11px; padding-right: 10px; display: flex; align-items: center; gap: 10px;}
#main-area { display: flex; flex-grow: 1; flex-shrink: 1; min-height: 0; overflow: hidden; }
#left-panel { width: 160px; background: #111; border-right: 2px solid #333; display: flex; flex-direction: column; flex-shrink: 0; min-height: 0; z-index: 20;}
.panel-header { padding: 8px; font-weight: bold; background: #222; border-bottom: 1px solid #444; font-size: 11px; text-align: center; color: #aaa; letter-spacing: 1px; flex-shrink: 0;}
#object-list { display: flex; flex-direction: column; gap: 6px; padding: 8px; overflow-y: auto; flex-grow: 1; min-height: 0; }
.obj-list-item { padding: 8px 10px; background: #1a1a22; border: 1px solid #445; border-radius: 4px; cursor: grab; display: flex; align-items: center; gap: 8px; font-size: 12px; transition: 0.1s;}
.obj-list-item:hover { background: #2a2a33; border-color: #00ffcc; }
.obj-list-item:active { cursor: grabbing; }
#editor { flex-grow: 1; display: flex; flex-direction: column; border-right: 4px solid #222; position: relative; min-width: 0; min-height: 0;}
#toolbar { padding: 8px; background: #151520; display: flex; flex-direction: column; gap: 6px; z-index: 10; box-shadow: 0 4px 10px rgba(0,0,0,0.8); overflow-y: auto; max-height: 180px; flex-shrink: 0;}
.toolbar-row { display: flex; gap: 6px; align-items: center; flex-wrap: wrap; }
.btn-group { display: flex; gap: 4px; border: 1px solid #333; padding: 4px; border-radius: 6px; background: #111; align-items: center; white-space: nowrap;}
.btn-group span { font-size: 10px; font-weight: bold; color: #888; writing-mode: vertical-lr; text-align: center; margin-right: 2px;}
button { font-size: 18px; cursor: pointer; background: #222; border: 1px solid #444; border-radius: 6px; transition: 0.1s; padding: 4px 6px; color: #fff; display: flex; align-items: center; gap: 4px;}
button:hover { background: #333; transform: scale(1.05); }
button:active { transform: scale(0.95); }
#canvas-container { flex-grow: 1; flex-shrink: 1; min-height: 0; overflow: auto; position: relative; background: #050508; outline: none; }
#ui-canvas { display: block; transform-origin: top left; }
#preview { width: 40%; position: relative; background: #000; flex-shrink: 0; min-width: 300px; display: flex; flex-direction: column; min-height: 0; overflow: hidden;}
#log-panel {
position: absolute; bottom: 0; left: 0; width: 100%; height: 200px; max-height: 40%;
background: rgba(0,0,0,0.85); color: #00ffcc; font-family: 'Courier New', monospace;
font-size: 11px; padding: 10px; padding-top: 35px; box-sizing: border-box;
overflow-y: auto; pointer-events: auto; user-select: text; -webkit-user-select: text;
border-top: 1px solid #333; z-index: 100;
}
#param-panel {
position: absolute; display: none; background: rgba(15, 15, 20, 0.95);
border: 1px solid #555; border-radius: 8px; padding: 12px;
color: white; z-index: 100; pointer-events: auto;
box-shadow: 0 10px 30px rgba(0,0,0,0.8); width: 220px;
transform: translate(-50%, 20px);
}
#param-title { font-weight: bold; margin-bottom: 8px; border-bottom: 1px solid #444; padding-bottom: 4px; text-align: center; font-size: 12px; color: #aaa;}
.param-row { display: flex; flex-direction: column; margin-bottom: 6px; }
.param-row label { font-size: 10px; color: #ccc; margin-bottom: 2px; display: flex; justify-content: space-between;}
.param-row input[type="range"] { width: 100%; cursor: pointer; margin: 0; accent-color: #00ffcc;}
.param-row input[type="file"] { width: 100%; color: #fff; font-size: 10px; }
#timeline-bar { flex-shrink: 0; height: 30px; background: #151520; border-top: 1px solid #333; display: flex; align-items: center; padding: 0 15px; gap: 15px;}
#timeline-bar button { font-size: 14px; padding: 2px 8px; background: #00ffcc; color: #000; border: none; font-weight: bold;}
#timeline-bar input[type="range"] { flex-grow: 1; cursor: pointer; accent-color: #00ffcc;}
#time-display { font-family: monospace; font-size: 14px; color: #00ffcc; width: 40px; text-align: right;}
#ctx-menu { position: absolute; display: none; background: rgba(25, 25, 30, 0.95); border: 1px solid #555; border-radius: 6px; padding: 4px 0; color: white; z-index: 200; box-shadow: 0 5px 15px rgba(0,0,0,0.8); min-width: 120px; font-size: 12px; }
.ctx-item { padding: 8px 16px; cursor: pointer; transition: background 0.1s; }
.ctx-item:hover { background: #00ffcc; color: #000; font-weight: bold; }
#template-container { display: flex; gap: 4px; padding-left: 4px; flex-wrap: wrap;}
.tpl-btn { font-size: 12px; padding: 4px 10px; background: #1a2a3a; border-color: #00ffcc; color: #00ffcc; }
.tpl-btn:hover { background: #00ffcc; color: #000; }
.dialog-overlay { display: none; position: fixed; top: 0; left: 0; width: 100%; height: 100%; background: rgba(0,0,0,0.6); z-index: 3000; justify-content: center; align-items: center; }
.dialog-box { background: #1a1a20; border: 1px solid #00ffcc; padding: 20px; border-radius: 8px; min-width: 300px; box-shadow: 0 10px 30px rgba(0,0,0,0.8); }
.dialog-box h3 { margin-top: 0; color: #00ffcc; font-size: 16px; border-bottom: 1px solid #333; padding-bottom: 10px; }
.dialog-row { margin-bottom: 10px; display: flex; justify-content: space-between; align-items: center; font-size: 13px; }
.dialog-row input[type="number"] { width: 80px; background: #000; color: #fff; border: 1px solid #555; padding: 4px; text-align: right; }
.dialog-btns { display: flex; justify-content: flex-end; gap: 10px; margin-top: 20px; }
.dialog-btns button { font-size: 14px; padding: 6px 16px; cursor: pointer;}
</style>
</head>
<body>
<div id="menubar">
<div class="menu-item">ファイル
<div class="submenu">
<div class="sub-item" onclick="fileOps.new()">📄 新規作成</div>
<div class="sub-item" onclick="fileOps.open()">📂 開く...</div>
<div class="sub-item" onclick="fileOps.save()">💾 上書き保存</div>
<div class="sub-item" onclick="fileOps.saveAs()">💾 別名で保存...</div>
<div class="ctx-sep"></div>
<div class="sub-item" onclick="fileOps.exportGLSL()">📜 GLSL出力</div>
<div class="sub-item" onclick="fileOps.exportSpriteSheet()" style="color:#00ffcc; font-weight:bold;">🎬 スプライトシート出力...</div>
</div>
</div>
<div class="menu-item">編集
<div class="submenu">
<div class="sub-item" onclick="undo()">⏪ 元に戻す (Undo)</div>
<div class="sub-item" onclick="redo()">⏩ やり直し (Redo)</div>
</div>
</div>
<div class="menu-item">設定
<div class="submenu">
<div class="sub-item" onclick="openDialog('dlg-layout')">📏 レイアウト...</div>
<div class="sub-item" onclick="openDialog('dlg-timeline')">⏱️ タイムライン...</div>
</div>
</div>
<div id="status-bar">
<label style="cursor:pointer; display:flex; align-items:center; gap:4px; color:#aaa;"><input type="checkbox" id="snap-toggle">📏グリッドスナップ</label>
<span style="color:#00ffcc;">🔍ズーム: <span id="zoom-val">100%</span></span>
<span id="proj-name" style="margin-left: 10px;">新規プロジェクト</span>
</div>
</div>
<div id="main-area">
<div id="left-panel">
<div class="panel-header">OBJECTS (大元)</div>
<div id="object-list"></div>
<div style="font-size:10px; color:#666; padding:10px; text-align:center; flex-shrink: 0;">↑ドラッグしてキャンバスへ配置</div>
</div>
<div id="editor">
<div id="toolbar">
<div class="toolbar-row">
<div class="btn-group"><span>ベース</span>
<button onclick="addNode('BASE')" title="大元の魔法陣を新規作成">🎇新規ベース</button>
</div>
<div class="btn-group"><span>見た目</span>
<button onclick="addNode('SHAPE')" title="形(多角形)">📐</button>
<button onclick="addNode('COLOR')" title="色(HSV)">🎨</button>
<button onclick="addNode('IMAGE')" title="画像(テクスチャ)読み込み">🖼️</button>
<button onclick="addNode('ANIMATION')" title="アニメ(スプライト)読み込み">🎞️</button>
<button onclick="addNode('CUSTOM')" title="生GLSLコード入力" style="background:#055;">💻カスタム</button>
</div>
<div class="btn-group"><span>特殊</span>
<button onclick="addNode('TIME')" title="時間制御(寿命/フェード)">⏳</button>
<button onclick="addNode('TRAIL')" title="残像・軌跡">💫</button>
<button onclick="addNode('FILTER_DISTORT')" title="空間の歪み(衝撃波)">🌊</button>
<button onclick="addNode('FILTER')" title="フィルター(発光/モザイク)">🌟</button>
<button onclick="addNode('PARTICLE')" title="パーティクル(爆発/破片)">✨</button>
</div>
</div>
<div class="toolbar-row">
<div class="btn-group"><span>動き</span>
<button onclick="addNode('M_LINEAR')" title="直線移動">➡️</button>
<button onclick="addNode('M_SINE')" title="反復移動">↔️</button>
<button onclick="addNode('M_BOUNCE')" title="バウンド">🪀</button>
<button onclick="addNode('M_PARABOLA')" title="放物線">⤴️</button>
<button onclick="addNode('M_SPIRAL')" title="渦巻き">🌀</button>
<button onclick="addNode('M_ROTATE')" title="空間回転(公転)">🔄</button>
<button onclick="addNode('M_HOMING')" title="ターゲット追尾">🎯</button>
<button onclick="addNode('M_EASING')" title="イージング(加減速)">🧲</button>
<button onclick="addNode('M_SHAKE')" title="揺らぎ(炎など)">🫨</button>
<button onclick="addNode('M_FALL')" title="落下と弾み(岩など)">🪨</button>
</div>
<button onclick="clearNodes()" style="margin-left:auto; font-size:12px;">🗑️ 全クリア</button>
</div>
<div class="toolbar-row" style="border-top: 1px solid #333; padding-top: 4px;">
<div class="btn-group"><span style="color:#00ffcc;">Template</span>
<button onclick="saveTemplate()" title="選択中のオブジェクトをテンプレートとして保存" style="font-size:12px; background:#005544;">➕ 登録</button>
</div>
<div id="template-container"></div>
</div>
</div>
<div id="canvas-container">
<canvas id="ui-canvas"></canvas>
<div id="param-panel"><div id="param-title">調整</div><div id="param-content"></div></div>
<div id="ctx-menu">
<div class="ctx-item" id="ctx-cut">✂️ カット</div><div class="ctx-item" id="ctx-copy">📄 コピー</div><div class="ctx-item" id="ctx-paste">📋 ペースト</div>
<div class="ctx-sep" id="ctx-sep-1"></div><div class="ctx-item" id="ctx-delete" style="color:#ff6666;">🗑️ 削除</div>
</div>
</div>
</div>
<div id="preview">
<div id="log-panel" onwheel="event.stopPropagation()">
<button onclick="copyGLSL()" style="position: absolute; top: 5px; right: 10px; font-size: 12px; background: #223; border: 1px solid #00ffcc; color: #00ffcc; padding: 4px 8px; border-radius: 4px; cursor: pointer;">📋 コードをコピー</button>
<div id="glsl-content" style="white-space: pre-wrap; word-wrap: break-word;">// 💻「カスタムGLSL」宝石が追加されました!</div>
</div>
</div>
</div>
<div id="timeline-bar">
<button id="play-btn" onclick="togglePlay()">⏸️</button>
<input type="range" id="time-slider" min="0" max="10" step="0.01" value="0">
<span id="time-display">0.00</span>
</div>
<div id="dlg-layout" class="dialog-overlay">
<div class="dialog-box">
<h3>レイアウト設定</h3>
<div class="dialog-row"><label>幅 (px):</label> <input type="number" id="inp-ly-w" value="2000"></div>
<div class="dialog-row"><label>高さ (px):</label> <input type="number" id="inp-ly-h" value="2000"></div>
<div class="dialog-row"><label>グリッドサイズ:</label> <input type="number" id="inp-ly-grid" value="50"></div>
<hr style="border-color:#444;">
<div class="dialog-row"><label>上下左右に拡張(+px):</label> <input type="number" id="inp-ly-add" value="0"></div>
<div style="text-align:right;"><button onclick="addLayoutSize()" style="background:#005544; color:#fff; border:none; padding:4px 8px; border-radius:4px;">↑拡張を実行</button></div>
<div class="dialog-btns">
<button onclick="applyLayoutSettings()" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">OK</button>
<button onclick="closeDialog('dlg-layout')" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
</div>
</div>
</div>
<div id="dlg-timeline" class="dialog-overlay">
<div class="dialog-box">
<h3>タイムライン設定</h3>
<div class="dialog-row"><label>長さ (秒):</label> <input type="number" id="inp-tl-len" step="0.1" value="10"></div>
<div class="dialog-row"><label>再生速度 (倍):</label> <input type="number" id="inp-tl-spd" step="0.1" value="1.0"></div>
<div class="dialog-row"><label>ループ再生:</label> <input type="checkbox" id="inp-tl-loop" checked style="width:auto;"></div>
<div class="dialog-btns">
<button onclick="applyTimelineSettings()" style="background:#00ffcc; color:#000; border:none; border-radius:4px;">OK</button>
<button onclick="closeDialog('dlg-timeline')" style="background:#444; color:#fff; border:none; border-radius:4px;">キャンセル</button>
</div>
</div>
</div>
<input type="file" id="file-loader" accept=".json" style="display:none">
<script type="importmap">
{ "imports": { "three": "https://unpkg.com/three@0.160.0/build/three.module.js" } }
</script>
<script type="module">
import * as THREE from 'three';
// ==========================================
// 0. DOM参照・グローバル変数の初期化
// ==========================================
const previewContainer = document.getElementById('preview');
const canvas = document.getElementById('ui-canvas');
const container = document.getElementById('canvas-container');
const ctx = canvas.getContext('2d');
const paramPanel = document.getElementById('param-panel');
const paramContent = document.getElementById('param-content');
const paramTitle = document.getElementById('param-title');
const ctxMenu = document.getElementById('ctx-menu');
const timeSlider = document.getElementById('time-slider');
const timeDisplay = document.getElementById('time-display');
const playBtn = document.getElementById('play-btn');
const objList = document.getElementById('object-list');
const glslContent = document.getElementById('glsl-content');
const zoomValDisplay = document.getElementById('zoom-val');
let nodes = [];
let globalSelectedNode = null;
let history = []; let historyIndex = -1; let isRestoring = false;
let layoutW = 2000; let layoutH = 2000; let gridSz = 50; let zoom = 1.0;
let tDuration = 10.0; let tSpeed = 1.0; let tLoop = true;
let isPlaying = true; let currentTime = 0.0;
let draggedNode = null; let isDragging = false; let dragStartPos = {x:0, y:0}; let hoverTarget = null;
let isPanning = false; let panStart = {x:0, y:0}; let scrollStart = {x:0, y:0};
let lastClickTime = 0; let lastClickNode = null;
let clipboardData = null; let ctxMousePos = {x:0, y:0};
// ==========================================
// 1. Three.js エンジンの初期化
// ==========================================
const scene = new THREE.Scene();
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, 0.1, 10); camera.position.z = 1;
const renderer = new THREE.WebGLRenderer({ antialias: true, preserveDrawingBuffer: true });
window.uniforms = { u_time: { value: 0.0 }, u_aspect: { value: 1.0 } };
let pw = previewContainer.clientWidth || 300; let ph = previewContainer.clientHeight || 300;
renderer.setSize(pw, ph); window.uniforms.u_aspect.value = pw / ph;
previewContainer.appendChild(renderer.domElement);
const geometry = new THREE.PlaneGeometry(2, 2);
let mesh = new THREE.Mesh(geometry, new THREE.MeshBasicMaterial({color: 0x000000}));
scene.add(mesh);
const clock = new THREE.Clock();
const resizeObserver = new ResizeObserver(() => {
const w = previewContainer.clientWidth; const h = previewContainer.clientHeight;
if(w > 0 && h > 0) { renderer.setSize(w, h); if(window.uniforms) window.uniforms.u_aspect.value = w / h; }
});
resizeObserver.observe(previewContainer);
const textureLoader = new THREE.TextureLoader(); const textures = {};
function loadTexture(id, dataUrl) {
if(!dataUrl) return;
textureLoader.load(dataUrl, tex => { tex.minFilter = THREE.NearestFilter; tex.magFilter = THREE.NearestFilter; textures[id] = tex; updateShader(); });
}
// ==========================================
// 2. ノードの定義 (NODE_DEFS) - CUSTOM宝石追加
// ==========================================
const NODE_DEFS = {
BASE: { type: 'BASE', emoji: '🎇', color: '#444455', radiusClosed: 16, radiusOpen: 60 },
// 見た目
COLOR: { type: 'PARAM', subtype: 'COLOR', emoji: '🎨', color: '#ff55aa', radius: 14, params: [
{ key: 'h', label: '色相 (Hue)', min: 0, max: 1, def: 0.1, step: 0.01 },
{ key: 's', label: '彩度 (Sat)', min: 0, max: 1, def: 0.8, step: 0.01 },
{ key: 'v', label: '明度 (Val)', min: 0, max: 2, def: 1.5, step: 0.01 }
]},
SHAPE: { type: 'PARAM', subtype: 'SHAPE', emoji: '📐', color: '#55aaff', radius: 14, params: [
{ key: 'n', label: '何角形? (0=円)', min: 0, max: 8, def: 4, step: 1 },
{ key: 'size', label: '大きさ', min: 0.01, max: 0.5, def: 0.1, step: 0.01 }
]},
IMAGE: { type: 'PARAM', subtype: 'IMAGE', emoji: '🖼️', color: '#ffaaaa', radius: 14, params: [
{ key: 'img', label: '画像', type: 'image', def: '' },
{ key: 'size', label: '大きさ', min: 0.01, max: 1.0, def: 0.2, step: 0.01 }
]},
ANIMATION: { type: 'PARAM', subtype: 'ANIMATION', emoji: '🎞️', color: '#ff88aa', radius: 14, params: [
{ key: 'img', label: '横並び画像', type: 'image', def: '' },
{ key: 'frames', label: 'フレーム総数', min: 1, max: 64, def: 4, step: 1 },
{ key: 'speed', label: '1Fの表示(秒)', min: 0.01, max: 1.0, def: 0.1, step: 0.01 },
{ key: 'size', label: '大きさ', min: 0.01, max: 1.0, def: 0.2, step: 0.01 }
]},
CUSTOM: { type: 'PARAM', subtype: 'CUSTOM', emoji: '💻', color: '#00ffaa', radius: 14, params: [
{ key: 'code', label: 'GLSL(変数:p, t_tr を使用)', type: 'textarea', def: '// 円を波打たせるサンプル\nlocal_d = length(p) - 0.2 + sin(p.y*20.0 + t_tr*10.0)*0.05;\nlocal_col = vec3(0.0, 1.0, 0.5);' }
]},
// 動き
M_LINEAR: { type: 'PARAM', subtype: 'MOTION_LINEAR', emoji: '➡️', color: '#aaff55', radius: 14, params: [
{ key: 'vx', label: 'X移動 (右/左)', min: -5, max: 5, def: 0.5, step: 0.01 },
{ key: 'vy', label: 'Y移動 (上/下)', min: -5, max: 5, def: 0.0, step: 0.01 }
]},
M_SINE: { type: 'PARAM', subtype: 'MOTION_SINE', emoji: '↔️', color: '#55ffaa', radius: 14, params: [
{ key: 'amp', label: '振幅', min: 0, max: 2, def: 0.5, step: 0.01 },
{ key: 'angle', label: '角度(斜め)', min: 0, max: 360, def: 0, step: 1 },
{ key: 'freq', label: '速さ', min: 0, max: 10, def: 2.0, step: 0.1 }
]},
M_BOUNCE: { type: 'PARAM', subtype: 'MOTION_BOUNCE', emoji: '🪀', color: '#ddff55', radius: 14, params: [
{ key: 'height', label: '跳ね高さ', min: 0, max: 1, def: 0.3, step: 0.01 },
{ key: 'speed', label: '速さ', min: 0, max: 10, def: 4.0, step: 0.1 }
]},
M_PARABOLA: { type: 'PARAM', subtype: 'MOTION_PARABOLA', emoji: '⤴️', color: '#aaddff', radius: 14, params: [
{ key: 'vx', label: 'X速度', min: -2, max: 2, def: 0.5, step: 0.01 },
{ key: 'vy', label: '初期Yジャンプ', min: 0, max: 3, def: 1.5, step: 0.01 },
{ key: 'g', label: '重力', min: 0, max: 5, def: 3.0, step: 0.1 }
]},
M_SPIRAL: { type: 'PARAM', subtype: 'MOTION_SPIRAL', emoji: '🌀', color: '#55ffdd', radius: 14, params: [
{ key: 'radius', label: '広がる速さ', min: 0, max: 1, def: 0.3, step: 0.01 },
{ key: 'spin', label: '回転速度', min: -10, max: 10, def: 5.0, step: 0.1 },
{ key: 'limit', label: '最大範囲', min: 0.1, max: 5.0, def: 2.0, step: 0.1 }
]},
M_ROTATE: { type: 'PARAM', subtype: 'MOTION_ROTATE', emoji: '🔄', color: '#cc88ff', radius: 14, params: [
{ key: 'speed', label: '公転/自転速度', min: -10, max: 10, def: 2.0, step: 0.1 }
]},
M_EASING: { type: 'PARAM', subtype: 'MOTION_EASING', emoji: '🧲', color: '#55aa55', radius: 14, params: [
{ key: 'mode', label: '1:In 2:Out 3:Smooth', min: 1, max: 3, def: 3, step: 1 }
]},
M_HOMING: { type: 'PARAM', subtype: 'MOTION_HOMING', emoji: '🎯', color: '#aa5555', radius: 14, params: [
{ key: 'tx', label: 'ターゲット X', min: -5, max: 5, def: 0.0, step: 0.01 },
{ key: 'ty', label: 'ターゲット Y', min: -5, max: 5, def: 0.0, step: 0.01 },
{ key: 'speed', label: '追尾の強さ', min: 0, max: 5, def: 1.0, step: 0.01 }
]},
M_SHAKE: { type: 'PARAM', subtype: 'MOTION_SHAKE', emoji: '🫨', color: '#dd8855', radius: 14, params: [
{ key: 'amp', label: '揺れの強さ', min: 0, max: 1, def: 0.1, step: 0.01 },
{ key: 'speed', label: '速さ', min: 0, max: 50, def: 15.0, step: 0.1 }
]},
M_FALL: { type: 'PARAM', subtype: 'MOTION_FALL', emoji: '🪨', color: '#888888', radius: 14, params: [
{ key: 'g', label: '落下速度', min: 0, max: 5, def: 2.0, step: 0.1 },
{ key: 'spin', label: '回転', min: -5, max: 5, def: 1.0, step: 0.1 }
]},
// 特殊
TIME: { type: 'PARAM', subtype: 'TIME', emoji: '⏳', color: '#ffaa00', radius: 14, params: [
{ key: 'delay', label: '発生遅延(秒)', min: 0, max: 5, def: 0.0, step: 0.1 },
{ key: 'life', label: '寿命(ループ秒数)', min: 0.1, max: 10, def: 3.0, step: 0.1 },
{ key: 'fade', label: '時間で消滅(0=無 1=有)', min: 0, max: 1, def: 1.0, step: 1 }
]},
FILTER: { type: 'PARAM', subtype: 'FILTER', emoji: '🌟', color: '#ddddff', radius: 14, params: [
{ key: 'pixel', label: 'ドット化(粗さ)', min: 0, max: 50, def: 0, step: 1 },
{ key: 'glow', label: '発光(Glow)強度', min: 0, max: 2, def: 0.0, step: 0.01 }
]},
PARTICLE: { type: 'PARAM', subtype: 'PARTICLE', emoji: '✨', color: '#ffcc55', radius: 14, params: [
{ key: 'count', label: '破片の数', min: 1, max: 30, def: 10, step: 1 },
{ key: 'spread', label: '散らばり幅', min: 0, max: 2, def: 0.5, step: 0.01 },
{ key: 'speed', label: '弾ける速さ', min: 0, max: 5, def: 1.0, step: 0.1 }
]},
TRAIL: { type: 'PARAM', subtype: 'TRAIL', emoji: '💫', color: '#ffdd55', radius: 14, params: [
{ key: 'count', label: '残像の数', min: 1, max: 20, def: 5, step: 1 },
{ key: 'interval', label: '間隔(秒)', min: 0.01, max: 0.5, def: 0.05, step: 0.01 },
{ key: 'fade', label: '減衰率(0-1)', min: 0.1, max: 1.0, def: 0.6, step: 0.01 }
]},
FILTER_DISTORT: { type: 'PARAM', subtype: 'FILTER_DISTORT', emoji: '🌊', color: '#5555ff', radius: 14, params: [
{ key: 'freq', label: '波の細かさ', min: 1, max: 20, def: 5.0, step: 0.1 },
{ key: 'power', label: '歪みの強さ', min: 0, max: 1.0, def: 0.1, step: 0.01 },
{ key: 'speed', label: '波の速さ', min: 0, max: 10, def: 2.0, step: 0.1 }
]}
};
// ==========================================
// 3. データ処理・UIヘルパー関数群
// ==========================================
function serializeNode(node) {
let clone = { id: node.id, x: node.x, y: node.y, type: node.type, subtype: node.subtype, refId: node.refId, emoji: node.emoji, color: node.color, radiusClosed: node.radiusClosed, radiusOpen: node.radiusOpen, radius: node.radius, params: node.params, vals: node.vals, isOpen: node.isOpen };
if(node.children) clone.children = node.children.map(c => serializeNode(c));
return clone;
}
function deserializeNode(data, x, y, keepId = false) {
let newId = keepId && data.id ? data.id : Math.random().toString(36).substr(2, 9);
let n = { id: newId, x: x !== undefined ? x : (data.x || layoutW/2), y: y !== undefined ? y : (data.y || layoutH/2), type: data.type, subtype: data.subtype, refId: data.refId, emoji: data.emoji, color: data.color, radiusClosed: data.radiusClosed, radiusOpen: data.radiusOpen, radius: data.radius, params: data.params, vals: data.vals, isOpen: data.isOpen, parent: null, children: [] };
nodes.push(n);
if(data.children) { data.children.forEach(cData => { let child = deserializeNode(cData, cData.x, cData.y, keepId); child.parent = n; n.children.push(child); }); }
return n;
}
function pushHistory() {
if(isRestoring) return;
history = history.slice(0, historyIndex + 1);
let state = { nodes: nodes.filter(n=>!n.parent).map(serializeNode), layout: { w: layoutW, h: layoutH, g: gridSz } };
history.push(JSON.stringify(state)); historyIndex++;
}
function applyState(state) {
clearNodesCore();
layoutW = state.layout.w; layoutH = state.layout.h; gridSz = state.layout.g;
document.getElementById('inp-ly-w').value = layoutW; document.getElementById('inp-ly-h').value = layoutH; document.getElementById('inp-ly-grid').value = gridSz;
state.nodes.forEach(data => deserializeNode(data, data.x, data.y, true));
let texNodes = getAllNodes(nodes).filter(n => n.subtype === 'IMAGE' || n.subtype === 'ANIMATION');
texNodes.forEach(n => { if(n.vals.img) loadTexture(n.id, n.vals.img); });
resizeCanvas(); updateState();
}
window.undo = () => { if(historyIndex > 0) { historyIndex--; isRestoring = true; applyState(JSON.parse(history[historyIndex])); isRestoring = false; } };
window.redo = () => { if(historyIndex < history.length - 1) { historyIndex++; isRestoring = true; applyState(JSON.parse(history[historyIndex])); isRestoring = false; } };
window.openDialog = (id) => { document.getElementById(id).style.display = 'flex'; };
window.closeDialog = (id) => { document.getElementById(id).style.display = 'none'; };
window.applyLayoutSettings = () => {
layoutW = parseInt(document.getElementById('inp-ly-w').value); layoutH = parseInt(document.getElementById('inp-ly-h').value); gridSz = parseInt(document.getElementById('inp-ly-grid').value);
resizeCanvas(); closeDialog('dlg-layout'); pushHistory();
};
window.addLayoutSize = () => {
let add = parseInt(document.getElementById('inp-ly-add').value);
if(isNaN(add) || add === 0) return;
layoutW += add * 2; layoutH += add * 2;
document.getElementById('inp-ly-w').value = layoutW; document.getElementById('inp-ly-h').value = layoutH;
nodes.filter(n => !n.parent).forEach(root => { root.x += add; root.y += add; });
resizeCanvas(); pushHistory(); alert("拡張しました");
};
window.applyTimelineSettings = () => {
tDuration = parseFloat(document.getElementById('inp-tl-len').value); tSpeed = parseFloat(document.getElementById('inp-tl-spd').value); tLoop = document.getElementById('inp-tl-loop').checked;
timeSlider.max = tDuration; closeDialog('dlg-timeline');
};
window.togglePlay = () => { isPlaying = !isPlaying; playBtn.innerText = isPlaying ? '⏸️' : '▶️'; };
timeSlider.addEventListener('input', (e) => { currentTime = parseFloat(e.target.value); timeDisplay.innerText = currentTime.toFixed(2); window.uniforms.u_time.value = currentTime; });
window.copyGLSL = () => { navigator.clipboard.writeText(glslContent.innerText).then(() => alert('コピーしました!')); };
let templates = JSON.parse(localStorage.getItem('gemTemplates') || '[]');
window.saveTemplate = () => {
if(!globalSelectedNode) return alert("対象を選択してください。");
let name = prompt("テンプレート名を入力:"); if(!name) return;
templates.push({ name: name, data: serializeNode(globalSelectedNode) });
localStorage.setItem('gemTemplates', JSON.stringify(templates)); renderTemplates();
};
function renderTemplates() {
const tc = document.getElementById('template-container'); tc.innerHTML = '';
templates.forEach((tpl, i) => {
let btn = document.createElement('button'); btn.className = 'tpl-btn'; btn.innerText = tpl.name;
btn.onclick = () => {
let n = deserializeNode(tpl.data, layoutW/2, layoutH/2, false);
let texNodes = getAllNodes([n]).filter(x => x.subtype === 'IMAGE' || x.subtype === 'ANIMATION');
texNodes.forEach(x => { if(x.vals.img) loadTexture(x.id, x.vals.img); });
globalSelectedNode = n; updateState(); pushHistory();
};
btn.oncontextmenu = (e) => { e.preventDefault(); if(confirm(`削除しますか?`)){ templates.splice(i, 1); localStorage.setItem('gemTemplates', JSON.stringify(templates)); renderTemplates(); } };
tc.appendChild(btn);
});
}
const fileOps = {
currentHandle: null,
new: () => { if(confirm("新規作成しますか?")) { clearNodes(); fileOps.currentHandle = null; document.getElementById('proj-name').innerText = "新規プロジェクト"; pushHistory(); } },
open: async () => {
const input = document.getElementById('file-loader');
input.onchange = async (e) => {
const file = e.target.files[0];
let state = JSON.parse(await file.text());
if(Array.isArray(state)) state = { nodes: state, layout: {w:2000, h:2000, g:50} };
applyState(state);
document.getElementById('proj-name').innerText = file.name; input.value = ''; pushHistory();
};
input.click();
},
saveAs: async () => {
let state = { nodes: nodes.filter(n=>!n.parent).map(serializeNode), layout: { w: layoutW, h: layoutH, g: gridSz } };
const blob = new Blob([JSON.stringify(state, null, 2)], {type: "application/json"});
const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = "effect_project.json"; a.click();
},
save: () => fileOps.saveAs(),
exportGLSL: () => window.copyGLSL(),
exportSpriteSheet: () => {
let durationStr = prompt("アニメーションの総時間(秒)を入力してください", "2.0"); if(!durationStr) return;
let fpsStr = prompt("フレームレート(FPS)を入力してください\n※高いと画像が巨大になります", "30"); if(!fpsStr) return;
let duration = parseFloat(durationStr); let fps = parseInt(fpsStr);
let totalFrames = Math.ceil(duration * fps);
let cols = Math.ceil(Math.sqrt(totalFrames)); let rows = Math.ceil(totalFrames / cols);
let frameW = 256; let frameH = 256;
let outCanvas = document.createElement('canvas'); outCanvas.width = cols * frameW; outCanvas.height = rows * frameH;
let outCtx = outCanvas.getContext('2d');
let oldTime = window.uniforms.u_time.value;
let oldW = previewContainer.clientWidth; let oldH = previewContainer.clientHeight;
renderer.setSize(frameW, frameH); window.uniforms.u_aspect.value = frameW / frameH;
for(let i=0; i<totalFrames; i++) {
window.uniforms.u_time.value = i / fps; renderer.render(scene, camera);
let col = i % cols; let row = Math.floor(i / cols);
outCtx.drawImage(renderer.domElement, 0, 0, frameW, frameH, col * frameW, row * frameH, frameW, frameH);
}
window.uniforms.u_time.value = oldTime;
renderer.setSize(oldW, oldH); window.uniforms.u_aspect.value = oldW / oldH;
let a = document.createElement('a'); a.href = outCanvas.toDataURL('image/png'); a.download = 'spritesheet.png'; a.click();
}
};
window.fileOps = fileOps;
// ==========================================
// 4. 左側リスト更新とドラッグ&ドロップ(BASE_REF)
// ==========================================
function updateObjectList() {
objList.innerHTML = '';
let rootBases = nodes.filter(n => n.type === 'BASE' && !n.parent);
rootBases.forEach((root, idx) => {
let div = document.createElement('div'); div.className = 'obj-list-item'; div.innerText = `${root.emoji} Base ${idx + 1}`;
if(globalSelectedNode === root) div.style.borderColor = '#00ffcc';
div.draggable = true; div.ondragstart = (e) => { e.dataTransfer.setData('text/plain', root.id); };
div.onclick = () => { globalSelectedNode = root; updateObjectList(); };
objList.appendChild(div);
});
}
canvas.addEventListener('dragover', e => { e.preventDefault(); });
canvas.addEventListener('drop', e => {
e.preventDefault(); const refId = e.dataTransfer.getData('text/plain');
if(refId) {
const rect = canvas.getBoundingClientRect();
let nx = (e.clientX - rect.left) / zoom; let ny = (e.clientY - rect.top) / zoom;
if(document.getElementById('snap-toggle').checked) { nx = Math.round(nx/gridSz)*gridSz; ny = Math.round(ny/gridSz)*gridSz; }
let n = { id: Math.random().toString(36).substr(2, 9), type: 'BASE_REF', refId: refId, x: nx, y: ny, parent: null, children: [], isOpen: false };
nodes.push(n); globalSelectedNode = n; updateState(); pushHistory();
}
});
function updateState() { updateObjectList(); updateShader(); }
// ==========================================
// 5. ツリー構造・UIロジック (スクロール/ズーム対応)
// ==========================================
function resizeCanvas() {
canvas.width = layoutW * zoom; canvas.height = layoutH * zoom;
canvas.style.width = `${layoutW * zoom}px`; canvas.style.height = `${layoutH * zoom}px`;
zoomValDisplay.innerText = `${Math.round(zoom * 100)}%`;
updateState();
}
function clearNodesCore() { nodes = []; globalSelectedNode = null; closeParamPanel(); }
window.clearNodes = () => { clearNodesCore(); updateState(); pushHistory(); };
window.addNode = (defKey) => {
const def = NODE_DEFS[defKey]; const vals = {}; if(def.params) def.params.forEach(p => vals[p.key] = p.def);
let cx = (container.scrollLeft + container.clientWidth/2) / zoom; let cy = (container.scrollTop + container.clientHeight/2) / zoom;
let n = { id: Math.random().toString(36).substr(2, 9), ...def, vals: vals, x: cx, y: cy, parent: null, children: [], isOpen: true };
nodes.push(n); globalSelectedNode = n; updateState(); pushHistory();
};
function isBaseOpen(node) {
if(node.type !== 'BASE' && node.type !== 'BASE_REF') return false;
if(node.isOpen) return true;
if(node.children && node.children.some(c => (c.type === 'BASE' || c.type === 'BASE_REF') && isBaseOpen(c))) return true;
return false;
}
function getAllNodes(nodeList, visited = new Set()) {
let result = [];
nodeList.forEach(n => { if(visited.has(n)) return; visited.add(n); result.push(n); if(n.children) result = result.concat(getAllNodes(n.children, visited)); });
return result;
}
function getVisibleNodes(nodeList, visited = new Set()) {
let result = [];
nodeList.forEach(n => {
if(visited.has(n)) return; visited.add(n); result.push(n);
if(n.type === 'BASE' || n.type === 'BASE_REF') { if(n.isOpen && n.children) result = result.concat(getVisibleNodes(n.children, visited)); }
else if(n.children) { result = result.concat(getVisibleNodes(n.children, visited)); }
});
return result;
}
function getHitTarget(pos, ignoreNode = null) {
let visibleAll = getVisibleNodes(nodes.filter(n => !n.parent)).filter(n => n !== ignoreNode);
let target = null; let minR = 9999;
visibleAll.forEach(n => {
if(n.screenX === undefined) return;
let r = (n.type === 'BASE' || n.type === 'BASE_REF') ? (n.isOpen ? n.radiusOpen : n.radiusClosed) : n.radius;
if(Math.hypot(n.screenX - pos.x, n.screenY - pos.y) < r + 5) { if (r < minR) { minR = r; target = n; } }
});
return target;
}
function isDescendant(parent, child) {
if(!parent.children) return false; if(parent.children.includes(child)) return true;
return parent.children.some(c => isDescendant(c, child));
}
function getDepth(node) { let d = 0; let curr = node; while(curr.parent) { d++; curr = curr.parent; } return d; }
function getDropTarget(dragNode) {
let target = null; let maxDepth = -1;
let visibleAll = getVisibleNodes(nodes.filter(n => !n.parent)).filter(n => n !== dragNode && !isDescendant(dragNode, n));
visibleAll.forEach(n => {
if(n.screenX === undefined || (n.type !== 'BASE' && n.type !== 'BASE_REF')) return;
let r = n.isOpen ? n.radiusOpen : n.radiusClosed;
if(Math.hypot(n.screenX - dragNode.x, n.screenY - dragNode.y) < r + 15) {
let depth = getDepth(n); if(depth > maxDepth) { maxDepth = depth; target = n; }
}
});
return target;
}
function openParamPanel(node) {
if (node.type === 'BASE' || node.type === 'BASE_REF') return;
paramPanel.style.display = 'block'; paramTitle.innerText = `${node.emoji} の調整`;
paramContent.innerHTML = '';
node.params.forEach(p => {
let row = document.createElement('div'); row.className = 'param-row';
let labelDiv = document.createElement('label'); let nameSpan = document.createElement('span'); nameSpan.innerText = p.label;
let valSpan = document.createElement('span'); valSpan.innerText = (p.type === 'image' || p.type === 'textarea') ? '' : node.vals[p.key];
labelDiv.appendChild(nameSpan); labelDiv.appendChild(valSpan);
if(p.type === 'image') {
let input = document.createElement('input'); input.type = 'file'; input.accept = 'image/*';
let thumb = document.createElement('img'); thumb.style.width = '100%'; thumb.style.height = '40px'; thumb.style.objectFit = 'contain';
thumb.style.display = node.vals[p.key] ? 'block' : 'none'; if(node.vals[p.key]) thumb.src = node.vals[p.key];
input.addEventListener('change', (e) => {
const file = e.target.files[0]; if(!file) return; const reader = new FileReader();
reader.onload = (re) => { node.vals[p.key] = re.target.result; thumb.src = re.target.result; thumb.style.display = 'block'; loadTexture(node.id, re.target.result); pushHistory();};
reader.readAsDataURL(file);
});
row.appendChild(labelDiv); row.appendChild(input); row.appendChild(thumb);
} else if(p.type === 'textarea') {
let input = document.createElement('textarea');
input.style.width = '100%'; input.style.height = '80px';
input.style.background = '#050508'; input.style.color = '#00ffcc';
input.style.border = '1px solid #555'; input.style.fontFamily = 'monospace';
input.style.fontSize = '10px'; input.style.resize = 'vertical';
input.value = node.vals[p.key];
input.addEventListener('input', (e) => { node.vals[p.key] = e.target.value; updateState(); });
input.addEventListener('change', () => { pushHistory(); });
row.appendChild(labelDiv); row.appendChild(input);
} else {
let input = document.createElement('input'); input.type = 'range'; input.min = p.min; input.max = p.max; input.step = p.step; input.value = node.vals[p.key];
input.addEventListener('input', (e) => { let val = parseFloat(e.target.value); node.vals[p.key] = val; valSpan.innerText = val.toFixed(2); updateState(); });
input.addEventListener('change', () => { pushHistory(); });
row.appendChild(labelDiv); row.appendChild(input);
}
paramContent.appendChild(row);
});
updateParamPanelPos(node);
}
function closeParamPanel() { paramPanel.style.display = 'none'; }
function updateParamPanelPos(node = globalSelectedNode) {
if(!node || paramPanel.style.display === 'none') return;
if(node.screenX !== undefined) {
paramPanel.style.left = (node.screenX * zoom) + 'px';
paramPanel.style.top = ((node.screenY + 30) * zoom) + 'px';
}
}
const getPos = (e) => { const rect = canvas.getBoundingClientRect(); return { x: ((e.touches ? e.touches[0].clientX : e.clientX) - rect.left) / zoom, y: ((e.touches ? e.touches[0].clientY : e.clientY) - rect.top) / zoom }; };
canvas.addEventListener('pointerdown', (e) => {
if(e.button === 2) return; canvas.setPointerCapture(e.pointerId); ctxMenu.style.display = 'none';
const pos = getPos(e); dragStartPos = pos; isDragging = false; isPanning = false; hoverTarget = null;
draggedNode = getHitTarget(pos);
if(draggedNode) {
globalSelectedNode = draggedNode; updateObjectList();
} else {
globalSelectedNode = null; closeParamPanel(); updateObjectList();
isPanning = true; panStart = {x: e.clientX, y: e.clientY}; scrollStart = {x: container.scrollLeft, y: container.scrollTop};
}
});
canvas.addEventListener('pointermove', (e) => {
if(isPanning) { container.scrollLeft = scrollStart.x - (e.clientX - panStart.x); container.scrollTop = scrollStart.y - (e.clientY - panStart.y); return; }
if(!draggedNode) return;
const pos = getPos(e);
if(document.getElementById('snap-toggle').checked) { pos.x = Math.round(pos.x/gridSz)*gridSz; pos.y = Math.round(pos.y/gridSz)*gridSz; }
if(!isDragging && Math.hypot(pos.x - dragStartPos.x, pos.y - dragStartPos.y) > 4) {
isDragging = true;
if(draggedNode.parent) { draggedNode.parent.children = draggedNode.parent.children.filter(c => c !== draggedNode); draggedNode.parent = null; draggedNode.x = pos.x; draggedNode.y = pos.y; updateState(); }
}
if(isDragging) { draggedNode.x = pos.x; draggedNode.y = pos.y; hoverTarget = getDropTarget(draggedNode); updateParamPanelPos(); if(draggedNode.type === 'BASE') updateState(); }
});
canvas.addEventListener('pointerup', () => {
if(isPanning) { isPanning = false; return; }
if(draggedNode) {
if(!isDragging) {
let now = Date.now();
if(now - lastClickTime < 300 && lastClickNode === draggedNode) {
if(draggedNode.type === 'BASE' || draggedNode.type === 'BASE_REF') { draggedNode.isOpen = !draggedNode.isOpen; closeParamPanel(); }
else if(draggedNode.type === 'PARAM') { openParamPanel(draggedNode); }
lastClickTime = 0;
} else { lastClickTime = now; lastClickNode = draggedNode; }
} else {
if(hoverTarget) {
if(hoverTarget.type === 'BASE_REF' && draggedNode.type === 'PARAM') {
let actualBase = nodes.find(n => n.id === hoverTarget.refId);
if(actualBase) { draggedNode.parent = actualBase; actualBase.children.push(draggedNode); actualBase.isOpen = true; }
} else { draggedNode.parent = hoverTarget; hoverTarget.children.push(draggedNode); hoverTarget.isOpen = true; }
}
pushHistory();
}
}
draggedNode = null; isDragging = false; hoverTarget = null; updateState(); updateParamPanelPos();
});
window.addEventListener('keydown', (e) => {
if(e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
if(e.key === 'Delete' || e.key === 'Backspace') {
if(globalSelectedNode) { removeNode(globalSelectedNode); globalSelectedNode = null; updateState(); pushHistory(); }
}
if(e.key === 'PageUp') { zoom = Math.min(zoom + 0.1, 3.0); resizeCanvas(); e.preventDefault(); }
if(e.key === 'PageDown') { zoom = Math.max(zoom - 0.1, 0.2); resizeCanvas(); e.preventDefault(); }
});
// ==========================================
// 6. 右クリックコンテキストメニュー
// ==========================================
canvas.addEventListener('contextmenu', (e) => {
e.preventDefault(); const pos = getPos(e); ctxMousePos = { x: pos.x, y: pos.y };
let target = getHitTarget(pos); if(target) globalSelectedNode = target;
ctxMenu.style.display = 'block'; ctxMenu.style.left = e.clientX + 'px'; ctxMenu.style.top = e.clientY + 'px';
document.getElementById('ctx-cut').style.display = globalSelectedNode ? 'block' : 'none';
document.getElementById('ctx-copy').style.display = globalSelectedNode ? 'block' : 'none';
document.getElementById('ctx-delete').style.display = globalSelectedNode ? 'block' : 'none';
document.getElementById('ctx-paste').style.display = clipboardData ? 'block' : 'none';
if(!globalSelectedNode && !clipboardData) ctxMenu.style.display = 'none';
});
window.addEventListener('click', (e) => { if(!e.target.closest('#ctx-menu')) ctxMenu.style.display = 'none'; });
function removeNode(node) {
if(node.parent) node.parent.children = node.parent.children.filter(c => c !== node);
function getFlat(nList) { let res = []; nList.forEach(nx => { res.push(nx); if(nx.children) res = res.concat(getFlat(nx.children)); }); return res; }
let toDelete = getFlat([node]); nodes = nodes.filter(n => !toDelete.includes(n));
if(globalSelectedNode === node) { closeParamPanel(); globalSelectedNode = null; }
}
document.getElementById('ctx-delete').onclick = () => { if(!globalSelectedNode) return; removeNode(globalSelectedNode); ctxMenu.style.display = 'none'; updateState(); pushHistory(); };
document.getElementById('ctx-copy').onclick = () => { if(globalSelectedNode) clipboardData = serializeNode(globalSelectedNode); ctxMenu.style.display = 'none'; };
document.getElementById('ctx-cut').onclick = () => { if(globalSelectedNode) { clipboardData = serializeNode(globalSelectedNode); removeNode(globalSelectedNode); updateState(); pushHistory();} ctxMenu.style.display = 'none'; };
document.getElementById('ctx-paste').onclick = () => {
if(clipboardData) { let newNode = deserializeNode(clipboardData, ctxMousePos.x + (Math.random()*20-10), ctxMousePos.y + (Math.random()*20-10), false); globalSelectedNode = newNode; updateState(); pushHistory(); }
ctxMenu.style.display = 'none';
};
// ==========================================
// 7. GLSL生成エンジン
// ==========================================
function getInheritedModifier(node, subtype) {
let path = []; let curr = node; while(curr) { path.unshift(curr); curr = curr.parent; }
let result = null;
path.forEach(n => {
let actualN = n.type === 'BASE_REF' ? nodes.find(x => x.id === n.refId) : n;
if(actualN && actualN.children) { let found = actualN.children.find(c => c.subtype === subtype); if(found) result = found; }
});
return result;
}
function updateShader() {
let glsl = `
#define PI 3.14159265359
#define TWO_PI 6.28318530718
uniform float u_time;
uniform float u_aspect;
varying vec2 vUv;
vec3 hsv2rgb(vec3 c) {
vec4 K = vec4(1.0, 2.0 / 3.0, 1.0 / 3.0, 3.0);
vec3 p = abs(fract(c.xxx + K.xyz) * 6.0 - K.www);
return c.z * mix(K.xxx, clamp(p - K.xxx, 0.0, 1.0), c.y);
}
float sdPolygon(vec2 p, int n, float size) {
float a = atan(p.x, p.y) + PI;
float r = TWO_PI / float(n);
return cos(floor(0.5 + a / r) * r - a) * length(p) - size;
}
vec2 hash2(float n) {
float x = fract(sin(n) * 43758.5453123);
float y = fract(sin(n + 1.0) * 43758.5453123);
return vec2(x, y);
}
float noise3D(vec3 p) {
vec3 i = floor(p);
vec4 a = dot(i, vec3(1.0, 57.0, 21.0)) + vec4(0.0, 57.0, 21.0, 78.0);
vec3 f = cos((p-i)*acos(-1.0))*(-0.5)+0.5;
a = mix(sin(cos(a)*a),sin(cos(1.0+a)*(1.0+a)), f.x);
a.xy = mix(a.xz, a.yw, f.y);
return mix(a.x, a.y, f.z);
}
float flame3D(vec3 p, float time) {
float d = length(p * vec3(1.0, 0.5, 1.0) - vec3(0.0, -1.0, 0.0)) - 1.0;
return d + (noise3D(p + vec3(0.0, time * 2.0, 0.0)) + noise3D(p * 3.0) * 0.5) * 0.25 * (p.y);
}
`;
let texNodes = getAllNodes(nodes).filter(n => n.subtype === 'IMAGE' || n.subtype === 'ANIMATION');
texNodes.forEach((tn, i) => {
glsl += `uniform sampler2D u_tex${i};\n`;
if(tn.vals.img && !textures[tn.id]) loadTexture(tn.id, tn.vals.img);
window.uniforms[`u_tex${i}`] = { value: textures[tn.id] || null };
});
function getAllBases(nList) { let res = []; nList.forEach(n => { if(n.type==='BASE' || n.type==='BASE_REF') {res.push(n);} if(n.children) res=res.concat(getAllBases(n.children)); }); return res; }
let allBases = getAllBases(nodes.filter(n => !n.parent));
allBases.forEach((base, idx) => {
if(base.type === 'BASE_REF' && !nodes.find(x => x.id === base.refId)) return;
glsl += `\nvec2 getPos_base${idx}(vec2 st, float t) {\n`;
glsl += ` vec2 p = st;\n p.x *= u_aspect;\n`;
let path = []; let curr = base; while(curr) { path.unshift(curr); curr = curr.parent; }
path.forEach((n, level) => {
let isRoot = (level === 0);
if (isRoot) {
let uvX = n.x / layoutW; let uvY = 1.0 - (n.y / layoutH);
glsl += ` p -= vec2(${uvX.toFixed(4)} * u_aspect, ${uvY.toFixed(4)});\n`;
} else {
let r = n.parent.radiusOpen; let cidx = n.parent.children.indexOf(n);
let angle = (cidx / n.parent.children.length) * Math.PI * 2;
let dx = Math.cos(angle) * (r * 0.65) / layoutW; let dy = -(Math.sin(angle) * (r * 0.65) / layoutH);
glsl += ` p -= vec2(${dx.toFixed(4)} * u_aspect, ${dy.toFixed(4)});\n`;
}
let actualN = n.type === 'BASE_REF' ? nodes.find(x => x.id === n.refId) : n;
let motions = actualN && actualN.children ? actualN.children.filter(c => c.subtype && c.subtype.startsWith('MOTION_')) : [];
let easing = motions.find(m => m.subtype === 'MOTION_EASING');
glsl += ` float t_local_${level} = t;\n`;
if(easing) {
let mode = easing.vals.mode;
if(mode === 1) glsl += ` t_local_${level} = pow(fract(t), 2.0) + floor(t);\n`;
else if(mode === 2) glsl += ` t_local_${level} = (1.0 - pow(1.0 - fract(t), 2.0)) + floor(t);\n`;
else glsl += ` t_local_${level} = smoothstep(0.0, 1.0, fract(t)) + floor(t);\n`;
}
let rotMotion = motions.find(m => m.subtype === 'MOTION_ROTATE');
if(rotMotion) {
glsl += ` {\n float a = -(t_local_${level} * ${rotMotion.vals.speed.toFixed(2)});\n float s = sin(a), c = cos(a);\n p = mat2(c, -s, s, c) * p;\n }\n`;
}
glsl += ` vec2 offset_${level} = vec2(0.0);\n`;
motions.forEach(m => {
let v = m.vals;
if(m.subtype === 'MOTION_LINEAR') glsl += ` offset_${level} += vec2(${v.vx.toFixed(2)}, ${-v.vy.toFixed(2)}) * t_local_${level};\n`;
else if(m.subtype === 'MOTION_SINE') { let rad = v.angle * Math.PI / 180; glsl += ` offset_${level} += vec2(${Math.cos(rad).toFixed(3)}, ${-Math.sin(rad).toFixed(3)}) * sin(t_local_${level} * ${v.freq.toFixed(2)}) * ${v.amp.toFixed(2)};\n`; }
else if(m.subtype === 'MOTION_BOUNCE') glsl += ` offset_${level}.y += abs(sin(t_local_${level} * ${v.speed.toFixed(2)})) * ${v.height.toFixed(2)};\n`;
else if(m.subtype === 'MOTION_PARABOLA') glsl += ` offset_${level} += vec2(${v.vx.toFixed(2)} * t_local_${level}, ${v.vy.toFixed(2)} * t_local_${level} - 0.5 * ${v.g.toFixed(2)} * t_local_${level} * t_local_${level});\n`;
else if(m.subtype === 'MOTION_SPIRAL') {
glsl += ` float r_sp_${level} = min(${v.radius.toFixed(2)} * t_local_${level}, ${v.limit.toFixed(2)});\n`;
glsl += ` offset_${level} += vec2(cos(t_local_${level} * ${v.spin.toFixed(2)}), -sin(t_local_${level} * ${v.spin.toFixed(2)})) * r_sp_${level};\n`;
}
else if(m.subtype === 'MOTION_HOMING') glsl += ` offset_${level} += mix(vec2(0.0), vec2(${v.tx.toFixed(2)}, ${-v.ty.toFixed(2)}), clamp(t_local_${level} * ${v.speed.toFixed(2)}, 0.0, 1.0));\n`;
else if(m.subtype === 'MOTION_SHAKE') glsl += ` offset_${level} += vec2(sin(t_local_${level} * ${v.speed.toFixed(2)} + 1.0)*0.5 + sin(t_local_${level} * ${v.speed.toFixed(2)} * 1.7)*0.5, cos(t_local_${level} * ${v.speed.toFixed(2)} * 1.3)*0.5 + cos(t_local_${level} * ${v.speed.toFixed(2)} * 0.8)*0.5) * ${v.amp.toFixed(3)};\n`;
else if(m.subtype === 'MOTION_FALL') glsl += ` offset_${level}.y -= 0.5 * ${v.g.toFixed(2)} * t_local_${level} * t_local_${level};\n`;
});
glsl += ` p -= vec2(offset_${level}.x * u_aspect, offset_${level}.y);\n`;
let distort = actualN && actualN.children ? actualN.children.find(c => c.subtype === 'FILTER_DISTORT') : null;
if(distort) {
let dv = distort.vals;
glsl += ` p += vec2(sin(p.y * ${dv.freq.toFixed(2)} + t * ${dv.speed.toFixed(2)}), cos(p.x * ${dv.freq.toFixed(2)} + t * ${dv.speed.toFixed(2)})) * ${dv.power.toFixed(3)};\n`;
}
});
glsl += ` return p;\n}\n`;
});
glsl += `
void main() {
vec3 finalColor = vec3(0.0);
vec2 st = vUv;
`;
allBases.forEach((base, idx) => {
if(base.type === 'BASE_REF' && !nodes.find(x => x.id === base.refId)) return;
glsl += ` // --- Base Object ${idx} ---\n {\n`;
let timeNode = getInheritedModifier(base, 'TIME');
glsl += ` float t_base = u_time;\n float alpha = 1.0;\n`;
if(timeNode) {
let v = timeNode.vals;
glsl += ` t_base = max(0.0, u_time - ${v.delay.toFixed(2)});\n`;
glsl += ` t_base = mod(t_base, ${v.life.toFixed(2)} + 0.01);\n`;
if(v.fade > 0.5) glsl += ` alpha = clamp(1.0 - (t_base / ${v.life.toFixed(2)}), 0.0, 1.0);\n`;
} else {
glsl += ` t_base = mod(u_time, 3.0);\n`;
}
let filterNode = getInheritedModifier(base, 'FILTER');
let st_used = 'st';
if(filterNode && filterNode.vals.pixel > 0) {
let pScale = 200.0 / filterNode.vals.pixel;
glsl += ` vec2 st_mod = floor(st * ${pScale.toFixed(2)}) / ${pScale.toFixed(2)};\n`;
st_used = 'st_mod';
}
let trailNode = getInheritedModifier(base, 'TRAIL');
let trailCount = trailNode ? trailNode.vals.count : 1;
let trailInterval = trailNode ? trailNode.vals.interval : 0.05;
let trailFade = trailNode ? trailNode.vals.fade : 1.0;
let customNode = getInheritedModifier(base, 'CUSTOM');
let shape = getInheritedModifier(base, 'SHAPE');
let imageNode = getInheritedModifier(base, 'IMAGE');
let animNode = getInheritedModifier(base, 'ANIMATION');
let targetNode = animNode || imageNode || shape;
let n_gon = shape ? shape.vals.n : 0;
let shape_size = targetNode ? targetNode.vals.size : 0.05;
let particleNode = getInheritedModifier(base, 'PARTICLE');
let hasTex = (animNode || imageNode) ? true : false;
let texIdx = hasTex ? texNodes.indexOf(animNode || imageNode) : -1;
let color = getInheritedModifier(base, 'COLOR');
let colorCode = color ? `hsv2rgb(vec3(${color.vals.h.toFixed(3)}, ${color.vals.s.toFixed(2)}, ${color.vals.v.toFixed(2)}))` : `vec3(1.0)`;
let fallNode = getInheritedModifier(base, 'MOTION_FALL');
glsl += ` vec3 base_accum_col = vec3(0.0);\n`;
glsl += ` for(int tr = 0; tr < 30; tr++) {\n`;
glsl += ` if(tr >= ${Math.floor(trailCount)}) break;\n`;
glsl += ` float t_tr = max(0.0, t_base - float(tr) * ${trailInterval.toFixed(3)});\n`;
glsl += ` float currentFade = pow(${trailFade.toFixed(3)}, float(tr));\n`;
glsl += ` vec2 p = getPos_base${idx}(${st_used}, t_tr);\n`;
if(fallNode) {
glsl += ` {\n float fa = t_tr * ${fallNode.vals.spin.toFixed(2)};\n float fs = sin(fa), fc = cos(fa);\n p = mat2(fc, -fs, fs, fc) * p;\n }\n`;
}
if (customNode) {
glsl += ` vec3 base_color = ${colorCode};\n`;
if (particleNode) {
let pv = particleNode.vals; let p_spread = pv.spread.toFixed(3);
glsl += ` float out_d = 999.0;\n vec3 out_col = vec3(0.0);\n`;
glsl += ` for(int i=0; i<30; i++) {\n if(float(i) >= ${pv.count.toFixed(1)}) break;\n`;
glsl += ` vec2 poff = (hash2(float(i)) * 2.0 - 1.0) * (${p_spread} + t_tr * ${pv.speed.toFixed(2)});\n poff.x *= u_aspect;\n`;
glsl += ` vec2 orig_p = p;\n p = orig_p - poff;\n`;
glsl += ` float local_d = 999.0;\n vec3 local_col = vec3(1.0);\n`;
glsl += ` // --- Custom GLSL ---\n`;
glsl += ` ${customNode.vals.code}\n`;
glsl += ` if(local_d < out_d) { out_d = local_d; out_col = local_col; }\n`;
glsl += ` p = orig_p;\n`;
glsl += ` }\n`;
glsl += ` float local_d = out_d;\n`;
glsl += ` vec3 local_col = out_col * base_color;\n`;
glsl += ` float local_mask = smoothstep(0.015, 0.0, local_d);\n`;
} else {
glsl += ` float local_d = 999.0;\n vec3 local_col = vec3(1.0);\n`;
glsl += ` // --- Custom GLSL ---\n`;
glsl += ` ${customNode.vals.code}\n`;
glsl += ` local_col *= base_color;\n`;
glsl += ` float local_mask = smoothstep(0.015, 0.0, local_d);\n`;
}
} else {
glsl += ` float local_d = 999.0;\n vec4 localTexCol = vec4(0.0);\n`;
let animCode = "";
if(animNode) {
animCode = `float fIdx = floor(mod(t_tr / ${animNode.vals.speed.toFixed(3)}, ${animNode.vals.frames.toFixed(1)})); texUv.x = (texUv.x + fIdx) / ${animNode.vals.frames.toFixed(1)};`;
}
if(particleNode) {
let pv = particleNode.vals; let p_spread = pv.spread.toFixed(3);
glsl += ` for(int i=0; i<30; i++) {\n if(float(i) >= ${pv.count.toFixed(1)}) break;\n`;
glsl += ` vec2 poff = (hash2(float(i)) * 2.0 - 1.0) * (${p_spread} + t_tr * ${pv.speed.toFixed(2)});\n poff.x *= u_aspect;\n vec2 pp = p - poff;\n`;
if (hasTex) {
glsl += ` vec2 absp = abs(pp);\n float pd = max(absp.x, absp.y) - ${shape_size.toFixed(3)};\n local_d = min(local_d, pd);\n`;
glsl += ` vec2 texUv = clamp((pp / ${shape_size.toFixed(3)}) * 0.5 + 0.5, 0.0, 1.0);\n ${animCode}\n`;
glsl += ` vec4 tCol = texture2D(u_tex${texIdx}, texUv);\n localTexCol += tCol * smoothstep(0.015, 0.0, pd);\n`;
} else {
if(n_gon < 3) glsl += ` local_d = min(local_d, length(pp) - ${shape_size.toFixed(3)});\n`;
else glsl += ` local_d = min(local_d, sdPolygon(pp, ${n_gon}, ${shape_size.toFixed(3)}));\n`;
}
glsl += ` }\n`;
if(hasTex) glsl += ` localTexCol = clamp(localTexCol, 0.0, 1.0);\n`;
} else {
if (hasTex) {
glsl += ` vec2 absp = abs(p);\n local_d = max(absp.x, absp.y) - ${shape_size.toFixed(3)};\n`;
glsl += ` vec2 texUv = clamp((p / ${shape_size.toFixed(3)}) * 0.5 + 0.5, 0.0, 1.0);\n ${animCode}\n`;
glsl += ` localTexCol = texture2D(u_tex${texIdx}, texUv);\n`;
} else {
if(n_gon < 3) glsl += ` local_d = length(p) - ${shape_size.toFixed(3)};\n`;
else glsl += ` local_d = sdPolygon(p, ${n_gon}, ${shape_size.toFixed(3)});\n`;
}
}
glsl += ` float local_mask = smoothstep(0.015, 0.0, local_d);\n`;
glsl += ` vec3 local_col = ${colorCode};\n`;
if (hasTex) { glsl += ` local_col *= localTexCol.rgb;\n local_mask *= localTexCol.a;\n`; }
}
if(filterNode && filterNode.vals.glow > 0) {
glsl += ` local_mask += max(0.0, ${filterNode.vals.glow.toFixed(3)}) * (0.01 / (local_d*local_d + 0.001));\n`;
}
glsl += ` base_accum_col += local_col * local_mask * currentFade * alpha;\n`;
glsl += ` }\n`;
glsl += ` finalColor += base_accum_col;\n }\n`;
});
glsl += `\n gl_FragColor = vec4(finalColor, 1.0);\n}`;
glslContent.innerText = glsl.trim();
if(mesh) {
const newMat = new THREE.ShaderMaterial({
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: glsl, uniforms: window.uniforms, blending: THREE.AdditiveBlending, transparent: true
});
if(mesh.material) mesh.material.dispose(); mesh.material = newMat;
}
}
// ==========================================
// 8. キャンバス描画ループ (論理座標とズーム適用)
// ==========================================
function calcPos(node, cx, cy) {
node.screenX = cx; node.screenY = cy;
if((node.type === 'BASE' || node.type === 'BASE_REF') && node.isOpen && node.children) {
let r = node.radiusOpen;
node.children.forEach((child, i) => {
let angle = (i / node.children.length) * Math.PI * 2;
let childX = cx + Math.cos(angle) * (r * 0.65);
let childY = cy + Math.sin(angle) * (r * 0.65);
if(draggedNode === child) { childX = child.x; childY = child.y; }
calcPos(child, childX, childY);
});
}
}
function animateCanvas() {
ctx.clearRect(0, 0, canvas.width, canvas.height);
ctx.save();
ctx.scale(zoom, zoom);
ctx.fillStyle = '#111118';
ctx.fillRect(0, 0, layoutW, layoutH);
ctx.strokeStyle = '#1a1a25';
ctx.lineWidth = 1;
ctx.beginPath();
for(let x=0; x<=layoutW; x+=gridSz) { ctx.moveTo(x, 0); ctx.lineTo(x, layoutH); }
for(let y=0; y<=layoutH; y+=gridSz) { ctx.moveTo(0, y); ctx.lineTo(layoutW, y); }
ctx.stroke();
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
nodes.filter(n => !n.parent).forEach(root => calcPos(root, root.x, root.y));
let visibleNodes = getVisibleNodes(nodes.filter(n => !n.parent));
ctx.strokeStyle = '#334455'; ctx.lineWidth = 2;
visibleNodes.filter(n => (n.type === 'BASE' || n.type === 'BASE_REF') && n.parent).forEach(b => {
ctx.beginPath(); ctx.moveTo(b.parent.screenX, b.parent.screenY); ctx.lineTo(b.screenX, b.screenY); ctx.stroke();
});
if(hoverTarget && hoverTarget.screenX !== undefined) {
ctx.beginPath(); ctx.arc(hoverTarget.screenX, hoverTarget.screenY, hoverTarget.radiusOpen + 8, 0, Math.PI * 2);
ctx.strokeStyle = `rgba(255, 200, 0, ${0.4 + 0.6 * Math.sin(Date.now() / 100)})`; ctx.lineWidth = 4; ctx.stroke();
}
visibleNodes.forEach(n => {
let sx = n.screenX; let sy = n.screenY;
if(n.type === 'BASE') {
let r = n.isOpen ? n.radiusOpen : n.radiusClosed;
ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2);
ctx.fillStyle = n.isOpen ? '#1a1a25' : n.color; ctx.fill();
ctx.strokeStyle = n.isOpen ? '#00ffcc' : '#555'; ctx.lineWidth = n.isOpen ? 2 : 1;
if(n.isOpen) ctx.setLineDash([5, 5]); else ctx.setLineDash([]);
ctx.stroke(); ctx.setLineDash([]);
if(!n.isOpen) { ctx.font = '14px sans-serif'; ctx.fillText(n.emoji, sx, sy + 1); }
else { ctx.beginPath(); ctx.arc(sx, sy, 4, 0, Math.PI*2); ctx.fillStyle='#00ffcc'; ctx.fill(); }
}
else if(n.type === 'BASE_REF') {
let actual = nodes.find(x => x.id === n.refId);
let emoji = actual ? actual.emoji : '❓'; let color = actual ? actual.color : '#555';
let r = n.isOpen ? n.radiusOpen : n.radiusClosed;
ctx.beginPath(); ctx.arc(sx, sy, r, 0, Math.PI * 2);
ctx.fillStyle = n.isOpen ? '#111' : '#222'; ctx.fill();
ctx.strokeStyle = color; ctx.lineWidth = n.isOpen ? 2 : 1;
if(n.isOpen) ctx.setLineDash([3, 5]); else ctx.setLineDash([2, 2]);
ctx.stroke(); ctx.setLineDash([]);
if(!n.isOpen) { ctx.font = '14px sans-serif'; ctx.fillText(emoji, sx, sy + 1); }
else { ctx.beginPath(); ctx.arc(sx, sy, 4, 0, Math.PI*2); ctx.fillStyle=color; ctx.fill(); }
}
else if(n.type === 'PARAM') {
ctx.beginPath(); ctx.arc(sx, sy, n.radius, 0, Math.PI * 2);
ctx.fillStyle = n.color; ctx.fill();
ctx.strokeStyle = '#222'; ctx.lineWidth = 2; ctx.stroke();
ctx.font = '14px sans-serif'; ctx.fillText(n.emoji, sx, sy + 1);
}
});
if(globalSelectedNode && globalSelectedNode.screenX !== undefined && visibleNodes.includes(globalSelectedNode)) {
let t = Date.now() / 150; let sx = globalSelectedNode.screenX; let sy = globalSelectedNode.screenY;
let r = (globalSelectedNode.type === 'BASE' || globalSelectedNode.type === 'BASE_REF') ? (globalSelectedNode.isOpen ? globalSelectedNode.radiusOpen : globalSelectedNode.radiusClosed) : globalSelectedNode.radius;
ctx.beginPath(); ctx.arc(sx, sy, r + 5 + Math.sin(t)*2, 0, Math.PI*2);
ctx.strokeStyle = `rgba(0, 255, 204, ${0.4 + 0.4 * Math.sin(t)})`; ctx.lineWidth = 3; ctx.stroke();
}
ctx.restore();
requestAnimationFrame(animateCanvas);
}
setTimeout(() => { resizeCanvas(); animateCanvas(); }, 100);
// ==========================================
// 9. アニメーションループ
// ==========================================
function animateThree() {
requestAnimationFrame(animateThree);
let delta = clock.getDelta();
if(isPlaying) {
currentTime += delta * tSpeed;
if(currentTime >= tDuration) {
if(tLoop) currentTime = 0.0;
else { currentTime = tDuration; isPlaying = false; playBtn.innerText = '▶️'; }
}
timeSlider.value = currentTime;
timeDisplay.innerText = currentTime.toFixed(2);
window.uniforms.u_time.value = currentTime;
}
renderer.render(scene, camera);
}
animateThree();
</script>
</body>
</html>
・カスタムのサンプル。(炎)↓
vec3 org = vec3(0.0, -2.0, 4.0);
vec3 dir = normalize(vec3(p.x * 2.0, -p.y * 2.0, -1.5));
float d_ray = 0.0, glow = 0.0, eps = 0.02;
vec3 rp = org;
bool glowed = false;
for(int i = 0; i < 64; i++) {
float f_dist = flame3D(rp, t_tr);
d_ray = min(100.0 - length(rp), abs(f_dist)) + eps;
rp += d_ray * dir;
if( d_ray > eps ) {
if(f_dist < 0.0) glowed = true;
if(glowed) glow = float(i) / 64.0;
}
}
vec3 col_base = mix(vec3(1.0, 0.5, 0.1), vec3(0.1, 0.5, 1.0), rp.y * 0.02 + 0.4);
vec3 final_rgb = mix(vec3(0.0), col_base, pow(glow * 2.0, 4.0));
local_col = final_rgb;
local_d = -1.0;


コメント