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>Shader Gummy Maker - With Parameters</title>
<style>
body { margin: 0; display: flex; height: 100vh; background: #111; color: #fff; font-family: 'Segoe UI', sans-serif; overflow: hidden; }
#editor { width: 50%; display: flex; flex-direction: column; border-right: 4px solid #333; position: relative; }
#toolbar { padding: 10px; background: #222; display: flex; gap: 10px; flex-wrap: wrap; z-index: 10; box-shadow: 0 4px 10px rgba(0,0,0,0.5);}
.btn-group { display: flex; gap: 5px; border: 2px solid #444; padding: 5px; border-radius: 8px; background: #1a1a1a;}
.btn-group span { font-size: 12px; font-weight: bold; color: #aaa; writing-mode: vertical-lr; text-align: center;}
button { font-size: 24px; cursor: pointer; background: #333; border: 2px solid #555; border-radius: 8px; transition: 0.1s; padding: 5px 10px;}
button:hover { background: #555; transform: scale(1.1); }
button:active { transform: scale(0.9); }
#gummy-canvas { flex-grow: 1; background: #1a1a1a; cursor: grab; touch-action: none; }
#gummy-canvas:active { cursor: grabbing; }
#preview { width: 50%; position: relative; background: #000; }
#log-panel { position: absolute; bottom: 0; left: 0; width: 100%; height: 180px; background: rgba(0,0,0,0.85); color: #00ffcc; font-family: 'Courier New', Courier, monospace; font-size: 13px; padding: 10px; box-sizing: border-box; overflow-y: auto; pointer-events: none; border-top: 2px solid #333; }
/* --- パラメータ調整用ポップアップパネル --- */
#param-panel {
position: absolute; display: none; background: rgba(20, 20, 30, 0.95);
border: 2px solid #00ffcc; border-radius: 12px; padding: 15px;
color: white; z-index: 100; pointer-events: auto;
box-shadow: 0 10px 20px rgba(0,0,0,0.5); width: 220px;
transform: translate(-50%, -120%); /* グミの上部に表示 */
}
#param-panel::after {
content: ''; position: absolute; bottom: -10px; left: 50%; transform: translateX(-50%);
border-width: 10px 10px 0; border-style: solid; border-color: #00ffcc transparent transparent transparent;
}
#param-title { font-weight: bold; margin-bottom: 10px; border-bottom: 1px solid #444; padding-bottom: 5px; text-align: center; font-size: 14px;}
.param-row { display: flex; flex-direction: column; margin-bottom: 10px; }
.param-row label { font-size: 11px; color: #aaa; margin-bottom: 3px; display: flex; justify-content: space-between;}
.param-row input[type="range"] { width: 100%; cursor: pointer; accent-color: #00ffcc; }
.param-row input[type="color"] { width: 100%; height: 30px; cursor: pointer; border: none; border-radius: 4px; padding: 0;}
</style>
</head>
<body>
<div id="editor">
<div id="toolbar">
<div class="btn-group"><span>形</span>
<button onclick="addGummy('CIRCLE')" title="まる">🔴</button>
<button onclick="addGummy('RING')" title="リング">🍩</button>
</div>
<div class="btn-group"><span>色</span>
<button onclick="addGummy('SOLID')" title="べたぬり">🖍️</button>
<button onclick="addGummy('RAINBOW')" title="虹色">🌈</button>
</div>
<div class="btn-group"><span>変形</span>
<button onclick="addGummy('WAVE')" title="なみだつ">🌊</button>
<button onclick="addGummy('TWIST')" title="うずまき">🌪️</button>
</div>
<button onclick="clearGummies()" style="margin-left:auto; font-size:14px;">🗑️ リセット</button>
</div>
<canvas id="gummy-canvas"></canvas>
<div id="param-panel">
<div id="param-title">調整</div>
<div id="param-content"></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';
// 16進数カラーをGLSL用のRGBに変換するヘルパー
function hexToRgb(hex) {
let bigint = parseInt(hex.replace('#',''), 16);
return { r: ((bigint >> 16) & 255) / 255.0, g: ((bigint >> 8) & 255) / 255.0, b: (bigint & 255) / 255.0 };
}
// ==========================================
// 1. パラメータ定義とコードジェネレータ
// ==========================================
const TYPES = {
// --- 形(SHAPE) ---
CIRCLE: { cat: 'SHAPE', emoji: '🔴', color: '#ff5555',
params: { radius: { min: 0.05, max: 0.8, def: 0.25, step: 0.01, label: '大きさ' } },
getCode: (g) => `d = min(d, length(st - 0.5) - ${g.vals.radius.toFixed(3)});`
},
RING: { cat: 'SHAPE', emoji: '🍩', color: '#ffaa55',
params: { radius: { min: 0.1, max: 0.8, def: 0.3, step: 0.01, label: '円の大きさ' }, thick: { min: 0.01, max: 0.2, def: 0.05, step: 0.01, label: '線の太さ' } },
getCode: (g) => `d = min(d, abs(length(st - 0.5) - ${g.vals.radius.toFixed(3)}) - ${g.vals.thick.toFixed(3)});`
},
// --- 色(COLOR) ---
SOLID: { cat: 'COLOR', emoji: '🖍️', color: '#ffaaaa',
params: { color: { type: 'color', def: '#ff3333', label: 'えらぶ色' } },
getCode: (g) => {
const rgb = hexToRgb(g.vals.color);
return `col = vec3(${rgb.r.toFixed(3)}, ${rgb.g.toFixed(3)}, ${rgb.b.toFixed(3)});`;
}
},
RAINBOW: { cat: 'COLOR', emoji: '🌈', color: '#dddddd',
params: { speed: { min: 0.1, max: 10.0, def: 2.0, step: 0.1, label: '変化の速さ' } },
getCode: (g) => `col = 0.5 + 0.5 * cos(u_time * ${g.vals.speed.toFixed(3)} + st.xyx + vec3(0,2,4));`
},
// --- 変形(DISTORT) ---
WAVE: { cat: 'DISTORT', emoji: '🌊', color: '#55ffff',
params: { power: { min: 0.01, max: 0.5, def: 0.05, step: 0.01, label: '波の高さ' }, freq: { min: 1.0, max: 30.0, def: 10.0, step: 0.5, label: '波の数' }, speed: { min: 0.0, max: 20.0, def: 5.0, step: 0.1, label: '波の速さ' } },
getCode: (g) => `st.y += sin(st.x * ${g.vals.freq.toFixed(3)} + u_time * ${g.vals.speed.toFixed(3)}) * ${g.vals.power.toFixed(3)};`
},
TWIST: { cat: 'DISTORT', emoji: '🌪️', color: '#aa55ff',
params: { amount: { min: 0.0, max: 20.0, def: 5.0, step: 0.1, label: 'ひねりの強さ' } },
getCode: (g) => {
return `
float angle = ${g.vals.amount.toFixed(3)} * length(st - 0.5);
float s = sin(angle), c = cos(angle);
st -= 0.5;
st = vec2(st.x * c - st.y * s, st.x * s + st.y * c);
st += 0.5;`;
}
}
};
// ==========================================
// 2. キャンバスとUIロジック
// ==========================================
const canvas = document.getElementById('gummy-canvas');
const ctx = canvas.getContext('2d');
const paramPanel = document.getElementById('param-panel');
let width, height;
let gummies = [];
let draggedGummy = null;
let selectedGummy = null; // スライダーを表示しているグミ
const GUMMY_RADIUS = 32;
const SNAP_DIST = 90;
function resize() {
width = canvas.parentElement.clientWidth;
height = canvas.parentElement.clientHeight - document.getElementById('toolbar').clientHeight;
canvas.width = width; canvas.height = height;
}
window.addEventListener('resize', resize); resize();
window.addGummy = (typeKey) => {
const template = TYPES[typeKey];
const vals = {};
// 初期値をセット
if(template.params) {
for(let k in template.params) vals[k] = template.params[k].def;
}
gummies.push({
id: Math.random(), type: typeKey, ...template, vals: vals,
x: width/2 + (Math.random()-0.5)*50, y: height/2 + (Math.random()-0.5)*50,
vx: 0, vy: 0, clusterId: -1
});
updateShader();
};
window.clearGummies = () => { gummies = []; closeParamPanel(); updateShader(); };
// --- パラメータパネルのUI構築 ---
function openParamPanel(gummy) {
selectedGummy = gummy;
if (!gummy.params || Object.keys(gummy.params).length === 0) { closeParamPanel(); return; }
paramPanel.style.display = 'block';
document.getElementById('param-title').innerText = `${gummy.emoji} の調整`;
const content = document.getElementById('param-content');
content.innerHTML = '';
for(let key in gummy.params) {
let pDef = gummy.params[key];
let row = document.createElement('div'); row.className = 'param-row';
let labelDiv = document.createElement('label');
let nameSpan = document.createElement('span'); nameSpan.innerText = pDef.label;
let valSpan = document.createElement('span');
valSpan.innerText = pDef.type === 'color' ? '' : gummy.vals[key];
labelDiv.appendChild(nameSpan); labelDiv.appendChild(valSpan);
let input = document.createElement('input');
if(pDef.type === 'color') {
input.type = 'color'; input.value = gummy.vals[key];
} else {
input.type = 'range';
input.min = pDef.min; input.max = pDef.max; input.step = pDef.step || 0.01;
input.value = gummy.vals[key];
}
input.addEventListener('input', (e) => {
let val = pDef.type === 'color' ? e.target.value : parseFloat(e.target.value);
gummy.vals[key] = val;
if(pDef.type !== 'color') valSpan.innerText = val.toFixed(2);
updateShader(calculateClusters()); // リアルタイム更新
});
row.appendChild(labelDiv); row.appendChild(input);
content.appendChild(row);
}
updateParamPanelPos();
}
function closeParamPanel() {
paramPanel.style.display = 'none';
selectedGummy = null;
}
function updateParamPanelPos() {
if(!selectedGummy || paramPanel.style.display === 'none') return;
// グミの上に吹き出しとして表示
paramPanel.style.left = selectedGummy.x + 'px';
paramPanel.style.top = (selectedGummy.y - GUMMY_RADIUS - 10) + 'px';
}
const getPos = (e) => {
const rect = canvas.getBoundingClientRect();
const cx = e.touches ? e.touches[0].clientX : e.clientX;
const cy = e.touches ? e.touches[0].clientY : e.clientY;
return { x: cx - rect.left, y: cy - rect.top };
};
canvas.addEventListener('pointerdown', (e) => {
canvas.setPointerCapture(e.pointerId);
const pos = getPos(e);
draggedGummy = gummies.find(g => Math.hypot(g.x - pos.x, g.y - pos.y) < GUMMY_RADIUS + 10);
if(draggedGummy) {
draggedGummy.vx = 0; draggedGummy.vy = 0;
openParamPanel(draggedGummy); // タップしたらパネルを開く
} else {
closeParamPanel(); // 何もないところをタップしたら閉じる
}
});
canvas.addEventListener('pointermove', (e) => {
if(!draggedGummy) return;
const pos = getPos(e);
draggedGummy.x = pos.x; draggedGummy.y = pos.y;
updateParamPanelPos();
});
canvas.addEventListener('pointerup', () => { draggedGummy = null; });
// --- クラスタリング処理 ---
let lastGraphHash = "";
function calculateClusters() {
gummies.forEach(g => g.clusterId = -1);
let cId = 0, clusters = [];
for(let i=0; i<gummies.length; i++) {
if(gummies[i].clusterId !== -1) continue;
let q = [gummies[i]]; gummies[i].clusterId = cId;
let currentCluster = [];
while(q.length > 0) {
let curr = q.shift(); currentCluster.push(curr);
for(let j=0; j<gummies.length; j++) {
if(gummies[j].clusterId === -1 && Math.hypot(curr.x - gummies[j].x, curr.y - gummies[j].y) < SNAP_DIST) {
gummies[j].clusterId = cId; q.push(gummies[j]);
}
}
}
clusters.push(currentCluster); cId++;
}
// 繋がり方のハッシュを計算(位置が変わっただけではシェーダーを再構築しない)
const newHash = clusters.map(c => c.map(g => g.id).sort().join('-')).sort().join('|');
if(newHash !== lastGraphHash) { lastGraphHash = newHash; updateShader(clusters); }
return clusters;
}
// ==========================================
// 3. GLSLシェーダーの動的生成
// ==========================================
function updateShader(clusters = calculateClusters()) {
let glsl = `
uniform float u_time;
varying vec2 vUv;
void main() {
vec3 finalColor = vec3(0.0);
`;
let validClusters = 0;
clusters.forEach((cluster, idx) => {
let shapes = cluster.filter(g => g.cat === 'SHAPE');
if(shapes.length === 0) return; // 形がない塊は描画しない
let colors = cluster.filter(g => g.cat === 'COLOR');
let distorts = cluster.filter(g => g.cat === 'DISTORT');
glsl += `\n // --- 塊 ${idx} --- \n`;
glsl += ` {\n`;
glsl += ` vec2 st = vUv;\n`;
glsl += ` vec3 col = vec3(1.0);\n`;
glsl += ` float d = 999.0;\n\n`;
if(distorts.length > 0) glsl += ` // 変形\n`;
distorts.forEach(g => { glsl += ` ${g.getCode(g)}\n`; });
glsl += `\n // 形\n`;
shapes.forEach(g => { glsl += ` ${g.getCode(g)}\n`; });
if(colors.length > 0) glsl += `\n // 色\n`;
colors.forEach(g => { glsl += ` ${g.getCode(g)}\n`; });
// 輪郭を少しぼかして、魔法のオーラっぽくする
glsl += `\n float mask = smoothstep(0.02, 0.0, d);\n`;
glsl += ` finalColor = max(finalColor, col * mask);\n`;
glsl += ` }\n`;
validClusters++;
});
glsl += `\n gl_FragColor = vec4(finalColor, 1.0);\n}`;
document.getElementById('log-panel').innerText = validClusters > 0 ? glsl.trim() : "// 「形(🔴等)」のグミを置いてね!\n// 繋げたり、タップしてスライダーを動かしてね。";
if(validClusters > 0 && mesh && mesh.material) {
const newMaterial = new THREE.ShaderMaterial({
vertexShader: `varying vec2 vUv; void main() { vUv = uv; gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0); }`,
fragmentShader: glsl,
uniforms: uniforms
});
mesh.material.dispose();
mesh.material = newMaterial;
} else if (mesh) {
mesh.material = new THREE.MeshBasicMaterial({color: 0x000000});
}
}
// ==========================================
// 4. アニメーション&描画ループ
// ==========================================
function animateCanvas() {
ctx.clearRect(0, 0, width, height);
const clusters = calculateClusters();
for(let i=0; i<gummies.length; i++) {
let g1 = gummies[i];
if(g1 !== draggedGummy) {
g1.x += g1.vx; g1.y += g1.vy;
g1.vx *= 0.8; g1.vy *= 0.8;
if(g1.x < GUMMY_RADIUS) g1.x = GUMMY_RADIUS;
if(g1.x > width - GUMMY_RADIUS) g1.x = width - GUMMY_RADIUS;
if(g1.y < GUMMY_RADIUS) g1.y = GUMMY_RADIUS;
if(g1.y > height - GUMMY_RADIUS) g1.y = height - GUMMY_RADIUS;
}
for(let j=i+1; j<gummies.length; j++) {
let g2 = gummies[j];
if(g1.clusterId === g2.clusterId) {
let dx = g2.x - g1.x, dy = g2.y - g1.y;
let dist = Math.hypot(dx, dy);
let targetDist = GUMMY_RADIUS * 1.8;
if(dist > targetDist) {
let force = (dist - targetDist) * 0.05;
if(g1 !== draggedGummy) { g1.vx += (dx/dist)*force; g1.vy += (dy/dist)*force; }
if(g2 !== draggedGummy) { g2.vx -= (dx/dist)*force; g2.vy -= (dy/dist)*force; }
}
}
}
}
// スライム状の繋がり描画
ctx.lineWidth = GUMMY_RADIUS * 1.6;
ctx.lineCap = 'round'; ctx.lineJoin = 'round';
clusters.forEach(cluster => {
if(cluster.length < 2) return;
ctx.strokeStyle = 'rgba(255, 255, 255, 0.15)';
ctx.beginPath();
ctx.moveTo(cluster[0].x, cluster[0].y);
for(let i=1; i<cluster.length; i++) ctx.lineTo(cluster[i].x, cluster[i].y);
ctx.stroke();
});
// グミ描画
ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; ctx.font = '28px sans-serif';
gummies.forEach(g => {
ctx.beginPath(); ctx.arc(g.x, g.y, GUMMY_RADIUS, 0, Math.PI * 2);
ctx.fillStyle = g.color; ctx.fill();
// 選択されているグミは枠を光らせる
ctx.strokeStyle = (g === selectedGummy) ? '#00ffcc' : '#fff';
ctx.lineWidth = (g === selectedGummy) ? 4 : 2;
ctx.stroke();
ctx.fillText(g.emoji, g.x, g.y + 2);
});
requestAnimationFrame(animateCanvas);
}
animateCanvas();
// ==========================================
// 5. 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 });
renderer.setSize(previewContainer.clientWidth, previewContainer.clientHeight);
previewContainer.appendChild(renderer.domElement);
const uniforms = { u_time: { value: 0.0 } };
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();
function animateThree() {
requestAnimationFrame(animateThree);
uniforms.u_time.value = clock.getElapsedTime();
renderer.render(scene, camera);
}
animateThree();
</script>
</body>
</html>

コメント