Created
April 21, 2026 05:59
-
-
Save tado/10c1acd24ab8b51ba5b176ad6ca8df31 to your computer and use it in GitHub Desktop.
Turbulent Flow Simulation with Curl Noise, built with Three.js
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
| import * as THREE from 'three'; | |
| import { OrbitControls } from 'three/addons/controls/OrbitControls.js'; | |
| // ========== 3D Simplex Noise ========== | |
| // Stefan Gustavson のアルゴリズムに基づく実装 | |
| const F3 = 1.0 / 3.0; | |
| const G3 = 1.0 / 6.0; | |
| // 12方向のグラジェントベクトル | |
| const GRAD3 = new Float32Array([ | |
| 1, 1, 0, -1, 1, 0, 1,-1, 0, -1,-1, 0, | |
| 1, 0, 1, -1, 0, 1, 1, 0,-1, -1, 0,-1, | |
| 0, 1, 1, 0,-1, 1, 0, 1,-1, 0,-1,-1 | |
| ]); | |
| // ランダムな順列テーブルを生成 | |
| const { PERM, PERM_MOD12 } = (() => { | |
| const p = new Uint8Array(256); | |
| for (let i = 0; i < 256; i++) p[i] = i; | |
| for (let i = 255; i > 0; i--) { | |
| const j = Math.floor(Math.random() * (i + 1)); | |
| const tmp = p[i]; p[i] = p[j]; p[j] = tmp; | |
| } | |
| const PERM = new Uint8Array(512); | |
| const PERM_MOD12 = new Uint8Array(512); | |
| for (let i = 0; i < 512; i++) { | |
| PERM[i] = p[i & 255]; | |
| PERM_MOD12[i] = PERM[i] % 12; | |
| } | |
| return { PERM, PERM_MOD12 }; | |
| })(); | |
| function noise3D(xin, yin, zin) { | |
| const s = (xin + yin + zin) * F3; | |
| const i = Math.floor(xin + s); | |
| const j = Math.floor(yin + s); | |
| const k = Math.floor(zin + s); | |
| const t = (i + j + k) * G3; | |
| const x0 = xin - i + t, y0 = yin - j + t, z0 = zin - k + t; | |
| let i1, j1, k1, i2, j2, k2; | |
| if (x0 >= y0) { | |
| if (y0 >= z0) { i1=1;j1=0;k1=0;i2=1;j2=1;k2=0; } | |
| else if (x0 >= z0) { i1=1;j1=0;k1=0;i2=1;j2=0;k2=1; } | |
| else { i1=0;j1=0;k1=1;i2=1;j2=0;k2=1; } | |
| } else { | |
| if (y0 < z0) { i1=0;j1=0;k1=1;i2=0;j2=1;k2=1; } | |
| else if (x0 < z0) { i1=0;j1=1;k1=0;i2=0;j2=1;k2=1; } | |
| else { i1=0;j1=1;k1=0;i2=1;j2=1;k2=0; } | |
| } | |
| const x1=x0-i1+G3, y1=y0-j1+G3, z1=z0-k1+G3; | |
| const x2=x0-i2+2*G3, y2=y0-j2+2*G3, z2=z0-k2+2*G3; | |
| const x3=x0-1+3*G3, y3=y0-1+3*G3, z3=z0-1+3*G3; | |
| const ii=i&255, jj=j&255, kk=k&255; | |
| const g0 = PERM_MOD12[ii + PERM[jj + PERM[kk ]]] * 3; | |
| const g1 = PERM_MOD12[ii+i1 + PERM[jj+j1 + PERM[kk+k1]]] * 3; | |
| const g2 = PERM_MOD12[ii+i2 + PERM[jj+j2 + PERM[kk+k2]]] * 3; | |
| const g3 = PERM_MOD12[ii+1 + PERM[jj+1 + PERM[kk+1 ]]] * 3; | |
| let n0=0, n1=0, n2=0, n3=0, tt; | |
| tt = 0.6-x0*x0-y0*y0-z0*z0; if(tt>0){tt*=tt; n0=tt*tt*(GRAD3[g0]*x0+GRAD3[g0+1]*y0+GRAD3[g0+2]*z0);} | |
| tt = 0.6-x1*x1-y1*y1-z1*z1; if(tt>0){tt*=tt; n1=tt*tt*(GRAD3[g1]*x1+GRAD3[g1+1]*y1+GRAD3[g1+2]*z1);} | |
| tt = 0.6-x2*x2-y2*y2-z2*z2; if(tt>0){tt*=tt; n2=tt*tt*(GRAD3[g2]*x2+GRAD3[g2+1]*y2+GRAD3[g2+2]*z2);} | |
| tt = 0.6-x3*x3-y3*y3-z3*z3; if(tt>0){tt*=tt; n3=tt*tt*(GRAD3[g3]*x3+GRAD3[g3+1]*y3+GRAD3[g3+2]*z3);} | |
| return 32 * (n0 + n1 + n2 + n3); | |
| } | |
| // ========== Curl Noise ========== | |
| // ポテンシャル場 F=(Fx,Fy,Fz) のカールを有限差分で計算 | |
| // curl(F) = (dFz/dy - dFy/dz, dFx/dz - dFz/dx, dFy/dx - dFx/dy) | |
| // 3成分に独立性を持たせるためオフセットを使用 | |
| const EPS = 0.001; | |
| const OA = 31.41592; // ポテンシャル成分を独立させるオフセット定数 | |
| const OB = 27.18281; | |
| // Fx = noise3D(x, y+OA, z+OB) | |
| // Fy = noise3D(x+OB, y, z+OA) | |
| // Fz = noise3D(x+OA, y+OB, z ) | |
| const _curl = new Float32Array(3); | |
| function curlNoise(x, y, z) { | |
| const inv2e = 0.5 / EPS; | |
| // curl_x = dFz/dy - dFy/dz | |
| _curl[0] = ( | |
| noise3D(x+OA, y+OB+EPS, z) - noise3D(x+OA, y+OB-EPS, z) - | |
| noise3D(x+OB, y, z+OA+EPS) + noise3D(x+OB, y, z+OA-EPS) | |
| ) * inv2e; | |
| // curl_y = dFx/dz - dFz/dx | |
| _curl[1] = ( | |
| noise3D(x, y+OA, z+OB+EPS) - noise3D(x, y+OA, z+OB-EPS) - | |
| noise3D(x+OA+EPS, y+OB, z) + noise3D(x+OA-EPS, y+OB, z) | |
| ) * inv2e; | |
| // curl_z = dFy/dx - dFx/dy | |
| _curl[2] = ( | |
| noise3D(x+OB+EPS, y, z+OA) - noise3D(x+OB-EPS, y, z+OA) - | |
| noise3D(x, y+OA+EPS, z+OB) + noise3D(x, y+OA-EPS, z+OB) | |
| ) * inv2e; | |
| return _curl; | |
| } | |
| // ========== シーン設定 ========== | |
| const scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x050510); | |
| // カメラ | |
| const camera = new THREE.PerspectiveCamera(60, window.innerWidth / window.innerHeight, 0.1, 100); | |
| camera.position.set(0, 0, 3.5); | |
| // レンダラー | |
| const renderer = new THREE.WebGLRenderer({ antialias: true }); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2)); | |
| document.body.appendChild(renderer.domElement); | |
| // オービットコントロール | |
| const controls = new OrbitControls(camera, renderer.domElement); | |
| controls.enableDamping = true; | |
| controls.dampingFactor = 0.05; | |
| // ========== パーティクルシステム ========== | |
| // 平衡数 = SPAWN_RATE × avg(LIFETIME) ≒ 87 × 700 = 60,900 | |
| const PARTICLE_COUNT = 65000; | |
| const NOISE_SCALE = 1.4; // ノイズの空間スケール | |
| const SPEED = 0.0015; // パーティクルの移動速度 | |
| const LIFETIME_MIN = 400; // 寿命の最小フレーム数 | |
| const LIFETIME_MAX = 1000; // 寿命の最大フレーム数 | |
| const SPAWN_RATE = 87; // 1フレームあたりの発生数 | |
| const SPAWN_SPREAD = 0.04; // 発生位置の初期ランダムばらつき半径 | |
| const HIDDEN_POS = 9999; // 非アクティブ時の退避座標 | |
| const positions = new Float32Array(PARTICLE_COUNT * 3); | |
| const colors = new Float32Array(PARTICLE_COUNT * 3); | |
| const ages = new Uint16Array(PARTICLE_COUNT); // 経過フレーム数 | |
| const lifetimes = new Uint16Array(PARTICLE_COUNT); // 最大寿命 | |
| // アクティブリスト: 生存中スロットのインデックスを密に管理 | |
| // デッドスタック: 空きスロットをO(1)で取り出す | |
| const activeList = new Int32Array(PARTICLE_COUNT); | |
| const deadStack = new Int32Array(PARTICLE_COUNT); | |
| let activeCount = 0; | |
| let deadTop = PARTICLE_COUNT; | |
| // 全スロットを初期状態でデッドスタックに積む | |
| for (let i = 0; i < PARTICLE_COUNT; i++) { | |
| deadStack[i] = i; | |
| positions[i*3+1] = HIDDEN_POS; | |
| } | |
| // O(1) 発生: デッドスタックからスロットを取り出してアクティブリストに追加 | |
| function spawnParticle() { | |
| if (deadTop === 0) return; | |
| const i = deadStack[--deadTop]; | |
| const r = Math.random() * SPAWN_SPREAD; | |
| const theta = Math.random() * Math.PI * 2; | |
| const phi = Math.acos(2 * Math.random() - 1); | |
| positions[i*3] = r * Math.sin(phi) * Math.cos(theta); | |
| positions[i*3+1] = r * Math.sin(phi) * Math.sin(theta); | |
| positions[i*3+2] = r * Math.cos(phi); | |
| ages[i] = 0; | |
| lifetimes[i] = LIFETIME_MIN + Math.random() * (LIFETIME_MAX - LIFETIME_MIN); | |
| activeList[activeCount++] = i; | |
| } | |
| // O(1) 消去: アクティブリストの末尾と交換してから縮小 (swap-and-pop) | |
| function killAt(li) { | |
| const i = activeList[li]; | |
| positions[i*3+1] = HIDDEN_POS; | |
| deadStack[deadTop++] = i; | |
| activeList[li] = activeList[--activeCount]; | |
| } | |
| const geometry = new THREE.BufferGeometry(); | |
| geometry.setAttribute('position', new THREE.BufferAttribute(positions, 3)); | |
| geometry.setAttribute('color', new THREE.BufferAttribute(colors, 3)); | |
| // 加算合成で輝くような見た目に | |
| const material = new THREE.PointsMaterial({ | |
| size: 0.025, | |
| vertexColors: true, | |
| transparent: true, | |
| opacity: 0.9, | |
| blending: THREE.AdditiveBlending, | |
| depthWrite: false, | |
| sizeAttenuation: true, | |
| }); | |
| // ピボットグループ — パーティクル全体をまとめてゆっくり回転させる | |
| const pivot = new THREE.Group(); | |
| pivot.add(new THREE.Points(geometry, material)); | |
| scene.add(pivot); | |
| // ========== 動画キャプチャ ========== | |
| // captureStream(0) + requestFrame() 方式: | |
| // ブラウザ側でフレームを自動サンプリングせず、renderer.render() 後に手動で通知する。 | |
| // これにより実フレームレートに関わらず描画した全フレームが欠落なく記録される。 | |
| let mediaRecorder = null; | |
| let recordedChunks = []; | |
| let videoTrack = null; // requestFrame() 呼び出し用トラック | |
| function startRecording() { | |
| if (mediaRecorder && mediaRecorder.state !== 'inactive') mediaRecorder.stop(); | |
| recordedChunks = []; | |
| const stream = renderer.domElement.captureStream(0); // 0 = 手動フレーム通知モード | |
| videoTrack = stream.getVideoTracks()[0]; | |
| const mimeType = MediaRecorder.isTypeSupported('video/webm;codecs=vp9') | |
| ? 'video/webm;codecs=vp9' | |
| : 'video/webm'; | |
| mediaRecorder = new MediaRecorder(stream, { mimeType }); | |
| mediaRecorder.ondataavailable = e => { if (e.data.size > 0) recordedChunks.push(e.data); }; | |
| mediaRecorder.start(); | |
| console.log('録画開始'); | |
| } | |
| function stopRecording() { | |
| if (!mediaRecorder || mediaRecorder.state === 'inactive') return; | |
| mediaRecorder.onstop = () => { | |
| const blob = new Blob(recordedChunks, { type: 'video/webm' }); | |
| const url = URL.createObjectURL(blob); | |
| const a = document.createElement('a'); | |
| a.href = url; | |
| a.download = `particles_${Date.now()}.webm`; | |
| a.click(); | |
| URL.revokeObjectURL(url); | |
| console.log('録画保存完了'); | |
| }; | |
| mediaRecorder.stop(); | |
| } | |
| // ========== キーボード操作 ========== | |
| // r: パーティクルリセット + 録画開始 / s: 録画停止 + ファイル保存 | |
| window.addEventListener('keydown', e => { | |
| if (e.key === 'r' || e.key === 'R') { | |
| // 全アクティブパーティクルをデッドスタックに戻す | |
| while (activeCount > 0) killAt(0); | |
| startRecording(); | |
| } else if (e.key === 's' || e.key === 'S') { | |
| stopRecording(); | |
| } | |
| }); | |
| // ========== ウィンドウリサイズ対応 ========== | |
| window.addEventListener('resize', () => { | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| }); | |
| // ========== アニメーションループ ========== | |
| let time = 0; | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| time += 0.004; // 時間の進み速度 (ノイズ場がゆっくり変化する) | |
| // --- 中心からパーティクルを一定数ずつ発生 (O(1)/個) --- | |
| for (let s = 0; s < SPAWN_RATE; s++) spawnParticle(); | |
| // --- アクティブなパーティクルのみ更新 (非アクティブスロットは一切触れない) --- | |
| let li = 0; | |
| while (li < activeCount) { | |
| const i = activeList[li]; | |
| const age = ++ages[i]; | |
| // 寿命に達したら O(1) で消去 (swap-and-pop のため li はインクリメントしない) | |
| if (age >= lifetimes[i]) { | |
| killAt(li); | |
| continue; | |
| } | |
| const idx = i * 3; | |
| const x = positions[idx], y = positions[idx+1], z = positions[idx+2]; | |
| // Curl Noise によって発散ゼロの速度ベクトルを取得 | |
| const v = curlNoise(x * NOISE_SCALE, y * NOISE_SCALE, z * NOISE_SCALE + time); | |
| positions[idx] = x + v[0] * SPEED; | |
| positions[idx+1] = y + v[1] * SPEED; | |
| positions[idx+2] = z + v[2] * SPEED; | |
| // 速度の大きさに応じて青→シアン→白にグラデーション | |
| const spd = Math.sqrt(v[0]*v[0] + v[1]*v[1] + v[2]*v[2]); | |
| const c = Math.min(spd * 1.5, 1.0); | |
| colors[idx] = c * 0.25; | |
| colors[idx+1] = c * 0.7 + 0.1; | |
| colors[idx+2] = 1.0; | |
| li++; | |
| } | |
| geometry.attributes.position.needsUpdate = true; | |
| geometry.attributes.color.needsUpdate = true; | |
| // 全体をx・y・z軸それぞれ異なる速度でゆっくり回転 | |
| pivot.rotation.x += 0.0007; | |
| pivot.rotation.y += 0.0013; | |
| pivot.rotation.z += 0.0005; | |
| controls.update(); | |
| renderer.render(scene, camera); | |
| // 録画中は render 直後にフレームを明示的に通知 → コマ落ちなし | |
| if (videoTrack) videoTrack.requestFrame(); | |
| } | |
| animate(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment