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>

いいなと思ったら応援しよう!

コメント

コメントするには、 ログイン または 会員登録 をお願いします。
小ぶりなプログラムを試しに作っているんですが、 ここではその説明書きをしていこうと思います。 こういう機能をつけてみてほしいだとかいった要望があれば コメント欄に書いてみて下さい。 ひまをみて対応します。
GLSLシェーダーで遊ぶ「Glsl Viewer」|古井和雄
word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word word

mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1
mmMwWLliI0fiflO&1