Last active
January 29, 2026 06:42
-
-
Save kagaya/db6e9998dbf64ea3b2a99fe17bc87f6c to your computer and use it in GitHub Desktop.
BTW sandpile
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="ja"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0"> | |
| <title>3D Sandpile Simulation</title> | |
| <style> | |
| body { margin: 0; overflow: hidden; background-color: #111; font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; } | |
| #canvas-container { width: 100vw; height: 100vh; display: block; } | |
| /* UI Overlay */ | |
| #ui-panel { | |
| position: absolute; | |
| top: 10px; | |
| left: 10px; | |
| background: rgba(0, 0, 0, 0.8); | |
| color: white; | |
| padding: 15px; | |
| border-radius: 8px; | |
| border: 1px solid #444; | |
| width: 300px; /* Fixed width for graph consistency */ | |
| backdrop-filter: blur(5px); | |
| user-select: none; | |
| } | |
| h1 { margin: 0 0 10px 0; font-size: 1.2rem; color: #ffcc00; } | |
| .control-group { margin-bottom: 12px; } | |
| label { display: block; font-size: 0.85rem; margin-bottom: 4px; color: #ccc; } | |
| button { | |
| background: #333; | |
| color: white; | |
| border: 1px solid #666; | |
| padding: 6px 12px; | |
| border-radius: 4px; | |
| cursor: pointer; | |
| font-size: 0.85rem; | |
| transition: background 0.2s, color 0.2s; | |
| margin-right: 5px; | |
| margin-bottom: 5px; | |
| } | |
| button:hover { background: #555; } | |
| button.active { background: #ffcc00; color: black; border-color: #ffcc00; } | |
| button:disabled { opacity: 0.5; cursor: not-allowed; } | |
| /* Speed Input Styles */ | |
| .speed-controls { | |
| display: flex; | |
| align-items: center; | |
| gap: 10px; | |
| transition: opacity 0.3s; | |
| } | |
| .speed-controls.disabled { | |
| opacity: 0.3; | |
| pointer-events: none; | |
| } | |
| input[type="range"] { flex-grow: 1; cursor: pointer; } | |
| input[type="number"] { | |
| width: 80px; | |
| background: #222; | |
| border: 1px solid #555; | |
| color: #ffcc00; | |
| padding: 4px; | |
| border-radius: 4px; | |
| font-family: monospace; | |
| } | |
| .stats { font-size: 0.75rem; color: #888; margin-top: 10px; border-top: 1px solid #444; padding-top: 10px; } | |
| /* Graph Canvas */ | |
| .graph-container { | |
| margin-top: 10px; | |
| background: rgba(0,0,0,0.5); | |
| border: 1px solid #333; | |
| border-radius: 4px; | |
| padding: 5px; | |
| } | |
| canvas#graph-canvas { | |
| width: 100%; | |
| height: 80px; | |
| display: block; | |
| } | |
| /* Color Legend */ | |
| .legend { display: flex; gap: 5px; margin-top: 5px; } | |
| .legend-item { width: 20px; height: 20px; border-radius: 3px; display: flex; align-items: center; justify-content: center; font-size: 10px; font-weight: bold; color: rgba(0,0,0,0.7); } | |
| /* Fixed credit (bottom-right) */ | |
| #credit-fixed { | |
| position: fixed; | |
| right: 14px; | |
| bottom: 14px; | |
| z-index: 9999; | |
| background: rgba(0,0,0,0.65); | |
| border: 1px solid #444; | |
| border-radius: 10px; | |
| padding: 10px 12px; | |
| color: #ddd; | |
| font-size: 12px; | |
| line-height: 1.35; | |
| backdrop-filter: blur(6px); | |
| -webkit-backdrop-filter: blur(6px); | |
| user-select: none; | |
| pointer-events: auto; /* リンクをクリックできるように */ | |
| max-width: min(420px, calc(100vw - 28px)); | |
| box-shadow: 0 6px 24px rgba(0,0,0,0.35); | |
| } | |
| #credit-fixed strong { color: #fff; } | |
| #credit-fixed a { | |
| color: #ffcc00; | |
| text-decoration: none; | |
| word-break: break-all; | |
| } | |
| #credit-fixed a:hover { text-decoration: underline; } | |
| /* Mobile adjustment */ | |
| @media (max-width: 600px) { | |
| #ui-panel { | |
| width: calc(100% - 40px); | |
| max-width: none; | |
| bottom: 10px; | |
| top: auto; | |
| left: 10px; | |
| } | |
| h1 { font-size: 1rem; } | |
| #credit-fixed { | |
| font-size: 11px; | |
| right: 10px; | |
| bottom: 10px; | |
| padding: 8px 10px; | |
| } | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="canvas-container"></div> | |
| <!-- ✅ Fixed credit overlay --> | |
| <div id="credit-fixed"> | |
| Written by <strong>Katsushi Kagaya</strong><br> | |
| Reference: | |
| <a href="https://gist.github.com/kagaya/db6e9998dbf64ea3b2a99fe17bc87f6c" | |
| target="_blank" rel="noopener noreferrer"> | |
| https://gist.github.com/kagaya/db6e9998dbf64ea3b2a99fe17bc87f6c | |
| </a> | |
| </div> | |
| <div id="ui-panel"> | |
| <h1>3D Sandpile Model</h1> | |
| <div class="control-group"> | |
| <label>操作 (Controls)</label> | |
| <button id="btn-pause">停止/再開</button> | |
| <button id="btn-reset">リセット</button> | |
| </div> | |
| <div class="control-group"> | |
| <label>特殊モード</label> | |
| <button id="btn-sequential">順次投入 (雪崩待機)</button> | |
| </div> | |
| <div class="control-group"> | |
| <label>砂の投入速度 (個/フレーム)</label> | |
| <div class="speed-controls" id="speed-controls-wrapper"> | |
| <input type="range" id="input-speed-slider" min="-5" max="2.3" step="0.1" value="1.7"> | |
| <input type="number" id="input-speed-val" value="50" min="0" step="0.0001"> | |
| </div> | |
| </div> | |
| <div class="control-group"> | |
| <label>投入場所</label> | |
| <button id="btn-mode-center" class="active">中央</button> | |
| <button id="btn-mode-random">ランダム</button> | |
| </div> | |
| <div class="control-group"> | |
| <label>砂の高さと色</label> | |
| <div class="legend"> | |
| <div class="legend-item" style="background:#111; border:1px solid #333; color:#fff;">0</div> | |
| <div class="legend-item" style="background:#0044ff;">1</div> | |
| <div class="legend-item" style="background:#00ccff;">2</div> | |
| <div class="legend-item" style="background:#ffff00;">3</div> | |
| <div class="legend-item" style="background:#ff3300;">4+</div> | |
| </div> | |
| </div> | |
| <div class="stats"> | |
| Total Grains: <span id="stat-grains">0</span><br> | |
| Avalanches: <span id="stat-avalanches">0</span> | |
| </div> | |
| <div class="graph-container"> | |
| <label style="font-size: 0.7rem; margin-bottom: 2px;">Topples / Frame</label> | |
| <canvas id="graph-canvas"></canvas> | |
| </div> | |
| </div> | |
| <!-- Three.js and OrbitControls from CDN --> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.128.0/examples/js/controls/OrbitControls.js"></script> | |
| <script> | |
| /** | |
| * Configuration & State | |
| */ | |
| const CONFIG = { | |
| gridSize: 101, // Must be odd for perfect center | |
| boxSize: 0.9, // Size of each column (leaving gap) | |
| maxHeight: 15, // Visual clamp for height | |
| colors: [ | |
| new THREE.Color(0x111111), // 0 grains | |
| new THREE.Color(0x0044ff), // 1 grain | |
| new THREE.Color(0x00ccff), // 2 grains | |
| new THREE.Color(0xffff00), // 3 grains | |
| new THREE.Color(0xff3300) // 4+ grains (Toppling) | |
| ] | |
| }; | |
| let state = { | |
| grid: new Uint8Array(CONFIG.gridSize * CONFIG.gridSize), | |
| active: true, | |
| speed: 50, | |
| pendingSand: 0, | |
| mode: 'center', // 'center' or 'random' | |
| sequentialMode: false, // New mode: wait for avalanches to finish | |
| totalGrains: 0, | |
| totalAvalanches: 0, | |
| // Graph History | |
| history: new Array(300).fill(0), | |
| graphMaxY: 10 | |
| }; | |
| /** | |
| * Three.js Setup | |
| */ | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x050505); | |
| scene.fog = new THREE.FogExp2(0x050505, 0.015); | |
| const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 1000); | |
| camera.position.set(0, 80, 80); | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = true; | |
| document.getElementById('canvas-container').appendChild(renderer.domElement); | |
| // Controls | |
| const controls = new THREE.OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| controls.autoRotate = true; | |
| controls.autoRotateSpeed = 0.5; | |
| // Lights | |
| const ambientLight = new THREE.AmbientLight(0x404040); | |
| scene.add(ambientLight); | |
| const dirLight = new THREE.DirectionalLight(0xffffff, 1); | |
| dirLight.position.set(50, 100, 50); | |
| dirLight.castShadow = true; | |
| dirLight.shadow.camera.left = -60; | |
| dirLight.shadow.camera.right = 60; | |
| dirLight.shadow.camera.top = 60; | |
| dirLight.shadow.camera.bottom = -60; | |
| dirLight.shadow.mapSize.width = 2048; | |
| dirLight.shadow.mapSize.height = 2048; | |
| scene.add(dirLight); | |
| // Point lights for glow effect | |
| const pointLight1 = new THREE.PointLight(0x00ccff, 0.5, 100); | |
| pointLight1.position.set(0, 20, 0); | |
| scene.add(pointLight1); | |
| /** | |
| * Instanced Mesh Setup | |
| */ | |
| const geometry = new THREE.BoxGeometry(CONFIG.boxSize, 1, CONFIG.boxSize); | |
| geometry.translate(0, 0.5, 0); | |
| const material = new THREE.MeshStandardMaterial({ | |
| color: 0xffffff, | |
| roughness: 0.3, | |
| metalness: 0.1 | |
| }); | |
| const count = CONFIG.gridSize * CONFIG.gridSize; | |
| const mesh = new THREE.InstancedMesh(geometry, material, count); | |
| mesh.instanceMatrix.setUsage(THREE.DynamicDrawUsage); | |
| mesh.castShadow = true; | |
| mesh.receiveShadow = true; | |
| scene.add(mesh); | |
| const dummy = new THREE.Object3D(); | |
| /** | |
| * Simulation Logic | |
| */ | |
| function getIndex(x, y) { | |
| if (x < 0 || x >= CONFIG.gridSize || y < 0 || y >= CONFIG.gridSize) return -1; | |
| return y * CONFIG.gridSize + x; | |
| } | |
| function addSand(x, y) { | |
| const idx = getIndex(x, y); | |
| if (idx !== -1) { | |
| state.grid[idx]++; | |
| state.totalGrains++; | |
| } | |
| } | |
| function updateGrid() { | |
| let frameAvalanches = 0; | |
| // 1. Identify unstable cells FIRST (to check stability) | |
| let unstable = []; | |
| for (let i = 0; i < count; i++) { | |
| if (state.grid[i] >= 4) { | |
| unstable.push(i); | |
| } | |
| } | |
| const isStable = (unstable.length === 0); | |
| // 2. Add Sand Phase | |
| if (state.active) { | |
| if (state.sequentialMode) { | |
| // SEQUENTIAL MODE: Only add 1 grain if grid is completely stable | |
| if (isStable) { | |
| let tx, ty; | |
| if (state.mode === 'center') { | |
| tx = Math.floor(CONFIG.gridSize / 2); | |
| ty = Math.floor(CONFIG.gridSize / 2); | |
| } else { | |
| tx = Math.floor(Math.random() * CONFIG.gridSize); | |
| ty = Math.floor(Math.random() * CONFIG.gridSize); | |
| } | |
| addSand(tx, ty); | |
| } | |
| } else if (state.speed > 0) { | |
| // NORMAL MODE: Accumulator based speed | |
| state.pendingSand += state.speed; | |
| let grainsToAdd = Math.floor(state.pendingSand); | |
| if (grainsToAdd > 0) { | |
| state.pendingSand -= grainsToAdd; | |
| const MAX_ITERATIONS = 10000; | |
| if (grainsToAdd > MAX_ITERATIONS) grainsToAdd = MAX_ITERATIONS; | |
| for (let i = 0; i < grainsToAdd; i++) { | |
| let tx, ty; | |
| if (state.mode === 'center') { | |
| tx = Math.floor(CONFIG.gridSize / 2); | |
| ty = Math.floor(CONFIG.gridSize / 2); | |
| } else { | |
| tx = Math.floor(Math.random() * CONFIG.gridSize); | |
| ty = Math.floor(Math.random() * CONFIG.gridSize); | |
| } | |
| addSand(tx, ty); | |
| } | |
| } | |
| } | |
| } | |
| // 3. Toppling Phase (The Physics) | |
| for (let i = 0; i < unstable.length; i++) { | |
| const idx = unstable[i]; | |
| if (state.grid[idx] >= 4) { | |
| state.grid[idx] -= 4; | |
| state.totalAvalanches++; | |
| frameAvalanches++; | |
| const y = Math.floor(idx / CONFIG.gridSize); | |
| const x = idx % CONFIG.gridSize; | |
| const neighbors = [ | |
| getIndex(x + 1, y), | |
| getIndex(x - 1, y), | |
| getIndex(x, y + 1), | |
| getIndex(x, y - 1) | |
| ]; | |
| for (let nIdx of neighbors) { | |
| if (nIdx !== -1) { | |
| state.grid[nIdx]++; | |
| } | |
| } | |
| } | |
| } | |
| return frameAvalanches; | |
| } | |
| /** | |
| * Visualization Update | |
| */ | |
| function updateVisuals() { | |
| const offset = (CONFIG.gridSize - 1) / 2; | |
| for (let y = 0; y < CONFIG.gridSize; y++) { | |
| for (let x = 0; x < CONFIG.gridSize; x++) { | |
| const i = getIndex(x, y); | |
| const grains = state.grid[i]; | |
| // Position centered around 0,0 | |
| dummy.position.set(x - offset, 0, y - offset); | |
| // Height logic | |
| let height = grains; | |
| if (height === 0) height = 0.1; | |
| else height = Math.min(height, CONFIG.maxHeight); | |
| dummy.scale.set(1, height, 1); | |
| dummy.updateMatrix(); | |
| mesh.setMatrixAt(i, dummy.matrix); | |
| // Color logic | |
| let colIndex = grains; | |
| if (colIndex >= 4) colIndex = 4; | |
| mesh.setColorAt(i, CONFIG.colors[colIndex]); | |
| } | |
| } | |
| mesh.instanceMatrix.needsUpdate = true; | |
| if (mesh.instanceColor) mesh.instanceColor.needsUpdate = true; | |
| } | |
| /** | |
| * Graph Drawing Logic | |
| */ | |
| const graphCanvas = document.getElementById('graph-canvas'); | |
| const graphCtx = graphCanvas.getContext('2d'); | |
| function resizeGraph() { | |
| const rect = graphCanvas.getBoundingClientRect(); | |
| graphCanvas.width = rect.width; | |
| graphCanvas.height = rect.height; | |
| } | |
| resizeGraph(); | |
| window.addEventListener('resize', resizeGraph); | |
| function drawGraph() { | |
| const width = graphCanvas.width; | |
| const height = graphCanvas.height; | |
| graphCtx.clearRect(0, 0, width, height); | |
| let currentDataMax = 0; | |
| for (let val of state.history) { | |
| if (val > currentDataMax) currentDataMax = val; | |
| } | |
| // Smooth scaling | |
| if (currentDataMax > state.graphMaxY) { | |
| state.graphMaxY = currentDataMax; | |
| } else { | |
| state.graphMaxY = state.graphMaxY * 0.99; | |
| if (state.graphMaxY < currentDataMax) state.graphMaxY = currentDataMax; | |
| if (state.graphMaxY < 10) state.graphMaxY = 10; | |
| } | |
| const maxVal = state.graphMaxY; | |
| graphCtx.beginPath(); | |
| graphCtx.strokeStyle = '#ffcc00'; | |
| graphCtx.lineWidth = 1.5; | |
| const stepX = width / (state.history.length - 1); | |
| for (let i = 0; i < state.history.length; i++) { | |
| const val = state.history[i]; | |
| const x = i * stepX; | |
| const y = height - (val / maxVal) * (height * 0.9) - 2; | |
| if (i === 0) graphCtx.moveTo(x, y); | |
| else graphCtx.lineTo(x, y); | |
| } | |
| graphCtx.stroke(); | |
| graphCtx.fillStyle = '#888'; | |
| graphCtx.font = '10px sans-serif'; | |
| graphCtx.fillText(Math.round(maxVal), 2, 10); | |
| } | |
| /** | |
| * UI & Interaction | |
| */ | |
| function updateStats() { | |
| document.getElementById('stat-grains').textContent = state.totalGrains; | |
| document.getElementById('stat-avalanches').textContent = state.totalAvalanches; | |
| } | |
| document.getElementById('btn-pause').addEventListener('click', () => { | |
| state.active = !state.active; | |
| controls.autoRotate = state.active; | |
| }); | |
| document.getElementById('btn-reset').addEventListener('click', () => { | |
| state.grid.fill(0); | |
| state.totalGrains = 0; | |
| state.totalAvalanches = 0; | |
| state.pendingSand = 0; | |
| state.history.fill(0); | |
| state.graphMaxY = 10; | |
| updateStats(); | |
| updateVisuals(); | |
| }); | |
| // Sequential Mode Toggle | |
| document.getElementById('btn-sequential').addEventListener('click', (e) => { | |
| state.sequentialMode = !state.sequentialMode; | |
| const btn = e.target; | |
| const speedControls = document.getElementById('speed-controls-wrapper'); | |
| if (state.sequentialMode) { | |
| btn.classList.add('active'); | |
| speedControls.classList.add('disabled'); | |
| } else { | |
| btn.classList.remove('active'); | |
| speedControls.classList.remove('disabled'); | |
| } | |
| }); | |
| // Speed Control Logic | |
| const speedSlider = document.getElementById('input-speed-slider'); | |
| const speedInput = document.getElementById('input-speed-val'); | |
| function updateSpeedFromSlider() { | |
| const logVal = parseFloat(speedSlider.value); | |
| if (logVal <= -4.9) { | |
| state.speed = 0; | |
| speedInput.value = 0; | |
| return; | |
| } | |
| const realVal = Math.pow(10, logVal); | |
| let displayVal; | |
| if (realVal < 0.01) displayVal = realVal.toExponential(2); | |
| else if (realVal < 1) displayVal = realVal.toFixed(4); | |
| else displayVal = Math.round(realVal); | |
| state.speed = realVal; | |
| speedInput.value = displayVal; | |
| } | |
| function updateSpeedFromInput() { | |
| let val = parseFloat(speedInput.value); | |
| if (isNaN(val) || val < 0) val = 0; | |
| state.speed = val; | |
| if (val <= 0) { | |
| speedSlider.value = -5; | |
| } else if (val > 0) { | |
| let logVal = Math.log10(val); | |
| if (logVal < -4) logVal = -4; | |
| speedSlider.value = logVal; | |
| } | |
| } | |
| speedSlider.addEventListener('input', updateSpeedFromSlider); | |
| speedInput.addEventListener('change', updateSpeedFromInput); | |
| document.getElementById('btn-mode-center').addEventListener('click', () => { | |
| state.mode = 'center'; | |
| document.getElementById('btn-mode-center').classList.add('active'); | |
| document.getElementById('btn-mode-random').classList.remove('active'); | |
| }); | |
| document.getElementById('btn-mode-random').addEventListener('click', () => { | |
| state.mode = 'random'; | |
| document.getElementById('btn-mode-random').classList.add('active'); | |
| document.getElementById('btn-mode-center').classList.remove('active'); | |
| }); | |
| // Click to add sand manually | |
| const raycaster = new THREE.Raycaster(); | |
| const mouse = new THREE.Vector2(); | |
| window.addEventListener('pointerdown', (event) => { | |
| // クレジットやUI上のクリックは無視 | |
| if (event.target.closest('#ui-panel') || event.target.closest('#credit-fixed')) return; | |
| mouse.x = (event.clientX / window.innerWidth) * 2 - 1; | |
| mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; | |
| raycaster.setFromCamera(mouse, camera); | |
| const intersects = raycaster.intersectObject(mesh); | |
| if (intersects.length > 0) { | |
| const instanceId = intersects[0].instanceId; | |
| if (instanceId !== undefined) { | |
| state.grid[instanceId] += 4; | |
| state.totalGrains += 4; | |
| } | |
| } | |
| }); | |
| /** | |
| * Animation Loop | |
| */ | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| const frameAvalanches = updateGrid(); | |
| state.history.push(frameAvalanches); | |
| state.history.shift(); | |
| updateVisuals(); | |
| updateStats(); | |
| drawGraph(); | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| } | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| resizeGraph(); | |
| }); | |
| animate(); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment