Skip to content

Instantly share code, notes, and snippets.

@kagaya
Last active January 29, 2026 06:42
Show Gist options
  • Select an option

  • Save kagaya/db6e9998dbf64ea3b2a99fe17bc87f6c to your computer and use it in GitHub Desktop.

Select an option

Save kagaya/db6e9998dbf64ea3b2a99fe17bc87f6c to your computer and use it in GitHub Desktop.
BTW sandpile
<!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