GLSLシェーダーで遊ぶ「Glsl Viewer」
・ソースコードはこちら。↓
<!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 - Master</title>
<style>
body { margin: 0; display: flex; height: 100vh; background: #0a0a10; color: #fff; font-family: 'Segoe UI', sans-serif; overflow: hidden; }
#editor { width: 50%; display: flex; flex-direction: column; border-right: 4px solid #222; position: relative; }
#toolbar { padding: 8px; background: #151520; display: flex; gap: 6px; flex-wrap: wrap; z-index: 10; box-shadow: 0 4px 10px rgba(0,0,0,0.8);}
.btn-group { display: flex; gap: 4px; border: 1px solid #333; padding: 4px; border-radius: 6px; background: #111; align-items: center;}
.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;}
button:hover { background: #333; transform: scale(1.1); }
button:active { transform: scale(0.9); }
#ui-canvas { flex-grow: 1; background: #111118; cursor: default; touch-action: none; }
#preview { width: 50%; position: relative; background: #000; }
#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; box-sizing: border-box; overflow-y: auto; pointer-events: none; border-top: 1px solid #333; }
/* パラメータパネル */
#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; }
</style>
</head>
<body>
<div id="editor">
<div id="toolbar">
<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('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>
</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>
<button onclick="clearNodes()" style="margin-left:auto; font-size:12px;">🗑️ リセット</button>
</div>
<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 id="preview">
<div id="log-panel">// ✨特殊宝石(時間・パーティクル等)が追加されました!</div>
</div>
<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: -2, max: 2, def: 0.5, step: 0.01 },
{ key: 'vy', label: 'Y速度', min: -2, max: 2, def: 0.0, step: 0.01 }
]},
M_SINE: { type: 'PARAM', subtype: 'MOTION_SINE', emoji: '↔️', color: '#55ffaa', radius: 14, params: [
{ key: 'ampX', label: 'X振幅', min: 0, max: 1, def: 0.5, step: 0.01 },
{ key: 'ampY', label: 'Y振幅', min: 0, max: 1, def: 0.0, step: 0.01 },
{ 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 }
]},
// 新規追加:特殊系(時間・フィルター・パーティクル)
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. UI キャンバスロジック
// ==========================================
const canvas = document.getElementById('ui-canvas');
const ctx = canvas.getContext('2d');
const paramPanel = document.getElementById('param-panel');
let width, height;
let nodes = [];
let draggedNode = null;
let selectedParam = null;
let activeBase = null;
// クリップボード用変数
let clipboardData = null;
let ctxTarget = null;
let ctxMousePos = {x:0, y:0};
function resize() {
width = canvas.parentElement.clientWidth; height = canvas.parentElement.clientHeight - document.getElementById('toolbar').clientHeight;
canvas.width = width; canvas.height = height;
if(window.uniforms) window.uniforms.u_aspect.value = width / height;
updateShader();
}
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: []
};
nodes.push(n);
if(n.type === 'BASE') activeBase = n;
updateShader();
};
window.clearNodes = () => { nodes = []; activeBase = null; closeParamPanel(); updateShader(); };
// --- パラメータパネルUI ---
function openParamPanel(node) {
if (node.type === 'BASE') return;
selectedParam = node;
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);
updateShader();
});
row.appendChild(labelDiv); row.appendChild(input); content.appendChild(row);
});
updateParamPanelPos();
}
function closeParamPanel() { paramPanel.style.display = 'none'; selectedParam = null; }
function updateParamPanelPos() {
if(!selectedParam || paramPanel.style.display === 'none') return;
let px = selectedParam.parent ? selectedParam.parent.x : selectedParam.x;
let py = selectedParam.parent ? selectedParam.parent.y : selectedParam.y;
paramPanel.style.left = px + 'px'; paramPanel.style.top = (py + (selectedParam.parent ? NODE_DEFS.BASE.radiusOpen : 20) + 10) + '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 }; };
// クリックした対象を判定する共通関数
function getHitTarget(pos) {
let target = null;
if(activeBase) {
activeBase.children.forEach((child, i) => {
let angle = (i / activeBase.children.length) * Math.PI * 2;
let cx = activeBase.x + Math.cos(angle) * (activeBase.radiusOpen * 0.65);
let cy = activeBase.y + Math.sin(angle) * (activeBase.radiusOpen * 0.65);
if(Math.hypot(cx - pos.x, cy - pos.y) < child.radius + 5) target = child;
});
}
if(!target) target = nodes.find(n => n.type === 'PARAM' && !n.parent && Math.hypot(n.x - pos.x, n.y - pos.y) < n.radius + 5);
if(!target) target = nodes.find(n => n.type === 'BASE' && Math.hypot(n.x - pos.x, n.y - pos.y) < (n === activeBase ? n.radiusOpen : n.radiusClosed));
return target;
}
canvas.addEventListener('pointerdown', (e) => {
if(e.button === 2) return;
canvas.setPointerCapture(e.pointerId);
document.getElementById('ctx-menu').style.display = 'none';
const pos = getPos(e);
draggedNode = getHitTarget(pos);
if(draggedNode) {
if(draggedNode.type === 'BASE') {
activeBase = draggedNode;
closeParamPanel();
} else if(draggedNode.type === 'PARAM') {
if(draggedNode.parent) {
draggedNode.parent.children = draggedNode.parent.children.filter(c => c !== draggedNode);
draggedNode.parent = null;
draggedNode.x = pos.x; draggedNode.y = pos.y;
updateShader();
}
openParamPanel(draggedNode);
}
} else {
activeBase = null;
closeParamPanel();
}
});
canvas.addEventListener('pointermove', (e) => {
if(!draggedNode) return;
const pos = getPos(e); draggedNode.x = pos.x; draggedNode.y = pos.y;
updateParamPanelPos();
if(draggedNode.type === 'BASE') updateShader();
});
canvas.addEventListener('pointerup', () => {
if(draggedNode && draggedNode.type === 'PARAM' && activeBase) {
if(Math.hypot(activeBase.x - draggedNode.x, activeBase.y - draggedNode.y) < activeBase.radiusOpen + 20) {
draggedNode.parent = activeBase;
activeBase.children.push(draggedNode);
updateShader();
updateParamPanelPos();
}
}
draggedNode = null;
});
// ==========================================
// 3. 右クリックコンテキストメニュー & コピペ機能
// ==========================================
const ctxMenu = document.getElementById('ctx-menu');
canvas.addEventListener('contextmenu', (e) => {
e.preventDefault();
const pos = getPos(e);
ctxMousePos = { x: pos.x, y: pos.y };
ctxTarget = getHitTarget(pos);
ctxMenu.style.display = 'block';
ctxMenu.style.left = e.clientX + 'px';
ctxMenu.style.top = e.clientY + 'px';
document.getElementById('ctx-cut').style.display = ctxTarget ? 'block' : 'none';
document.getElementById('ctx-copy').style.display = ctxTarget ? 'block' : 'none';
document.getElementById('ctx-delete').style.display = ctxTarget ? 'block' : 'none';
document.getElementById('ctx-sep-1').style.display = (ctxTarget && clipboardData) ? 'block' : 'none';
document.getElementById('ctx-paste').style.display = clipboardData ? 'block' : 'none';
if(!ctxTarget && !clipboardData) ctxMenu.style.display = 'none';
});
window.addEventListener('click', (e) => {
if(!e.target.closest('#ctx-menu')) ctxMenu.style.display = 'none';
});
function serializeNode(node) {
let clone = JSON.parse(JSON.stringify({
type: node.type, subtype: node.subtype, emoji: node.emoji, color: node.color,
radiusClosed: node.radiusClosed, radiusOpen: node.radiusOpen, radius: node.radius,
params: node.params, vals: node.vals
}));
if(node.type === 'BASE') {
clone.children = node.children.map(c => serializeNode(c));
}
return clone;
}
function deserializeNode(data, x, y) {
let n = {
id: Math.random().toString(36).substr(2, 9),
...data, x: x, y: y, parent: null, children: []
};
nodes.push(n);
if(data.type === 'BASE' && data.children) {
data.children.forEach(cData => {
let child = deserializeNode(cData, x, y);
child.parent = n;
n.children.push(child);
});
}
return n;
}
function removeNode(node) {
if(node.type === 'BASE') {
node.children.forEach(c => { nodes = nodes.filter(n => n !== c); });
if(activeBase === node) activeBase = null;
} else if(node.type === 'PARAM' && node.parent) {
node.parent.children = node.parent.children.filter(c => c !== node);
}
nodes = nodes.filter(n => n !== node);
if(selectedParam === node) closeParamPanel();
}
document.getElementById('ctx-delete').onclick = () => { if(!ctxTarget) return; removeNode(ctxTarget); ctxMenu.style.display = 'none'; updateShader(); };
document.getElementById('ctx-copy').onclick = () => { if(ctxTarget) clipboardData = serializeNode(ctxTarget); ctxMenu.style.display = 'none'; };
document.getElementById('ctx-cut').onclick = () => { if(ctxTarget) { clipboardData = serializeNode(ctxTarget); removeNode(ctxTarget); updateShader(); } ctxMenu.style.display = 'none'; };
document.getElementById('ctx-paste').onclick = () => {
if(clipboardData) {
let pasteX = ctxMousePos.x + (Math.random()*20 - 10);
let pasteY = ctxMousePos.y + (Math.random()*20 - 10);
let newNode = deserializeNode(clipboardData, pasteX, pasteY);
if(newNode.type === 'BASE') activeBase = newNode;
updateShader();
}
ctxMenu.style.display = 'none';
};
// ==========================================
// 4. GLSL生成エンジン (時間・パーティクル対応)
// ==========================================
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;
`;
let bases = nodes.filter(n => n.type === 'BASE');
bases.forEach((base, idx) => {
let uvX = base.x / width;
let uvY = 1.0 - (base.y / height);
glsl += `\n // --- Base Object ${idx} ---\n {\n`;
glsl += ` vec2 p = st - vec2(${uvX.toFixed(3)}, ${uvY.toFixed(3)});\n`;
glsl += ` p.x *= u_aspect;\n`;
// --- 時間制御 (TIME) ---
let timeNode = base.children.find(c => c.subtype === '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`; // フェードアウト
}
}
// --- フィルター (FILTER: モザイク/ドット化) ---
let filterNode = base.children.find(c => c.subtype === 'FILTER');
if(filterNode && filterNode.vals.pixel > 0) {
// pixelパラメータ(1~50)からグリッド分割数を計算
let pScale = 200.0 / filterNode.vals.pixel;
glsl += ` p = floor(p * ${pScale.toFixed(2)}) / ${pScale.toFixed(2)};\n`;
}
// --- 動き (MOTION) ---
glsl += ` vec2 offset = vec2(0.0);\n`;
let motions = base.children.filter(c => c.subtype.startsWith('MOTION_'));
motions.forEach(m => {
let v = m.vals;
if(m.subtype === 'MOTION_LINEAR') {
glsl += ` offset += vec2(${v.vx.toFixed(2)}, ${-v.vy.toFixed(2)}) * t;\n`;
}
else if(m.subtype === 'MOTION_SINE') {
glsl += ` offset += vec2(sin(t * ${v.freq.toFixed(2)}) * ${v.ampX.toFixed(2)}, cos(t * ${v.freq.toFixed(2)}) * ${v.ampY.toFixed(2)});\n`;
}
else if(m.subtype === 'MOTION_BOUNCE') {
glsl += ` offset.y += abs(sin(t * ${v.speed.toFixed(2)})) * ${v.height.toFixed(2)};\n`;
}
else if(m.subtype === 'MOTION_PARABOLA') {
glsl += ` offset += 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 += vec2(cos(t * ${v.spin.toFixed(2)}), sin(t * ${v.spin.toFixed(2)})) * (${v.radius.toFixed(2)} * t);\n`;
}
});
glsl += ` p -= offset;\n`;
// --- 形 (SHAPE) と パーティクル (PARTICLE) ---
let shape = base.children.find(c => c.subtype === 'SHAPE');
let n_gon = shape ? shape.vals.n : 0;
let size = shape ? shape.vals.size : 0.05;
let particleNode = base.children.find(c => c.subtype === 'PARTICLE');
glsl += ` float d = 999.0;\n`;
if(particleNode) {
let pv = particleNode.vals;
glsl += ` for(int i=0; i<30; i++) {\n`;
glsl += ` if(float(i) >= ${pv.count.toFixed(1)}) break;\n`; // WebGLのループ制約のため固定回数でbreak
glsl += ` vec2 h = hash2(float(i)) * 2.0 - 1.0;\n`; // -1 ~ 1のランダムベクトル
glsl += ` vec2 poff = h * (${pv.spread.toFixed(2)} + t * ${pv.speed.toFixed(2)});\n`; // 拡散ベクトル
glsl += ` vec2 pp = p - poff;\n`;
if(n_gon < 3) glsl += ` d = min(d, length(pp) - ${size.toFixed(3)});\n`;
else glsl += ` d = min(d, sdPolygon(pp, ${n_gon}, ${size.toFixed(3)}));\n`;
glsl += ` }\n`;
} else {
if(n_gon < 3) glsl += ` d = length(p) - ${size.toFixed(3)};\n`;
else glsl += ` d = sdPolygon(p, ${n_gon}, ${size.toFixed(3)});\n`;
}
// --- 色 (COLOR) と 発光 (GLOW) ---
let color = base.children.find(c => c.subtype === 'COLOR');
if(color) glsl += ` vec3 col = hsv2rgb(vec3(${color.vals.h.toFixed(2)}, ${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`;
// フィルターのGlow(発光)を加算
if(filterNode && filterNode.vals.glow > 0) {
glsl += ` mask += ${filterNode.vals.glow.toFixed(3)} * (0.01 / (d*d + 0.001));\n`;
}
// フェードアウト(alpha)を乗算して最終色に加算
glsl += ` finalColor += col * mask * alpha;\n }\n`;
});
glsl += `\n gl_FragColor = vec4(finalColor, 1.0);\n}`;
document.getElementById('log-panel').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;
}
}
// ==========================================
// 5. キャンバス描画ループ
// ==========================================
function animateCanvas() {
ctx.clearRect(0, 0, width, height);
ctx.textAlign = 'center'; ctx.textBaseline = 'middle';
nodes.filter(n => n.type === 'BASE').forEach(b => {
let isOpen = (b === activeBase);
let r = isOpen ? b.radiusOpen : b.radiusClosed;
ctx.beginPath(); ctx.arc(b.x, b.y, r, 0, Math.PI * 2);
ctx.fillStyle = isOpen ? '#1a1a25' : b.color; ctx.fill();
ctx.strokeStyle = isOpen ? '#00ffcc' : '#555'; ctx.lineWidth = isOpen ? 2 : 1;
if(isOpen) ctx.setLineDash([5, 5]); else ctx.setLineDash([]);
ctx.stroke(); ctx.setLineDash([]);
if(!isOpen) { ctx.font = '14px sans-serif'; ctx.fillText(b.emoji, b.x, b.y + 1); }
else {
ctx.beginPath(); ctx.arc(b.x, b.y, 4, 0, Math.PI*2); ctx.fillStyle='#00ffcc'; ctx.fill();
b.children.forEach((child, i) => {
let angle = (i / b.children.length) * Math.PI * 2;
let cx = b.x + Math.cos(angle) * (r * 0.65);
let cy = b.y + Math.sin(angle) * (r * 0.65);
ctx.beginPath(); ctx.arc(cx, cy, child.radius, 0, Math.PI*2);
ctx.fillStyle = child.color; ctx.fill();
ctx.strokeStyle = (child === selectedParam) ? '#fff' : '#222'; ctx.lineWidth = 2; ctx.stroke();
ctx.font = '14px sans-serif'; ctx.fillText(child.emoji, cx, cy + 1);
});
}
});
nodes.filter(n => n.type === 'PARAM' && !n.parent).forEach(p => {
ctx.beginPath(); ctx.arc(p.x, p.y, p.radius, 0, Math.PI * 2);
ctx.fillStyle = p.color; ctx.fill();
ctx.strokeStyle = (p === selectedParam) ? '#fff' : '#222'; ctx.lineWidth = 2; ctx.stroke();
ctx.font = '14px sans-serif'; ctx.fillText(p.emoji, p.x, p.y + 1);
});
requestAnimationFrame(animateCanvas);
}
animateCanvas();
// ==========================================
// 6. 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>

コメント