├── .gitattributes ├── .gitignore ├── FUNDING.yml ├── LICENSE ├── README.md ├── fpsMonitor.js ├── index.html ├── run_0.js ├── scrap_0.html ├── scrap_0.js ├── screenshot.png ├── sim_0.html ├── sim_0.js ├── sim_1.html ├── sim_1.js ├── sim_2.html ├── sim_2.js ├── sim_3.html ├── sim_3.js ├── sim_4.js ├── sim_4_unfinished.html ├── sim_5.html ├── sim_5.js └── style.css /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .hintrc 2 | -------------------------------------------------------------------------------- /FUNDING.yml: -------------------------------------------------------------------------------- 1 | ko_fi: grantkot 2 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Grant Kot 4 | 5 | Copyright 2022 Matthias Müller - Ten Minute Physics, 6 | www.youtube.com/c/TenMinutePhysics 7 | www.matthiasMueller.info/tenMinutePhysics 8 | (Used hash function) 9 | 10 | Permission is hereby granted, free of charge, to any person obtaining a copy 11 | of this software and associated documentation files (the "Software"), to deal 12 | in the Software without restriction, including without limitation the rights 13 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 14 | copies of the Software, and to permit persons to whom the Software is 15 | furnished to do so, subject to the following conditions: 16 | 17 | The above copyright notice and this permission notice shall be included in all 18 | copies or substantial portions of the Software. 19 | 20 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 21 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 22 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 23 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 24 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 25 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 26 | SOFTWARE. 27 | 28 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Particle-based Viscoelastic Fluid Simulation Implementation 2 | Implementation of the paper Particle-based Viscoelastic Fluid Simulation (Simon Clavet, Philippe Beaudoin, and Pierre Poulin) 3 | [https://dl.acm.org/doi/10.1145/1073368.1073400](https://dl.acm.org/doi/10.1145/1073368.1073400) 4 | 5 | # Demo 6 | https://kotsoft.github.io/particle_based_viscoelastic_fluid/ 7 | ![Screenshot](screenshot.png) 8 | 9 | # sim_0.js 10 | Implements up to and including Section 3, Simulation Step. Similar to verlet integration, we also track position, and previous position, and use those to compute velocity. Velocity is also stored to be further operated on in the viscosity step. 11 | 12 | # sim_1.js 13 | Implements Section 4, Double Density Relaxation, but neighbor search is O(n^2). Note that for this paper, the inner j loop starts at 0 and not i + 1. 14 | 15 | # sim_2.js 16 | Implements spatial hashing. You can toggle on/off to see the performance difference. 17 | Resources: 18 | - Ten Minute Physics (Matthias Müller) 19 | https://matthias-research.github.io/pages/tenMinutePhysics/index.html 20 | - Diligent Engine Tutorial 21 | https://github.com/DiligentGraphics/DiligentSamples/tree/cd125c95c2fac5da5b05b1e648c99be97a885263/Tutorials/Tutorial14_ComputeShader 22 | 23 | # sim_3.js 24 | Iterates through active buckets instead of via particles to try to get more locality. 25 | 26 | # sim_4.js (skipped, didn't finish yet) 27 | I wanted to see if I could set up some caches around each bucket I iterate to get a further perf increase. 28 | 29 | # sim_5.js 30 | Implements Section 5, elasticity and viscosity. Spring adjustment is where I had to make some changes to the paper to improve stability. 31 | - Alg. 4, Ln. 5 - New springs are added with rest length starting at the current distance between the particles (r_ij) instead of h 32 | - Moved where the new springs are added to inside the double-density relaxation method (loop combining optimization) 33 | - Added a parameter of min dist for springs so springs that are too short aren't created when the material is slammed 34 | - Alg. 5 - Added clamp to the inward radial velocity 35 | 36 | # scrap_0.js 37 | An unfinished experiment to apply position corrections all at once, rather than sequentially as they are done in the paper. Sequential position corrections is biased, so it causes many swirls to appear, as well as it needs both read and write access to the positions and presents some challenges when multithreading. 38 | 39 | # Support further development 40 | If you would like to see more features (e.g. multi-material, rigid bodies) and more optimizations (e.g. SoA version), or if you'd like to support development of my sandbox Liquid Crystal https://kotsoft.itch.io, donations are greatly appreciated. 41 | 42 | [![ko-fi](https://ko-fi.com/img/githubbutton_sm.svg)](https://ko-fi.com/grantkot) 43 | -------------------------------------------------------------------------------- /fpsMonitor.js: -------------------------------------------------------------------------------- 1 | class FPSMonitor { 2 | constructor(measureInterval = 1000) { 3 | this.measureInterval = measureInterval; 4 | this.lastMeasure = performance.now(); 5 | this.frames = 0; 6 | this.fps = 0; 7 | 8 | this.fpsDisplay = document.getElementById("fps"); 9 | } 10 | 11 | update() { 12 | this.frames++; 13 | const now = performance.now(); 14 | if (now - this.lastMeasure >= this.measureInterval) { 15 | this.fps = this.frames / (now - this.lastMeasure) * 1000; 16 | this.lastMeasure = now; 17 | this.frames = 0; 18 | 19 | this.fpsDisplay.innerText = `FPS: ${this.fps.toFixed(2)}`; 20 | } 21 | } 22 | } 23 | -------------------------------------------------------------------------------- /index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Particle-based Viscoelastic Fluid Simulation 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

FPS: 0

18 |

19 | 20 | 26 |

27 |

28 | 29 | 30 |

31 |

32 | 33 | 34 |

35 | 36 |

37 | 38 | 39 |

40 | 41 |

42 | 43 | 44 |

45 | 46 |

47 | 48 | 49 |

50 | 51 |

52 | 53 | 54 |

55 | 56 |

57 | 58 | 59 |

60 | 61 |

62 | 63 | 64 |

65 | 66 |

67 | 68 | 69 | 70 |

71 | 72 | 73 |

74 | 75 |

76 | 77 | 78 |

79 | 80 |

81 | 82 | 83 |

84 | 85 |

86 | 87 | 88 |

89 | 90 |

91 | 92 | 93 |

94 | 95 |

96 | 97 | 98 | 99 | 100 |

101 | 102 |

103 | 104 | KB: (A)ttract, (R)epel, (E)mit, (D)rain 105 | 106 |
107 | 108 | You can drag mouse as well as window! 109 | 110 |

111 | 112 |

113 | Prev 114 |

115 |
116 | 117 |

118 | 119 |

120 |
121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | -------------------------------------------------------------------------------- /run_0.js: -------------------------------------------------------------------------------- 1 | // Options 2 | let numParticles = 2000; 3 | 4 | // Setup a simulation 5 | const canvas = document.getElementById("simCanvas"); 6 | const ctx = canvas.getContext("2d"); 7 | canvas.width = window.innerWidth; 8 | canvas.height = window.innerHeight; 9 | 10 | let simulator = new Simulator(canvas.width, canvas.height, numParticles); 11 | simulator.running = true; 12 | 13 | const fpsMonitor = new FPSMonitor(); 14 | 15 | function loop() { 16 | simulator.update(); 17 | fpsMonitor.update(); 18 | 19 | ctx.clearRect(0, 0, canvas.width, canvas.height); 20 | simulator.draw(ctx); 21 | 22 | requestAnimationFrame(loop); 23 | } 24 | 25 | loop(); 26 | 27 | // Event listeners 28 | const materialSliders = ["restDensity", "stiffness", "nearStiffness", "springStiffness", "plasticity", "yieldRatio", "minDistRatio", "linViscosity", "quadViscosity", "kernelRadius", "pointSize", "gravX", "gravY", "dt"]; 29 | 30 | for (let sliderId of materialSliders) { 31 | let slider = document.getElementById(sliderId); 32 | 33 | if (slider) { 34 | slider.addEventListener("input", (e) => { 35 | simulator.material[sliderId] = e.target.value; 36 | }); 37 | } 38 | } 39 | 40 | document.getElementById("startButton").addEventListener("click", () => { 41 | for (let sliderId of materialSliders) { 42 | let slider = document.getElementById(sliderId); 43 | 44 | if (slider) { 45 | simulator.material[sliderId] = slider.value; 46 | } 47 | } 48 | 49 | simulator.start(); 50 | }); 51 | 52 | document.getElementById("pauseButton").addEventListener("click", () => { 53 | simulator.pause(); 54 | }); 55 | 56 | document.getElementById("stepButton").addEventListener("click", () => { 57 | simulator.running = true; 58 | simulator.update(); 59 | simulator.running = false; 60 | }); 61 | 62 | document.getElementById("resetButton").addEventListener("click", () => { 63 | simulator = new Simulator(canvas.width, canvas.height, numParticles); 64 | }); 65 | 66 | let collapseButton = document.getElementById("collapseButton"); 67 | 68 | if (collapseButton) { 69 | document.getElementById("collapseButton").addEventListener("click", () => { 70 | let controls = document.getElementById("controls"); 71 | 72 | if (controls.style.display == "none") { 73 | controls.style.display = "block"; 74 | collapseButton.innerText = "Collapse"; 75 | } else { 76 | controls.style.display = "none"; 77 | collapseButton.innerText = "Expand"; 78 | } 79 | }); 80 | } 81 | 82 | document.getElementById("numParticles").addEventListener("input", (e) => { 83 | if (e.target.value == numParticles) { 84 | return; 85 | } 86 | 87 | numParticles = e.target.value; 88 | simulator = new Simulator(canvas.width, canvas.height, numParticles); 89 | }); 90 | 91 | { 92 | let useSpatialHash = document.getElementById("useSpatialHash") 93 | 94 | if (useSpatialHash) { 95 | useSpatialHash.addEventListener("change", (e) => { 96 | simulator.useSpatialHash = e.target.checked; 97 | }); 98 | } 99 | } 100 | 101 | window.addEventListener("resize", () => { 102 | canvas.width = window.innerWidth; 103 | canvas.height = window.innerHeight; 104 | simulator.resize(canvas.width, canvas.height); 105 | }); 106 | 107 | window.addEventListener("pointermove", (e) => { 108 | simulator.mouseX = e.clientX; 109 | simulator.mouseY = e.clientY; 110 | }); 111 | 112 | window.addEventListener("pointerdown", (e) => { 113 | // Account for first-frame drags (mobile primarily) 114 | simulator.mouseX = e.clientX; 115 | simulator.mouseY = e.clientY; 116 | simulator.mousePrevX = e.clientX; 117 | simulator.mousePrevY = e.clientY; 118 | 119 | if (e.button == 0) { 120 | simulator.drag = true; 121 | } 122 | }); 123 | 124 | window.addEventListener("pointerup", (e) => { 125 | if (e.button == 0) { 126 | simulator.drag = false; 127 | } 128 | }); 129 | 130 | const actionKeys = { "e": "emit", "d": "drain", "a": "attract", "r": "repel" }; 131 | 132 | window.addEventListener("keydown", (e) => { 133 | if (actionKeys[e.key]) { 134 | simulator[actionKeys[e.key]] = true; 135 | } 136 | }); 137 | 138 | window.addEventListener("keyup", (e) => { 139 | if (actionKeys[e.key]) { 140 | simulator[actionKeys[e.key]] = false; 141 | } 142 | }); 143 | 144 | window.addEventListener("blur", () => { 145 | for (let key in actionKeys) { 146 | simulator[actionKeys[key]] = false; 147 | } 148 | 149 | simulator.drag = false; 150 | }); 151 | -------------------------------------------------------------------------------- /scrap_0.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PVFS 2.1 - Optimize Neighbor Search 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |

FPS: 0

17 |

18 | 19 | 25 |

26 |

27 | 28 | 29 |

30 |

31 | 32 | 33 | 34 |

35 |
36 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | -------------------------------------------------------------------------------- /scrap_0.js: -------------------------------------------------------------------------------- 1 | class Particle { 2 | constructor(posX, posY, velX, velY) { 3 | this.posX = posX; 4 | this.posY = posY; 5 | 6 | this.prevX = posX; 7 | this.prevY = posY; 8 | 9 | this.velX = velX; 10 | this.velY = velY; 11 | 12 | this.dispX = 0; 13 | this.dispY = 0; 14 | } 15 | } 16 | 17 | class Simulator { 18 | constructor(width, height, numParticles) { 19 | this.running = false; 20 | 21 | this.width = width; 22 | this.height = height; 23 | 24 | this.gravX = 0.0; 25 | this.gravY = 0.2; 26 | 27 | this.particles = []; 28 | this.addParticles(numParticles); 29 | 30 | this.screenX = window.screenX; 31 | this.screenY = window.screenY; 32 | 33 | this.useSpatialHash = true; 34 | this.numHashBuckets = 1000; 35 | this.particleListHeads = []; // Same size as numHashBuckets, each points to first particle in bucket list 36 | this.particleListNextIdx = []; // Same size as particles list, each points to next particle in bucket list 37 | } 38 | 39 | start() { this.running = true; } 40 | pause() { this.running = false; } 41 | 42 | resize(width, height) { 43 | this.width = width; 44 | this.height = height; 45 | } 46 | 47 | addParticles(count) { 48 | for (let i = 0; i < count; i++) { 49 | const posX = Math.random() * this.width; 50 | const posY = Math.random() * this.height; 51 | const velX = Math.random() * 2 - 1; 52 | const velY = Math.random() * 2 - 1; 53 | 54 | this.particles.push(new Particle(posX, posY, velX, velY)); 55 | } 56 | } 57 | 58 | draw(ctx) { 59 | ctx.save(); 60 | ctx.translate(-5, -5); 61 | 62 | for (let p of this.particles) { 63 | ctx.fillRect(p.posX, p.posY, 10, 10); 64 | } 65 | 66 | ctx.restore(); 67 | } 68 | 69 | // Algorithm 1: Simulation step 70 | update(dt = 1) { 71 | if (!this.running) { 72 | return; 73 | } 74 | 75 | const screenMoveX = window.screenX - this.screenX; 76 | const screenMoveY = window.screenY - this.screenY; 77 | 78 | this.screenX = window.screenX; 79 | this.screenY = window.screenY; 80 | 81 | for (let p of this.particles) { 82 | // apply gravity 83 | p.velX += this.gravX * dt; 84 | p.velY += this.gravY * dt; 85 | 86 | p.posX -= screenMoveX; 87 | p.posY -= screenMoveY; 88 | } 89 | 90 | this.applyViscosity(dt); 91 | 92 | for (let p of this.particles) { 93 | // save previous position 94 | p.prevX = p.posX; 95 | p.prevY = p.posY; 96 | 97 | // advance to predicted position 98 | p.posX += p.velX * dt; 99 | p.posY += p.velY * dt; 100 | } 101 | 102 | this.populateHashGrid(); 103 | 104 | this.adjustSprings(dt); 105 | this.applySpringDisplacements(dt); 106 | this.doubleDensityRelaxation(dt); 107 | 108 | this.applyPressureDisplacements(dt); 109 | 110 | this.resolveCollisions(dt); 111 | 112 | for (let p of this.particles) { 113 | // use previous position to calculate new velocity 114 | p.velX = (p.posX - p.prevX) / dt; 115 | p.velY = (p.posY - p.prevY) / dt; 116 | } 117 | } 118 | 119 | doubleDensityRelaxation(dt) { 120 | const numParticles = this.particles.length; 121 | const kernelRadius = 40; // h 122 | const kernelRadiusSq = kernelRadius * kernelRadius; 123 | const kernelRadiusInv = 1.0 / kernelRadius; 124 | 125 | const restDensity = 2; 126 | const stiffness = .5; 127 | const nearStiffness = 0.5; 128 | 129 | // Neighbor cache 130 | const neighborIndices = []; 131 | const neighborUnitX = []; 132 | const neighborUnitY = []; 133 | const neighborCloseness = []; 134 | const visitedBuckets = []; 135 | 136 | for (let i = 0; i < numParticles; i++) { 137 | let p0 = this.particles[i]; 138 | 139 | let density = 0; 140 | let nearDensity = 0; 141 | 142 | let numNeighbors = 0; 143 | let numVisitedBuckets = 0; 144 | 145 | if (this.useSpatialHash) { 146 | // Compute density and near-density 147 | const bucketX = Math.floor(p0.posX * kernelRadiusInv); 148 | const bucketY = Math.floor(p0.posY * kernelRadiusInv); 149 | 150 | for (let bucketDX = -1; bucketDX <= 1; bucketDX++) { 151 | for (let bucketDY = -1; bucketDY <= 1; bucketDY++) { 152 | const bucketIdx = this.getHashBucketIdx(Math.floor(bucketX + bucketDX), Math.floor(bucketY + bucketDY)); 153 | 154 | // Check hash collision 155 | let found = false; 156 | for (let k = 0; k < numVisitedBuckets; k++) { 157 | if (visitedBuckets[k] === bucketIdx) { 158 | found = true; 159 | break; 160 | } 161 | } 162 | 163 | if (found) { 164 | continue; 165 | } 166 | 167 | visitedBuckets[numVisitedBuckets] = bucketIdx; 168 | numVisitedBuckets++; 169 | 170 | let neighborIdx = this.particleListHeads[bucketIdx]; 171 | 172 | while (neighborIdx != -1) { 173 | if (neighborIdx === i) { 174 | neighborIdx = this.particleListNextIdx[neighborIdx]; 175 | continue; 176 | } 177 | 178 | let p1 = this.particles[neighborIdx]; 179 | 180 | const diffX = p1.posX - p0.posX; 181 | 182 | if (diffX > kernelRadius || diffX < -kernelRadius) { 183 | neighborIdx = this.particleListNextIdx[neighborIdx]; 184 | continue; 185 | } 186 | 187 | const diffY = p1.posY - p0.posY; 188 | 189 | if (diffY > kernelRadius || diffY < -kernelRadius) { 190 | neighborIdx = this.particleListNextIdx[neighborIdx]; 191 | continue; 192 | } 193 | 194 | const rSq = diffX * diffX + diffY * diffY; 195 | 196 | if (rSq < kernelRadiusSq) { 197 | const r = Math.sqrt(rSq); 198 | const q = r * kernelRadiusInv; 199 | const closeness = 1 - q; 200 | const closenessSq = closeness * closeness; 201 | 202 | density += closeness * closeness; 203 | nearDensity += closeness * closenessSq; 204 | 205 | neighborIndices[numNeighbors] = neighborIdx; 206 | neighborUnitX[numNeighbors] = diffX / r; 207 | neighborUnitY[numNeighbors] = diffY / r; 208 | neighborCloseness[numNeighbors] = closeness; 209 | numNeighbors++; 210 | } 211 | 212 | neighborIdx = this.particleListNextIdx[neighborIdx]; 213 | } 214 | } 215 | } 216 | } else { 217 | // The old n^2 way 218 | 219 | for (let j = 0; j < numParticles; j++) { 220 | if (i === j) { 221 | continue; 222 | } 223 | 224 | let p1 = this.particles[j]; 225 | 226 | const diffX = p1.posX - p0.posX; 227 | 228 | if (diffX > kernelRadius || diffX < -kernelRadius) { 229 | continue; 230 | } 231 | 232 | const diffY = p1.posY - p0.posY; 233 | 234 | if (diffY > kernelRadius || diffY < -kernelRadius) { 235 | continue; 236 | } 237 | 238 | const rSq = diffX * diffX + diffY * diffY; 239 | 240 | if (rSq < kernelRadiusSq) { 241 | const r = Math.sqrt(rSq); 242 | const q = r / kernelRadius; 243 | const closeness = 1 - q; 244 | const closenessSq = closeness * closeness; 245 | 246 | density += closeness * closeness; 247 | nearDensity += closeness * closenessSq; 248 | 249 | neighborIndices[numNeighbors] = j; 250 | neighborUnitX[numNeighbors] = diffX / r; 251 | neighborUnitY[numNeighbors] = diffY / r; 252 | neighborCloseness[numNeighbors] = closeness; 253 | numNeighbors++; 254 | } 255 | } 256 | } 257 | 258 | // Add wall density 259 | const closestX = Math.min(p0.posX, this.width - p0.posX); 260 | const closestY = Math.min(p0.posY, this.height - p0.posY); 261 | 262 | if (closestX < kernelRadius) { 263 | const q = closestX / kernelRadius; 264 | const closeness = 1 - q; 265 | const closenessSq = closeness * closeness; 266 | 267 | density += closeness * closeness; 268 | nearDensity += closeness * closenessSq; 269 | } 270 | 271 | if (closestY < kernelRadius) { 272 | const q = closestY / kernelRadius; 273 | const closeness = 1 - q; 274 | const closenessSq = closeness * closeness; 275 | 276 | density += closeness * closeness; 277 | nearDensity += closeness * closenessSq; 278 | } 279 | 280 | // Compute pressure and near-pressure 281 | const pressure = stiffness * (density - restDensity); 282 | const nearPressure = nearStiffness * nearDensity; 283 | 284 | let dispX = 0; 285 | let dispY = 0; 286 | 287 | for (let j = 0; j < numNeighbors; j++) { 288 | let p1 = this.particles[neighborIndices[j]]; 289 | 290 | const closeness = neighborCloseness[j]; 291 | const D = dt * dt * (pressure * closeness + nearPressure * closeness * closeness) / 2; 292 | const DX = D * neighborUnitX[j]; 293 | const DY = D * neighborUnitY[j]; 294 | 295 | p1.dispX += DX; 296 | p1.dispY += DY; 297 | 298 | dispX -= DX; 299 | dispY -= DY; 300 | } 301 | 302 | p0.dispX += dispX; 303 | p0.dispY += dispY; 304 | } 305 | } 306 | 307 | // Mueller 10 minute physics 308 | getHashBucketIdx(bucketX, bucketY) { 309 | const h = ((bucketX * 92837111) ^ (bucketY * 689287499)); 310 | return Math.abs(h) % this.numHashBuckets; 311 | } 312 | 313 | populateHashGrid() { 314 | // Clear the hash grid 315 | for (let i = 0; i < this.numHashBuckets; i++) { 316 | this.particleListHeads[i] = -1; 317 | } 318 | 319 | // Populate the hash grid 320 | const numParticles = this.particles.length; 321 | const bucketSize = 40; // Same as kernel radius 322 | const bucketSizeInv = 1.0 / bucketSize; 323 | 324 | for (let i = 0; i < numParticles; i++) { 325 | let p = this.particles[i]; 326 | 327 | const bucketX = Math.floor(p.posX * bucketSizeInv); 328 | const bucketY = Math.floor(p.posY * bucketSizeInv); 329 | 330 | const bucketIdx = this.getHashBucketIdx(bucketX, bucketY); 331 | 332 | this.particleListNextIdx[i] = this.particleListHeads[bucketIdx]; 333 | this.particleListHeads[bucketIdx] = i; 334 | } 335 | } 336 | 337 | applyPressureDisplacements(dt) { 338 | for (let p of this.particles) { 339 | p.posX += p.dispX * .5; 340 | p.posY += p.dispY * .5; 341 | 342 | p.dispX = 0; 343 | p.dispY = 0; 344 | } 345 | } 346 | 347 | applySpringDisplacements(dt) { } 348 | adjustSprings(dt) { } 349 | applyViscosity(dt) { } 350 | resolveCollisions(dt) { 351 | const boundaryMul = 0.5 * dt; // 1 is no bounce, 2 is full bounce 352 | const boundaryMinX = 5; 353 | const boundaryMaxX = this.width - 5; 354 | const boundaryMinY = 5; 355 | const boundaryMaxY = this.height - 5; 356 | 357 | 358 | for (let p of this.particles) { 359 | if (p.posX < boundaryMinX) { 360 | p.posX += boundaryMul * (boundaryMinX - p.posX); 361 | } else if (p.posX > boundaryMaxX) { 362 | p.posX += boundaryMul * (boundaryMaxX - p.posX); 363 | } 364 | 365 | if (p.posY < boundaryMinY) { 366 | p.posY += boundaryMul * (boundaryMinY - p.posY); 367 | } else if (p.posY > boundaryMaxY) { 368 | p.posY += boundaryMul * (boundaryMaxY - p.posY); 369 | } 370 | } 371 | } 372 | } 373 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/kotsoft/particle_based_viscoelastic_fluid/3238340fbd1e26665ac2a7b3e9ca5b42bb4f368e/screenshot.png -------------------------------------------------------------------------------- /sim_0.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PVFS 1 - Simulation Step 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

FPS: 0

18 |

19 | 20 | 26 |

27 |

28 | 29 | 30 | 31 | 32 |

33 |

34 | 35 | You can drag window around. 36 | 37 |

38 |

39 | Next 40 |

41 |
42 |

43 | 44 |

45 |
46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | -------------------------------------------------------------------------------- /sim_0.js: -------------------------------------------------------------------------------- 1 | class Particle { 2 | constructor(posX, posY, velX, velY) { 3 | this.posX = posX; 4 | this.posY = posY; 5 | 6 | this.prevX = posX; 7 | this.prevY = posY; 8 | 9 | this.velX = velX; 10 | this.velY = velY; 11 | } 12 | } 13 | 14 | class Simulator { 15 | constructor(width, height, numParticles) { 16 | this.running = false; 17 | 18 | this.width = width; 19 | this.height = height; 20 | 21 | this.gravX = 0; 22 | this.gravY = 0.1; 23 | 24 | this.particles = []; 25 | this.addParticles(numParticles); 26 | } 27 | 28 | start() { this.running = true; } 29 | pause() { this.running = false; } 30 | 31 | resize(width, height) { 32 | this.width = width; 33 | this.height = height; 34 | } 35 | 36 | addParticles(count) { 37 | for (let i = 0; i < count; i++) { 38 | const posX = Math.random() * this.width; 39 | const posY = Math.random() * this.height; 40 | const velX = Math.random() * 2 - 1; 41 | const velY = Math.random() * 2 - 1; 42 | 43 | this.particles.push(new Particle(posX, posY, velX, velY)); 44 | } 45 | } 46 | 47 | draw(ctx) { 48 | ctx.save(); 49 | ctx.translate(-2.5, -2.5); 50 | ctx.fillStyle = "#0066FF"; 51 | 52 | for (let p of this.particles) { 53 | ctx.fillRect(p.posX, p.posY, 5, 5); 54 | } 55 | 56 | ctx.restore(); 57 | } 58 | 59 | // Algorithm 1: Simulation step 60 | update(dt = 1) { 61 | if (!this.running) { 62 | return; 63 | } 64 | 65 | for (let p of this.particles) { 66 | // apply gravity 67 | p.velX += this.gravX * dt; 68 | p.velY += this.gravY * dt; 69 | } 70 | 71 | this.applyViscosity(dt); 72 | 73 | for (let p of this.particles) { 74 | // save previous position 75 | p.prevX = p.posX; 76 | p.prevY = p.posY; 77 | 78 | // advance to predicted position 79 | p.posX += p.velX * dt; 80 | p.posY += p.velY * dt; 81 | } 82 | 83 | this.adjustSprings(dt); 84 | this.applySpringDisplacements(dt); 85 | this.doubleDensityRelaxation(dt); 86 | this.resolveCollisions(dt); 87 | 88 | for (let p of this.particles) { 89 | // use previous position to calculate new velocity 90 | p.velX = (p.posX - p.prevX) / dt; 91 | p.velY = (p.posY - p.prevY) / dt; 92 | } 93 | } 94 | 95 | doubleDensityRelaxation(dt) { } 96 | applySpringDisplacements(dt) { } 97 | adjustSprings(dt) { } 98 | applyViscosity(dt) { } 99 | resolveCollisions(dt) { 100 | const boundaryMul = .5; // Soft boundary 101 | const boundaryMinX = 5; 102 | const boundaryMaxX = this.width - 5; 103 | const boundaryMinY = 5; 104 | const boundaryMaxY = this.height - 5; 105 | 106 | 107 | for (let p of this.particles) { 108 | if (p.posX < boundaryMinX) { 109 | p.posX += boundaryMul * (boundaryMinX - p.posX); 110 | } else if (p.posX > boundaryMaxX) { 111 | p.posX += boundaryMul * (boundaryMaxX - p.posX); 112 | } 113 | 114 | if (p.posY < boundaryMinY) { 115 | p.posY += boundaryMul * (boundaryMinY - p.posY); 116 | } else if (p.posY > boundaryMaxY) { 117 | p.posY += boundaryMul * (boundaryMaxY - p.posY); 118 | } 119 | } 120 | } 121 | } 122 | -------------------------------------------------------------------------------- /sim_1.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PVFS 2 - Double Density Relaxation 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

FPS: 0

18 |

19 | 20 | 26 |

27 |

28 | 29 | 30 | 31 | 32 |

33 | 34 |

35 | 36 | You can drag window around. 37 | 38 |

39 | 40 |

41 | Prev 42 | | 43 | Next 44 |

45 |
46 | 47 |

48 | 49 |

50 | 51 |
52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | -------------------------------------------------------------------------------- /sim_1.js: -------------------------------------------------------------------------------- 1 | class Particle { 2 | constructor(posX, posY, velX, velY) { 3 | this.posX = posX; 4 | this.posY = posY; 5 | 6 | this.prevX = posX; 7 | this.prevY = posY; 8 | 9 | this.velX = velX; 10 | this.velY = velY; 11 | } 12 | } 13 | 14 | class Simulator { 15 | constructor(width, height, numParticles) { 16 | this.running = false; 17 | 18 | this.width = width; 19 | this.height = height; 20 | 21 | this.gravX = 0.0; 22 | this.gravY = 0.5; 23 | 24 | this.particles = []; 25 | this.addParticles(numParticles); 26 | 27 | this.screenX = window.screenX; 28 | this.screenY = window.screenY; 29 | } 30 | 31 | start() { this.running = true; } 32 | pause() { this.running = false; } 33 | 34 | resize(width, height) { 35 | this.width = width; 36 | this.height = height; 37 | } 38 | 39 | addParticles(count) { 40 | for (let i = 0; i < count; i++) { 41 | const posX = Math.random() * this.width; 42 | const posY = Math.random() * this.height; 43 | const velX = Math.random() * 2 - 1; 44 | const velY = Math.random() * 2 - 1; 45 | 46 | this.particles.push(new Particle(posX, posY, velX, velY)); 47 | } 48 | } 49 | 50 | draw(ctx) { 51 | ctx.save(); 52 | ctx.translate(-5, -5); 53 | 54 | ctx.fillStyle = "#0066FF"; 55 | 56 | for (let p of this.particles) { 57 | ctx.fillRect(p.posX, p.posY, 10, 10); 58 | } 59 | 60 | ctx.restore(); 61 | } 62 | 63 | // Algorithm 1: Simulation step 64 | update(dt = 1) { 65 | if (!this.running) { 66 | return; 67 | } 68 | 69 | const screenMoveX = window.screenX - this.screenX; 70 | const screenMoveY = window.screenY - this.screenY; 71 | 72 | this.screenX = window.screenX; 73 | this.screenY = window.screenY; 74 | 75 | for (let p of this.particles) { 76 | // apply gravity 77 | p.velX += this.gravX * dt; 78 | p.velY += this.gravY * dt; 79 | 80 | p.posX -= screenMoveX; 81 | p.posY -= screenMoveY; 82 | } 83 | 84 | this.applyViscosity(dt); 85 | 86 | for (let p of this.particles) { 87 | // save previous position 88 | p.prevX = p.posX; 89 | p.prevY = p.posY; 90 | 91 | // advance to predicted position 92 | p.posX += p.velX * dt; 93 | p.posY += p.velY * dt; 94 | } 95 | 96 | this.adjustSprings(dt); 97 | this.applySpringDisplacements(dt); 98 | this.doubleDensityRelaxation(dt); 99 | this.resolveCollisions(dt); 100 | 101 | for (let p of this.particles) { 102 | // use previous position to calculate new velocity 103 | p.velX = (p.posX - p.prevX) / dt; 104 | p.velY = (p.posY - p.prevY) / dt; 105 | } 106 | } 107 | 108 | doubleDensityRelaxation(dt) { 109 | const numParticles = this.particles.length; 110 | const kernelRadius = 40; // h 111 | const kernelRadiusSq = kernelRadius * kernelRadius; 112 | 113 | const restDensity = 2; 114 | const stiffness = 1.0; 115 | const nearStiffness = .5; 116 | 117 | // Neighbor cache 118 | const neighborIndices = []; 119 | const neighborUnitX = []; 120 | const neighborUnitY = []; 121 | const neighborCloseness = []; 122 | 123 | for (let i = 0; i < numParticles; i++) { 124 | let p0 = this.particles[i]; 125 | 126 | let density = 0; 127 | let nearDensity = 0; 128 | 129 | let numNeighbors = 0; 130 | 131 | // Compute density and near-density 132 | for (let j = 0; j < numParticles; j++) { 133 | if (i === j) { 134 | continue; 135 | } 136 | 137 | let p1 = this.particles[j]; 138 | 139 | const diffX = p1.posX - p0.posX; 140 | 141 | if (diffX > kernelRadius || diffX < -kernelRadius) { 142 | continue; 143 | } 144 | 145 | const diffY = p1.posY - p0.posY; 146 | 147 | if (diffY > kernelRadius || diffY < -kernelRadius) { 148 | continue; 149 | } 150 | 151 | const rSq = diffX * diffX + diffY * diffY; 152 | 153 | if (rSq < kernelRadiusSq) { 154 | const r = Math.sqrt(rSq); 155 | const q = r / kernelRadius; 156 | const closeness = 1 - q; 157 | const closenessSq = closeness * closeness; 158 | 159 | density += closeness * closeness; 160 | nearDensity += closeness * closenessSq; 161 | 162 | neighborIndices[numNeighbors] = j; 163 | neighborUnitX[numNeighbors] = diffX / r; 164 | neighborUnitY[numNeighbors] = diffY / r; 165 | neighborCloseness[numNeighbors] = closeness; 166 | numNeighbors++; 167 | } 168 | } 169 | 170 | // Add wall density 171 | const closestX = Math.min(p0.posX, this.width - p0.posX); 172 | const closestY = Math.min(p0.posY, this.height - p0.posY); 173 | 174 | if (closestX < kernelRadius) { 175 | const q = closestX / kernelRadius; 176 | const closeness = 1 - q; 177 | const closenessSq = closeness * closeness; 178 | 179 | density += closeness * closeness; 180 | nearDensity += closeness * closenessSq; 181 | } 182 | 183 | if (closestY < kernelRadius) { 184 | const q = closestY / kernelRadius; 185 | const closeness = 1 - q; 186 | const closenessSq = closeness * closeness; 187 | 188 | density += closeness * closeness; 189 | nearDensity += closeness * closenessSq; 190 | } 191 | 192 | // Compute pressure and near-pressure 193 | const pressure = stiffness * (density - restDensity); 194 | const nearPressure = nearStiffness * nearDensity; 195 | 196 | let dispX = 0; 197 | let dispY = 0; 198 | 199 | for (let j = 0; j < numNeighbors; j++) { 200 | let p1 = this.particles[neighborIndices[j]]; 201 | 202 | const closeness = neighborCloseness[j]; 203 | const D = dt * dt * (pressure * closeness + nearPressure * closeness * closeness) / 2; 204 | const DX = D * neighborUnitX[j]; 205 | const DY = D * neighborUnitY[j]; 206 | 207 | p1.posX += DX; 208 | p1.posY += DY; 209 | 210 | dispX -= DX; 211 | dispY -= DY; 212 | } 213 | 214 | p0.posX += dispX; 215 | p0.posY += dispY; 216 | } 217 | } 218 | 219 | applySpringDisplacements(dt) { } 220 | adjustSprings(dt) { } 221 | applyViscosity(dt) { } 222 | resolveCollisions(dt) { 223 | const boundaryMul = 1.5 * dt; // 1 is no bounce, 2 is full bounce 224 | const boundaryMinX = 5; 225 | const boundaryMaxX = this.width - 5; 226 | const boundaryMinY = 5; 227 | const boundaryMaxY = this.height - 5; 228 | 229 | 230 | for (let p of this.particles) { 231 | if (p.posX < boundaryMinX) { 232 | p.posX += boundaryMul * (boundaryMinX - p.posX); 233 | } else if (p.posX > boundaryMaxX) { 234 | p.posX += boundaryMul * (boundaryMaxX - p.posX); 235 | } 236 | 237 | if (p.posY < boundaryMinY) { 238 | p.posY += boundaryMul * (boundaryMinY - p.posY); 239 | } else if (p.posY > boundaryMaxY) { 240 | p.posY += boundaryMul * (boundaryMaxY - p.posY); 241 | } 242 | } 243 | } 244 | } 245 | -------------------------------------------------------------------------------- /sim_2.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PVFS 2.1 - Spatial Hash Neighbor Search 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

FPS: 0

18 |

19 | 20 | 26 |

27 |

28 | 29 | 30 |

31 |

32 | 33 | 34 | 35 | 36 |

37 | 38 |

39 | 40 | You can drag window around. 41 | 42 |

43 | 44 |

45 | Prev 46 | | 47 | Next 48 |

49 |
50 | 51 |

52 | 53 |

54 |
55 | 56 | 57 | 58 | 59 | 60 | 61 | 62 | -------------------------------------------------------------------------------- /sim_2.js: -------------------------------------------------------------------------------- 1 | class Particle { 2 | constructor(posX, posY, velX, velY) { 3 | this.posX = posX; 4 | this.posY = posY; 5 | 6 | this.prevX = posX; 7 | this.prevY = posY; 8 | 9 | this.velX = velX; 10 | this.velY = velY; 11 | } 12 | } 13 | 14 | class Material { 15 | constructor(name, restDensity, stiffness, nearStiffness, kernelRadius) { 16 | this.name = name; 17 | this.restDensity = restDensity; 18 | this.stiffness = stiffness; 19 | this.nearStiffness = nearStiffness; 20 | this.kernelRadius = kernelRadius; 21 | 22 | this.maxPressure = 1; 23 | } 24 | } 25 | 26 | class Simulator { 27 | constructor(width, height, numParticles) { 28 | this.running = false; 29 | 30 | this.width = width; 31 | this.height = height; 32 | 33 | this.gravX = 0.0; 34 | this.gravY = 0.2; 35 | 36 | this.particles = []; 37 | this.addParticles(numParticles); 38 | 39 | this.screenX = window.screenX; 40 | this.screenY = window.screenY; 41 | 42 | this.useSpatialHash = true; 43 | this.numHashBuckets = 5000; 44 | this.particleListHeads = []; // Same size as numHashBuckets, each points to first particle in bucket list 45 | this.particleListNextIdx = []; // Same size as particles list, each points to next particle in bucket list 46 | 47 | this.material = new Material("water", 2, 0.5, 0.5, 40); 48 | } 49 | 50 | start() { this.running = true; } 51 | pause() { this.running = false; } 52 | 53 | resize(width, height) { 54 | this.width = width; 55 | this.height = height; 56 | } 57 | 58 | addParticles(count) { 59 | for (let i = 0; i < count; i++) { 60 | const posX = Math.random() * this.width; 61 | const posY = Math.random() * this.height; 62 | const velX = Math.random() * 2 - 1; 63 | const velY = Math.random() * 2 - 1; 64 | 65 | this.particles.push(new Particle(posX, posY, velX, velY)); 66 | } 67 | } 68 | 69 | draw(ctx) { 70 | ctx.save(); 71 | ctx.translate(-5, -5); 72 | ctx.fillStyle = "#0066FF"; 73 | 74 | for (let p of this.particles) { 75 | ctx.fillRect(p.posX, p.posY, 10, 10); 76 | } 77 | 78 | ctx.restore(); 79 | } 80 | 81 | // Algorithm 1: Simulation step 82 | update(dt = 1) { 83 | if (!this.running) { 84 | return; 85 | } 86 | 87 | const screenMoveX = window.screenX - this.screenX; 88 | const screenMoveY = window.screenY - this.screenY; 89 | 90 | this.screenX = window.screenX; 91 | this.screenY = window.screenY; 92 | 93 | for (let p of this.particles) { 94 | // apply gravity 95 | p.velX += this.gravX * dt; 96 | p.velY += this.gravY * dt; 97 | 98 | p.posX -= screenMoveX; 99 | p.posY -= screenMoveY; 100 | } 101 | 102 | this.applyViscosity(dt); 103 | 104 | for (let p of this.particles) { 105 | // save previous position 106 | p.prevX = p.posX; 107 | p.prevY = p.posY; 108 | 109 | // advance to predicted position 110 | p.posX += p.velX * dt; 111 | p.posY += p.velY * dt; 112 | } 113 | 114 | this.populateHashGrid(); 115 | 116 | this.adjustSprings(dt); 117 | this.applySpringDisplacements(dt); 118 | this.doubleDensityRelaxation(dt); 119 | this.resolveCollisions(dt); 120 | 121 | for (let p of this.particles) { 122 | // use previous position to calculate new velocity 123 | p.velX = (p.posX - p.prevX) / dt; 124 | p.velY = (p.posY - p.prevY) / dt; 125 | } 126 | } 127 | 128 | doubleDensityRelaxation(dt) { 129 | const numParticles = this.particles.length; 130 | const kernelRadius = this.material.kernelRadius; // h 131 | const kernelRadiusSq = kernelRadius * kernelRadius; 132 | const kernelRadiusInv = 1.0 / kernelRadius; 133 | 134 | const restDensity = this.material.restDensity; 135 | const stiffness = this.material.stiffness; 136 | const nearStiffness = this.material.nearStiffness; 137 | 138 | // Neighbor cache 139 | const neighborIndices = []; 140 | const neighborUnitX = []; 141 | const neighborUnitY = []; 142 | const neighborCloseness = []; 143 | const visitedBuckets = []; 144 | 145 | for (let i = 0; i < numParticles; i++) { 146 | let p0 = this.particles[i]; 147 | 148 | let density = 0; 149 | let nearDensity = 0; 150 | 151 | let numNeighbors = 0; 152 | let numVisitedBuckets = 0; 153 | 154 | if (this.useSpatialHash) { 155 | // Compute density and near-density 156 | const bucketX = Math.floor(p0.posX * kernelRadiusInv); 157 | const bucketY = Math.floor(p0.posY * kernelRadiusInv); 158 | 159 | for (let bucketDX = -1; bucketDX <= 1; bucketDX++) { 160 | for (let bucketDY = -1; bucketDY <= 1; bucketDY++) { 161 | const bucketIdx = this.getHashBucketIdx(Math.floor(bucketX + bucketDX), Math.floor(bucketY + bucketDY)); 162 | 163 | // Check hash collision 164 | let found = false; 165 | for (let k = 0; k < numVisitedBuckets; k++) { 166 | if (visitedBuckets[k] === bucketIdx) { 167 | found = true; 168 | break; 169 | } 170 | } 171 | 172 | if (found) { 173 | continue; 174 | } 175 | 176 | visitedBuckets[numVisitedBuckets] = bucketIdx; 177 | numVisitedBuckets++; 178 | 179 | let neighborIdx = this.particleListHeads[bucketIdx]; 180 | 181 | while (neighborIdx != -1) { 182 | if (neighborIdx === i) { 183 | neighborIdx = this.particleListNextIdx[neighborIdx]; 184 | continue; 185 | } 186 | 187 | let p1 = this.particles[neighborIdx]; 188 | 189 | const diffX = p1.posX - p0.posX; 190 | 191 | if (diffX > kernelRadius || diffX < -kernelRadius) { 192 | neighborIdx = this.particleListNextIdx[neighborIdx]; 193 | continue; 194 | } 195 | 196 | const diffY = p1.posY - p0.posY; 197 | 198 | if (diffY > kernelRadius || diffY < -kernelRadius) { 199 | neighborIdx = this.particleListNextIdx[neighborIdx]; 200 | continue; 201 | } 202 | 203 | const rSq = diffX * diffX + diffY * diffY; 204 | 205 | if (rSq < kernelRadiusSq) { 206 | const r = Math.sqrt(rSq); 207 | const q = r * kernelRadiusInv; 208 | const closeness = 1 - q; 209 | const closenessSq = closeness * closeness; 210 | 211 | density += closeness * closeness; 212 | nearDensity += closeness * closenessSq; 213 | 214 | neighborIndices[numNeighbors] = neighborIdx; 215 | neighborUnitX[numNeighbors] = diffX / r; 216 | neighborUnitY[numNeighbors] = diffY / r; 217 | neighborCloseness[numNeighbors] = closeness; 218 | numNeighbors++; 219 | } 220 | 221 | neighborIdx = this.particleListNextIdx[neighborIdx]; 222 | } 223 | } 224 | } 225 | } else { 226 | // The old n^2 way 227 | 228 | for (let j = 0; j < numParticles; j++) { 229 | if (i === j) { 230 | continue; 231 | } 232 | 233 | let p1 = this.particles[j]; 234 | 235 | const diffX = p1.posX - p0.posX; 236 | 237 | if (diffX > kernelRadius || diffX < -kernelRadius) { 238 | continue; 239 | } 240 | 241 | const diffY = p1.posY - p0.posY; 242 | 243 | if (diffY > kernelRadius || diffY < -kernelRadius) { 244 | continue; 245 | } 246 | 247 | const rSq = diffX * diffX + diffY * diffY; 248 | 249 | if (rSq < kernelRadiusSq) { 250 | const r = Math.sqrt(rSq); 251 | const q = r / kernelRadius; 252 | const closeness = 1 - q; 253 | const closenessSq = closeness * closeness; 254 | 255 | density += closeness * closeness; 256 | nearDensity += closeness * closenessSq; 257 | 258 | neighborIndices[numNeighbors] = j; 259 | neighborUnitX[numNeighbors] = diffX / r; 260 | neighborUnitY[numNeighbors] = diffY / r; 261 | neighborCloseness[numNeighbors] = closeness; 262 | numNeighbors++; 263 | } 264 | } 265 | } 266 | 267 | // Compute pressure and near-pressure 268 | let pressure = stiffness * (density - restDensity); 269 | let nearPressure = nearStiffness * nearDensity; 270 | 271 | if (pressure > 1) { 272 | pressure = 1; 273 | } 274 | 275 | if (nearPressure > 1) { 276 | nearPressure = 1; 277 | } 278 | 279 | let dispX = 0; 280 | let dispY = 0; 281 | 282 | for (let j = 0; j < numNeighbors; j++) { 283 | let p1 = this.particles[neighborIndices[j]]; 284 | 285 | const closeness = neighborCloseness[j]; 286 | const D = dt * dt * (pressure * closeness + nearPressure * closeness * closeness) / 2; 287 | const DX = D * neighborUnitX[j]; 288 | const DY = D * neighborUnitY[j]; 289 | 290 | p1.posX += DX; 291 | p1.posY += DY; 292 | 293 | dispX -= DX; 294 | dispY -= DY; 295 | 296 | // p0.posX -= DX; 297 | // p0.posY -= DY; 298 | } 299 | 300 | p0.posX += dispX; 301 | p0.posY += dispY; 302 | } 303 | } 304 | 305 | // Mueller 10 minute physics 306 | getHashBucketIdx(bucketX, bucketY) { 307 | const h = ((bucketX * 92837111) ^ (bucketY * 689287499)); 308 | return Math.abs(h) % this.numHashBuckets; 309 | } 310 | 311 | populateHashGrid() { 312 | // Clear the hash grid 313 | for (let i = 0; i < this.numHashBuckets; i++) { 314 | this.particleListHeads[i] = -1; 315 | } 316 | 317 | // Populate the hash grid 318 | const numParticles = this.particles.length; 319 | const bucketSize = this.material.kernelRadius; // Same as kernel radius 320 | const bucketSizeInv = 1.0 / bucketSize; 321 | 322 | for (let i = 0; i < numParticles; i++) { 323 | let p = this.particles[i]; 324 | 325 | const bucketX = Math.floor(p.posX * bucketSizeInv); 326 | const bucketY = Math.floor(p.posY * bucketSizeInv); 327 | 328 | const bucketIdx = this.getHashBucketIdx(bucketX, bucketY); 329 | 330 | this.particleListNextIdx[i] = this.particleListHeads[bucketIdx]; 331 | this.particleListHeads[bucketIdx] = i; 332 | } 333 | } 334 | 335 | applySpringDisplacements(dt) { } 336 | adjustSprings(dt) { } 337 | applyViscosity(dt) { } 338 | resolveCollisions(dt) { 339 | const boundaryMul = 0.5 * dt; // 1 is no bounce, 2 is full bounce 340 | const boundaryMinX = 5; 341 | const boundaryMaxX = this.width - 5; 342 | const boundaryMinY = 5; 343 | const boundaryMaxY = this.height - 5; 344 | 345 | 346 | for (let p of this.particles) { 347 | if (p.posX < boundaryMinX) { 348 | p.posX += boundaryMul * (boundaryMinX - p.posX); 349 | } else if (p.posX > boundaryMaxX) { 350 | p.posX += boundaryMul * (boundaryMaxX - p.posX); 351 | } 352 | 353 | if (p.posY < boundaryMinY) { 354 | p.posY += boundaryMul * (boundaryMinY - p.posY); 355 | } else if (p.posY > boundaryMaxY) { 356 | p.posY += boundaryMul * (boundaryMaxY - p.posY); 357 | } 358 | } 359 | } 360 | } 361 | -------------------------------------------------------------------------------- /sim_3.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PVFS 2.2 - Iterate by Bucket Instead of Particle 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

FPS: 0

18 |

19 | 20 | 26 |

27 |

28 | 29 | 30 |

31 |

32 | 33 | 34 |

35 | 36 |

37 | 38 | 39 |

40 | 41 |

42 | 43 | 44 |

45 | 46 |

47 | 48 | 49 |

50 | 51 |

52 | 53 | 54 |

55 | 56 |

57 | 58 | 59 |

60 | 61 |

62 | 63 | 64 |

65 | 66 |

67 | 68 | 69 | 70 | 71 |

72 | 73 |

74 | 75 | KB: (A)ttract, (R)epel, (E)mit, (D)rain 76 | 77 |
78 | 79 | You can drag mouse as well as window! 80 | 81 |

82 | 83 |

84 | Prev 85 | | 86 | Next 87 |

88 |
89 | 90 |

91 | 92 |

93 |
94 | 95 | 96 | 97 | 98 | 99 | 100 | 101 | -------------------------------------------------------------------------------- /sim_3.js: -------------------------------------------------------------------------------- 1 | class Particle { 2 | constructor(posX, posY, velX, velY) { 3 | this.posX = posX; 4 | this.posY = posY; 5 | 6 | this.prevX = posX; 7 | this.prevY = posY; 8 | 9 | this.velX = velX; 10 | this.velY = velY; 11 | } 12 | } 13 | 14 | function moveParticleData(dst, src) { 15 | dst.posX = src.posX; 16 | dst.posY = src.posY; 17 | dst.prevX = src.prevX; 18 | dst.prevY = src.prevY; 19 | dst.velX = src.velX; 20 | dst.velY = src.velY; 21 | } 22 | 23 | class Material { 24 | constructor(name, restDensity, stiffness, nearStiffness, kernelRadius) { 25 | this.name = name; 26 | this.restDensity = restDensity; 27 | this.stiffness = stiffness; 28 | this.nearStiffness = nearStiffness; 29 | this.kernelRadius = kernelRadius; 30 | this.pointSize = 5; 31 | this.gravX = 0.0; 32 | this.gravY = 0.5; 33 | this.dt = 1; 34 | 35 | this.maxPressure = 1; 36 | } 37 | } 38 | 39 | class Simulator { 40 | constructor(width, height, numParticles) { 41 | this.running = false; 42 | 43 | this.width = width; 44 | this.height = height; 45 | 46 | this.particles = []; 47 | this.addParticles(numParticles); 48 | 49 | this.screenX = window.screenX; 50 | this.screenY = window.screenY; 51 | this.screenMoveSmootherX = 0; 52 | this.screenMoveSmootherY = 0; 53 | 54 | this.mouseX = width / 2; 55 | this.mouseY = height / 2; 56 | this.attract = false; 57 | this.repel = false; 58 | this.emit = false; 59 | this.drain = false; 60 | this.drag = false; 61 | 62 | this.mousePrevX = this.mouseX; 63 | this.mousePrevY = this.mouseY; 64 | 65 | this.useSpatialHash = true; 66 | this.numHashBuckets = 5000; 67 | this.numActiveBuckets = 0; 68 | this.activeBuckets = []; 69 | this.particleListHeads = []; // Same size as numHashBuckets, each points to first particle in bucket list 70 | 71 | for (let i = 0; i < this.numHashBuckets; i++) { 72 | this.particleListHeads.push(-1); 73 | this.activeBuckets.push(0); 74 | } 75 | 76 | this.particleListNextIdx = []; // Same size as particles list, each points to next particle in bucket list 77 | 78 | this.material = new Material("water", 4, 0.5, 0.5, 40); 79 | } 80 | 81 | start() { this.running = true; } 82 | pause() { this.running = false; } 83 | 84 | resize(width, height) { 85 | this.width = width; 86 | this.height = height; 87 | } 88 | 89 | addParticles(count) { 90 | for (let i = 0; i < count; i++) { 91 | const posX = Math.random() * this.width; 92 | const posY = Math.random() * this.height; 93 | const velX = Math.random() * 2 - 1; 94 | const velY = Math.random() * 2 - 1; 95 | 96 | this.particles.push(new Particle(posX, posY, velX, velY)); 97 | } 98 | } 99 | 100 | emitParticles() { 101 | const emitRate = 10; 102 | 103 | for (let i = 0; i < emitRate; i++) { 104 | const posX = this.mouseX + Math.random() * 10 - 5; 105 | const posY = this.mouseY + Math.random() * 10 - 5; 106 | const velX = Math.random() * 2 - 1; 107 | const velY = Math.random() * 2 - 1; 108 | 109 | this.particles.push(new Particle(posX, posY, velX, velY)); 110 | } 111 | } 112 | 113 | drainParticles() { 114 | let numParticles = this.particles.length; 115 | 116 | for (let i = 0; i < numParticles; i++) { 117 | let p = this.particles[i]; 118 | 119 | const dx = p.posX - this.mouseX; 120 | const dy = p.posY - this.mouseY; 121 | const distSq = dx * dx + dy * dy; 122 | 123 | if (distSq < 10000) { 124 | moveParticleData(p, this.particles[numParticles - 1]); 125 | numParticles--; 126 | } 127 | } 128 | 129 | this.particles.length = numParticles; 130 | } 131 | 132 | draw(ctx) { 133 | ctx.save(); 134 | 135 | const pointSize = this.material.pointSize; 136 | 137 | ctx.translate(-.5 * pointSize, -.5 * pointSize); 138 | 139 | for (let p of this.particles) { 140 | const speed = (p.velX * p.velX + p.velY * p.velY) * 2; 141 | ctx.fillStyle = `rgb(${speed}, ${speed * 0.4 + 153}, 255)`; 142 | 143 | ctx.fillRect(p.posX, p.posY, pointSize, pointSize); 144 | } 145 | 146 | ctx.restore(); 147 | 148 | // ctx.beginPath(); 149 | 150 | // ctx.strokeStyle = "#0066FF"; 151 | 152 | // for (let p of this.particles) { 153 | // ctx.moveTo(p.posX, p.posY); 154 | // ctx.lineTo(p.posX - p.velX, p.posY - p.velY); 155 | // } 156 | 157 | // ctx.stroke(); 158 | 159 | } 160 | 161 | // Algorithm 1: Simulation step 162 | update() { 163 | this.screenMoveSmootherX += window.screenX - this.screenX; 164 | this.screenMoveSmootherY += window.screenY - this.screenY; 165 | this.screenX = window.screenX; 166 | this.screenY = window.screenY; 167 | 168 | const maxScreenMove = 50; 169 | const screenMoveX = this.screenMoveSmootherX > maxScreenMove ? maxScreenMove : this.screenMoveSmootherX < -maxScreenMove ? -maxScreenMove : this.screenMoveSmootherX; 170 | const screenMoveY = this.screenMoveSmootherY > maxScreenMove ? maxScreenMove : this.screenMoveSmootherY < -maxScreenMove ? -maxScreenMove : this.screenMoveSmootherY; 171 | 172 | this.screenMoveSmootherX -= screenMoveX; 173 | this.screenMoveSmootherY -= screenMoveY; 174 | 175 | const dragX = this.mouseX - this.mousePrevX; 176 | const dragY = this.mouseY - this.mousePrevY; 177 | this.mousePrevX = this.mouseX; 178 | this.mousePrevY = this.mouseY; 179 | 180 | if (!this.running) { 181 | return; 182 | } 183 | 184 | if (this.emit) { 185 | this.emitParticles(); 186 | } 187 | 188 | if (this.drain) { 189 | this.drainParticles(); 190 | } 191 | 192 | const dt = this.material.dt; 193 | 194 | const gravX = 0.02 * this.material.kernelRadius * this.material.gravX * dt; 195 | const gravY = 0.02 * this.material.kernelRadius * this.material.gravY * dt; 196 | 197 | let attractRepel = this.attract ? 0.01 * this.material.kernelRadius : 0; 198 | attractRepel -= this.repel ? 0.01 * this.material.kernelRadius : 0; 199 | const arNonZero = attractRepel !== 0; 200 | 201 | for (let p of this.particles) { 202 | // apply gravity 203 | p.velX += gravX; 204 | p.velY += gravY; 205 | 206 | if (arNonZero) { 207 | let dx = p.posX - this.mouseX; 208 | let dy = p.posY - this.mouseY; 209 | const distSq = dx * dx + dy * dy; 210 | 211 | if (distSq < 100000 && distSq > 0.1) { 212 | const dist = Math.sqrt(distSq); 213 | const invDist = 1 / dist; 214 | 215 | dx *= invDist; 216 | dy *= invDist; 217 | 218 | p.velX -= attractRepel * dx; 219 | p.velY -= attractRepel * dy; 220 | } 221 | } 222 | 223 | if (this.drag) { 224 | let dx = p.posX - this.mouseX; 225 | let dy = p.posY - this.mouseY; 226 | const distSq = dx * dx + dy * dy; 227 | 228 | if (distSq < 10000 && distSq > 0.1) { 229 | const dist = Math.sqrt(distSq); 230 | const invDist = 1 / dist; 231 | 232 | p.velX = dragX; 233 | p.velY = dragY; 234 | } 235 | } 236 | 237 | p.posX -= screenMoveX; 238 | p.posY -= screenMoveY; 239 | } 240 | 241 | this.applyViscosity(dt); 242 | 243 | const boundaryMul = 0.5 * dt; // 1 is no bounce, 2 is full bounce 244 | const boundaryMinX = 5; 245 | const boundaryMaxX = this.width - 5; 246 | const boundaryMinY = 5; 247 | const boundaryMaxY = this.height - 5; 248 | 249 | for (let p of this.particles) { 250 | // save previous position 251 | p.prevX = p.posX; 252 | p.prevY = p.posY; 253 | 254 | // advance to predicted position 255 | p.posX += p.velX * dt; 256 | p.posY += p.velY * dt; 257 | 258 | // Could do boundary both before and after density relaxation 259 | // if (p.posX < boundaryMinX) { 260 | // p.posX += boundaryMul * (boundaryMinX - p.posX); 261 | // } else if (p.posX > boundaryMaxX) { 262 | // p.posX += boundaryMul * (boundaryMaxX - p.posX); 263 | // } 264 | 265 | // if (p.posY < boundaryMinY) { 266 | // p.posY += boundaryMul * (boundaryMinY - p.posY); 267 | // } else if (p.posY > boundaryMaxY) { 268 | // p.posY += boundaryMul * (boundaryMaxY - p.posY); 269 | // } 270 | } 271 | 272 | this.populateHashGrid(); 273 | 274 | this.adjustSprings(dt); 275 | this.applySpringDisplacements(dt); 276 | this.doubleDensityRelaxation(dt); 277 | this.resolveCollisions(dt); 278 | 279 | const dtInv = 1 / dt; 280 | 281 | for (let p of this.particles) { 282 | // use previous position to calculate new velocity 283 | p.velX = (p.posX - p.prevX) * dtInv; 284 | p.velY = (p.posY - p.prevY) * dtInv; 285 | } 286 | } 287 | 288 | doubleDensityRelaxation(dt) { 289 | const numParticles = this.particles.length; 290 | const kernelRadius = this.material.kernelRadius; // h 291 | const kernelRadiusSq = kernelRadius * kernelRadius; 292 | const kernelRadiusInv = 1.0 / kernelRadius; 293 | 294 | const restDensity = this.material.restDensity; 295 | const stiffness = this.material.stiffness * dt * dt; 296 | const nearStiffness = this.material.nearStiffness * dt * dt; 297 | 298 | // Neighbor cache 299 | const neighbors = []; 300 | const neighborUnitX = []; 301 | const neighborUnitY = []; 302 | const neighborCloseness = []; 303 | const visitedBuckets = []; 304 | 305 | const numActiveBuckets = this.numActiveBuckets; 306 | 307 | const wallCloseness = [0, 0]; // x, y 308 | const wallDirection = [0, 0]; // x, y 309 | 310 | const boundaryMinX = 5; 311 | const boundaryMaxX = this.width - 5; 312 | const boundaryMinY = 5; 313 | const boundaryMaxY = this.height - 5; 314 | 315 | const softMinX = boundaryMinX + kernelRadius; 316 | const softMaxX = boundaryMaxX - kernelRadius; 317 | const softMinY = boundaryMinY + kernelRadius; 318 | const softMaxY = boundaryMaxY - kernelRadius; 319 | 320 | 321 | for (let abIdx = 0; abIdx < numActiveBuckets; abIdx++) { 322 | let selfIdx = this.particleListHeads[this.activeBuckets[abIdx]]; 323 | 324 | while (selfIdx != -1) { 325 | let p0 = this.particles[selfIdx]; 326 | 327 | let density = 0; 328 | let nearDensity = 0; 329 | 330 | let numNeighbors = 0; 331 | let numVisitedBuckets = 0; 332 | 333 | // Compute density and near-density 334 | const bucketX = Math.floor(p0.posX * kernelRadiusInv); 335 | const bucketY = Math.floor(p0.posY * kernelRadiusInv); 336 | 337 | for (let bucketDX = -1; bucketDX <= 1; bucketDX++) { 338 | for (let bucketDY = -1; bucketDY <= 1; bucketDY++) { 339 | const bucketIdx = this.getHashBucketIdx(Math.floor(bucketX + bucketDX), Math.floor(bucketY + bucketDY)); 340 | 341 | // Check hash collision 342 | let found = false; 343 | for (let k = 0; k < numVisitedBuckets; k++) { 344 | if (visitedBuckets[k] === bucketIdx) { 345 | found = true; 346 | break; 347 | } 348 | } 349 | 350 | if (found) { 351 | continue; 352 | } 353 | 354 | visitedBuckets[numVisitedBuckets] = bucketIdx; 355 | numVisitedBuckets++; 356 | 357 | let neighborIdx = this.particleListHeads[bucketIdx]; 358 | 359 | while (neighborIdx != -1) { 360 | if (neighborIdx === selfIdx) { 361 | neighborIdx = this.particleListNextIdx[neighborIdx]; 362 | continue; 363 | } 364 | 365 | let p1 = this.particles[neighborIdx]; 366 | 367 | const diffX = p1.posX - p0.posX; 368 | 369 | if (diffX > kernelRadius || diffX < -kernelRadius) { 370 | neighborIdx = this.particleListNextIdx[neighborIdx]; 371 | continue; 372 | } 373 | 374 | const diffY = p1.posY - p0.posY; 375 | 376 | if (diffY > kernelRadius || diffY < -kernelRadius) { 377 | neighborIdx = this.particleListNextIdx[neighborIdx]; 378 | continue; 379 | } 380 | 381 | const rSq = diffX * diffX + diffY * diffY; 382 | 383 | if (rSq < kernelRadiusSq) { 384 | const r = Math.sqrt(rSq); 385 | const q = r * kernelRadiusInv; 386 | const closeness = 1 - q; 387 | const closenessSq = closeness * closeness; 388 | 389 | density += closeness * closeness; 390 | nearDensity += closeness * closenessSq; 391 | 392 | neighbors[numNeighbors] = this.particles[neighborIdx]; 393 | neighborUnitX[numNeighbors] = diffX / r; 394 | neighborUnitY[numNeighbors] = diffY / r; 395 | neighborCloseness[numNeighbors] = closeness; 396 | numNeighbors++; 397 | } 398 | 399 | neighborIdx = this.particleListNextIdx[neighborIdx]; 400 | } 401 | } 402 | } 403 | 404 | // Add wall density 405 | // if (p0.posX < softMinX) { 406 | // wallCloseness[0] = 1 - (softMinX - Math.max(boundaryMinX, p0.posX)) * kernelRadiusInv; 407 | // } else if (p0.posX > softMaxX) { 408 | // wallCloseness[0] = 1 - (Math.min(boundaryMaxX, p0.posX) - softMaxX) * kernelRadiusInv; 409 | // } else { 410 | // wallCloseness[0] = 0; 411 | // } 412 | 413 | // if (p0.posY < softMinY) { 414 | // wallCloseness[1] = 1 - (softMinY - Math.max(boundaryMinY, p0.posY)) * kernelRadiusInv; 415 | // } else if (p0.posY > softMaxY) { 416 | // wallCloseness[1] = 1 - (Math.min(boundaryMaxY, p0.posY) - softMaxY) * kernelRadiusInv; 417 | // } else { 418 | // wallCloseness[1] = 0; 419 | // } 420 | 421 | // const wallMul = 1; 422 | 423 | // if (wallCloseness[0] > 0) { 424 | // density += wallMul * wallCloseness[0] * wallCloseness[0]; 425 | // nearDensity += wallMul * wallCloseness[0] * wallCloseness[0] * wallCloseness[0]; 426 | // } 427 | 428 | // if (wallCloseness[1] > 0) { 429 | // density += wallMul * wallCloseness[1] * wallCloseness[1]; 430 | // nearDensity += wallMul * wallCloseness[1] * wallCloseness[1] * wallCloseness[1]; 431 | // } 432 | 433 | // Compute pressure and near-pressure 434 | let pressure = stiffness * (density - restDensity); 435 | let nearPressure = nearStiffness * nearDensity; 436 | let immisciblePressure = stiffness * (density - 0); 437 | 438 | // Optional: Clamp pressure for stability 439 | // const pressureSum = pressure + nearPressure; 440 | 441 | // if (pressureSum > 1) { 442 | // const pressureMul = 1 / pressureSum; 443 | // pressure *= pressureMul; 444 | // nearPressure *= pressureMul; 445 | // } 446 | 447 | 448 | if (pressure > 1) { 449 | pressure = 1; 450 | } 451 | 452 | if (nearPressure > 1) { 453 | nearPressure = 1; 454 | } 455 | 456 | let dispX = 0; 457 | let dispY = 0; 458 | 459 | for (let j = 0; j < numNeighbors; j++) { 460 | let p1 = neighbors[j]; 461 | 462 | const closeness = neighborCloseness[j]; 463 | const D = (pressure * closeness + nearPressure * closeness * closeness) / 2; 464 | const DX = D * neighborUnitX[j]; 465 | const DY = D * neighborUnitY[j]; 466 | 467 | p1.posX += DX; 468 | p1.posY += DY; 469 | 470 | dispX -= DX; 471 | dispY -= DY; 472 | 473 | // p0.posX -= DX; 474 | // p0.posY -= DY; 475 | } 476 | 477 | p0.posX += dispX; 478 | p0.posY += dispY; 479 | 480 | selfIdx = this.particleListNextIdx[selfIdx]; 481 | } 482 | } 483 | } 484 | 485 | // Mueller 10 minute physics 486 | getHashBucketIdx(bucketX, bucketY) { 487 | const h = ((bucketX * 92837111) ^ (bucketY * 689287499)); 488 | return Math.abs(h) % this.numHashBuckets; 489 | } 490 | 491 | populateHashGrid() { 492 | // Clear the hash grid 493 | for (let i = 0; i < this.numActiveBuckets; i++) { 494 | this.particleListHeads[this.activeBuckets[i]] = -1; 495 | } 496 | 497 | for (let i = 0; i < this.numHashBuckets; i++) { 498 | this.particleListHeads[i] = -1; 499 | } 500 | 501 | this.numActiveBuckets = 0; 502 | 503 | // Populate the hash grid 504 | const numParticles = this.particles.length; 505 | const bucketSize = this.material.kernelRadius; // Same as kernel radius 506 | const bucketSizeInv = 1.0 / bucketSize; 507 | 508 | for (let i = 0; i < numParticles; i++) { 509 | let p = this.particles[i]; 510 | 511 | const bucketX = Math.floor(p.posX * bucketSizeInv); 512 | const bucketY = Math.floor(p.posY * bucketSizeInv); 513 | 514 | const bucketIdx = this.getHashBucketIdx(bucketX, bucketY); 515 | 516 | const headIdx = this.particleListHeads[bucketIdx]; 517 | 518 | if (headIdx === -1) { 519 | this.activeBuckets[this.numActiveBuckets] = bucketIdx; 520 | this.numActiveBuckets++; 521 | } 522 | 523 | this.particleListNextIdx[i] = headIdx; 524 | this.particleListHeads[bucketIdx] = i; 525 | } 526 | } 527 | 528 | applySpringDisplacements(dt) { } 529 | adjustSprings(dt) { } 530 | applyViscosity(dt) { } 531 | resolveCollisions(dt) { 532 | const boundaryMul = 0.5 * dt * dt; 533 | const boundaryMinX = 5; 534 | const boundaryMaxX = this.width - 5; 535 | const boundaryMinY = 5; 536 | const boundaryMaxY = this.height - 5; 537 | 538 | 539 | for (let p of this.particles) { 540 | if (p.posX < boundaryMinX) { 541 | p.posX += boundaryMul * (boundaryMinX - p.posX); 542 | } else if (p.posX > boundaryMaxX) { 543 | p.posX += boundaryMul * (boundaryMaxX - p.posX); 544 | } 545 | 546 | if (p.posY < boundaryMinY) { 547 | p.posY += boundaryMul * (boundaryMinY - p.posY); 548 | } else if (p.posY > boundaryMaxY) { 549 | p.posY += boundaryMul * (boundaryMaxY - p.posY); 550 | } 551 | } 552 | } 553 | } 554 | -------------------------------------------------------------------------------- /sim_4.js: -------------------------------------------------------------------------------- 1 | class Particle { 2 | constructor(posX, posY, velX, velY) { 3 | this.posX = posX; 4 | this.posY = posY; 5 | 6 | this.prevX = posX; 7 | this.prevY = posY; 8 | 9 | this.velX = velX; 10 | this.velY = velY; 11 | } 12 | } 13 | 14 | class Simulator { 15 | constructor(width, height, numParticles) { 16 | this.running = false; 17 | 18 | this.width = width; 19 | this.height = height; 20 | 21 | this.gravX = 0.0; 22 | this.gravY = 0.2; 23 | 24 | this.particles = []; 25 | this.addParticles(numParticles); 26 | 27 | this.screenX = window.screenX; 28 | this.screenY = window.screenY; 29 | 30 | this.useSpatialHash = true; 31 | this.numHashBuckets = 1000; 32 | this.numActiveBuckets = 0; 33 | this.activeBuckets = []; 34 | this.particleListHeads = []; // Same size as numHashBuckets, each points to first particle in bucket list 35 | 36 | for (let i = 0; i < this.numHashBuckets; i++) { 37 | this.particleListHeads.push(-1); 38 | this.activeBuckets.push(0); 39 | } 40 | 41 | this.particleListNextIdx = []; // Same size as particles list, each points to next particle in bucket list 42 | } 43 | 44 | start() { this.running = true; } 45 | pause() { this.running = false; } 46 | 47 | resize(width, height) { 48 | this.width = width; 49 | this.height = height; 50 | } 51 | 52 | addParticles(count) { 53 | for (let i = 0; i < count; i++) { 54 | const posX = Math.random() * this.width; 55 | const posY = Math.random() * this.height; 56 | const velX = Math.random() * 2 - 1; 57 | const velY = Math.random() * 2 - 1; 58 | 59 | this.particles.push(new Particle(posX, posY, velX, velY)); 60 | } 61 | } 62 | 63 | draw(ctx) { 64 | ctx.save(); 65 | ctx.translate(-5, -5); 66 | 67 | for (let p of this.particles) { 68 | ctx.fillRect(p.posX, p.posY, 10, 10); 69 | } 70 | 71 | ctx.restore(); 72 | } 73 | 74 | // Algorithm 1: Simulation step 75 | update(dt = 1) { 76 | if (!this.running) { 77 | return; 78 | } 79 | 80 | const screenMoveX = window.screenX - this.screenX; 81 | const screenMoveY = window.screenY - this.screenY; 82 | 83 | this.screenX = window.screenX; 84 | this.screenY = window.screenY; 85 | 86 | for (let p of this.particles) { 87 | // apply gravity 88 | p.velX += this.gravX * dt; 89 | p.velY += this.gravY * dt; 90 | 91 | p.posX -= screenMoveX; 92 | p.posY -= screenMoveY; 93 | } 94 | 95 | this.applyViscosity(dt); 96 | 97 | for (let p of this.particles) { 98 | // save previous position 99 | p.prevX = p.posX; 100 | p.prevY = p.posY; 101 | 102 | // advance to predicted position 103 | p.posX += p.velX * dt; 104 | p.posY += p.velY * dt; 105 | } 106 | 107 | this.populateHashGrid(); 108 | 109 | this.adjustSprings(dt); 110 | this.applySpringDisplacements(dt); 111 | this.doubleDensityRelaxation(dt); 112 | this.resolveCollisions(dt); 113 | 114 | for (let p of this.particles) { 115 | // use previous position to calculate new velocity 116 | p.velX = (p.posX - p.prevX) / dt; 117 | p.velY = (p.posY - p.prevY) / dt; 118 | } 119 | } 120 | 121 | doubleDensityRelaxation(dt) { 122 | const numParticles = this.particles.length; 123 | const kernelRadius = 40; // h 124 | const kernelRadiusSq = kernelRadius * kernelRadius; 125 | const kernelRadiusInv = 1.0 / kernelRadius; 126 | 127 | const restDensity = 2; 128 | const stiffness = .5; 129 | const nearStiffness = 0.5; 130 | 131 | // Neighbor cache 132 | const neighborIndices = []; 133 | const neighborUnitX = []; 134 | const neighborUnitY = []; 135 | const neighborCloseness = []; 136 | const visitedBuckets = []; 137 | 138 | const numActiveBuckets = this.numActiveBuckets; 139 | 140 | const centerBucketParticles = []; 141 | const centerBucketParticleVisited = []; 142 | const centerBucketX = []; 143 | const centerBucketY = []; 144 | 145 | 146 | for (let abIdx = 0; abIdx < numActiveBuckets; abIdx++) { 147 | let selfIdx = this.particleListHeads[this.activeBuckets[abIdx]]; 148 | 149 | let numBucketParticles = 0; 150 | 151 | while (selfIdx != -1) { 152 | const p = this.particles[selfIdx]; 153 | centerBucketParticles[numBucketParticles] = p; 154 | centerBucketParticleVisited[numBucketParticles] = false; 155 | centerBucketX[numBucketParticles] = Math.floor(p.posX * kernelRadiusInv); 156 | centerBucketY[numBucketParticles] = Math.floor(p.posY * kernelRadiusInv); 157 | 158 | numBucketParticles++; 159 | 160 | selfIdx = this.particleListNextIdx[selfIdx]; 161 | } 162 | 163 | let numVisited = 0; 164 | let firstUnvisitedIdx = 0; 165 | 166 | while (numVisited < numBucketParticles) { 167 | let bucketX = centerBucketX[firstUnvisitedIdx]; 168 | let bucketY = centerBucketY[firstUnvisitedIdx]; 169 | 170 | let mismatchFound = false; 171 | 172 | for (let visitIdx = firstUnvisitedIdx; visitIdx < numBucketParticles; visitIdx++) { 173 | if (centerBucketParticleVisited[visitIdx]) { 174 | continue; 175 | } 176 | 177 | if (centerBucketX[visitIdx] !== bucketX || centerBucketY[visitIdx] !== bucketY) { 178 | if (!mismatchFound) { 179 | firstUnvisitedIdx = visitIdx; 180 | mismatchFound = true; 181 | } 182 | 183 | continue; 184 | } 185 | 186 | 187 | 188 | centerBucketParticleVisited[visitIdx] = true; 189 | numVisited++; 190 | } 191 | } 192 | 193 | 194 | for (let i = 0; i < numBucketParticles; i++) { 195 | 196 | } 197 | } 198 | 199 | for (let abIdx = 0; abIdx < numActiveBuckets; abIdx++) { 200 | let selfIdx = this.particleListHeads[this.activeBuckets[abIdx]]; 201 | 202 | while (selfIdx != -1) { 203 | let p0 = this.particles[selfIdx]; 204 | 205 | let density = 0; 206 | let nearDensity = 0; 207 | 208 | let numNeighbors = 0; 209 | let numVisitedBuckets = 0; 210 | 211 | // Compute density and near-density 212 | const bucketX = Math.floor(p0.posX * kernelRadiusInv); 213 | const bucketY = Math.floor(p0.posY * kernelRadiusInv); 214 | 215 | for (let bucketDX = -1; bucketDX <= 1; bucketDX++) { 216 | for (let bucketDY = -1; bucketDY <= 1; bucketDY++) { 217 | const bucketIdx = this.getHashBucketIdx(Math.floor(bucketX + bucketDX), Math.floor(bucketY + bucketDY)); 218 | 219 | // Check hash collision 220 | let found = false; 221 | for (let k = 0; k < numVisitedBuckets; k++) { 222 | if (visitedBuckets[k] === bucketIdx) { 223 | found = true; 224 | break; 225 | } 226 | } 227 | 228 | if (found) { 229 | continue; 230 | } 231 | 232 | visitedBuckets[numVisitedBuckets] = bucketIdx; 233 | numVisitedBuckets++; 234 | 235 | let neighborIdx = this.particleListHeads[bucketIdx]; 236 | 237 | while (neighborIdx != -1) { 238 | if (neighborIdx === selfIdx) { 239 | neighborIdx = this.particleListNextIdx[neighborIdx]; 240 | continue; 241 | } 242 | 243 | let p1 = this.particles[neighborIdx]; 244 | 245 | const diffX = p1.posX - p0.posX; 246 | 247 | if (diffX > kernelRadius || diffX < -kernelRadius) { 248 | neighborIdx = this.particleListNextIdx[neighborIdx]; 249 | continue; 250 | } 251 | 252 | const diffY = p1.posY - p0.posY; 253 | 254 | if (diffY > kernelRadius || diffY < -kernelRadius) { 255 | neighborIdx = this.particleListNextIdx[neighborIdx]; 256 | continue; 257 | } 258 | 259 | const rSq = diffX * diffX + diffY * diffY; 260 | 261 | if (rSq < kernelRadiusSq) { 262 | const r = Math.sqrt(rSq); 263 | const q = r * kernelRadiusInv; 264 | const closeness = 1 - q; 265 | const closenessSq = closeness * closeness; 266 | 267 | density += closeness * closeness; 268 | nearDensity += closeness * closenessSq; 269 | 270 | neighborIndices[numNeighbors] = neighborIdx; 271 | neighborUnitX[numNeighbors] = diffX / r; 272 | neighborUnitY[numNeighbors] = diffY / r; 273 | neighborCloseness[numNeighbors] = closeness; 274 | numNeighbors++; 275 | } 276 | 277 | neighborIdx = this.particleListNextIdx[neighborIdx]; 278 | } 279 | } 280 | } 281 | 282 | 283 | // Add wall density 284 | const closestX = Math.min(p0.posX, this.width - p0.posX); 285 | const closestY = Math.min(p0.posY, this.height - p0.posY); 286 | 287 | // if (closestX < kernelRadius) { 288 | // const q = closestX / kernelRadius; 289 | // const closeness = 1 - q; 290 | // const closenessSq = closeness * closeness; 291 | 292 | // density += closeness * closeness; 293 | // nearDensity += closeness * closenessSq; 294 | // } 295 | 296 | // if (closestY < kernelRadius) { 297 | // const q = closestY / kernelRadius; 298 | // const closeness = 1 - q; 299 | // const closenessSq = closeness * closeness; 300 | 301 | // density += closeness * closeness; 302 | // nearDensity += closeness * closenessSq; 303 | // } 304 | 305 | // Compute pressure and near-pressure 306 | const pressure = stiffness * (density - restDensity); 307 | const nearPressure = nearStiffness * nearDensity; 308 | 309 | let dispX = 0; 310 | let dispY = 0; 311 | 312 | for (let j = 0; j < numNeighbors; j++) { 313 | let p1 = this.particles[neighborIndices[j]]; 314 | 315 | const closeness = neighborCloseness[j]; 316 | const D = dt * dt * (pressure * closeness + nearPressure * closeness * closeness) / 2; 317 | const DX = D * neighborUnitX[j]; 318 | const DY = D * neighborUnitY[j]; 319 | 320 | p1.posX += DX; 321 | p1.posY += DY; 322 | 323 | dispX -= DX; 324 | dispY -= DY; 325 | } 326 | 327 | p0.posX += dispX; 328 | p0.posY += dispY; 329 | 330 | selfIdx = this.particleListNextIdx[selfIdx]; 331 | } 332 | } 333 | } 334 | 335 | // Mueller 10 minute physics 336 | getHashBucketIdx(bucketX, bucketY) { 337 | const h = ((bucketX * 92837111) ^ (bucketY * 689287499)); 338 | return Math.abs(h) % this.numHashBuckets; 339 | } 340 | 341 | populateHashGrid() { 342 | // Clear the hash grid 343 | for (let i = 0; i < this.numActiveBuckets; i++) { 344 | this.particleListHeads[this.activeBuckets[i]] = -1; 345 | } 346 | 347 | for (let i = 0; i < this.numHashBuckets; i++) { 348 | this.particleListHeads[i] = -1; 349 | } 350 | 351 | this.numActiveBuckets = 0; 352 | 353 | // Populate the hash grid 354 | const numParticles = this.particles.length; 355 | const bucketSize = 40; // Same as kernel radius 356 | const bucketSizeInv = 1.0 / bucketSize; 357 | 358 | for (let i = 0; i < numParticles; i++) { 359 | let p = this.particles[i]; 360 | 361 | const bucketX = Math.floor(p.posX * bucketSizeInv); 362 | const bucketY = Math.floor(p.posY * bucketSizeInv); 363 | 364 | const bucketIdx = this.getHashBucketIdx(bucketX, bucketY); 365 | 366 | const head = this.particleListHeads[bucketIdx]; 367 | 368 | if (head === -1) { 369 | this.activeBuckets[this.numActiveBuckets] = bucketIdx; 370 | this.numActiveBuckets++; 371 | } 372 | 373 | this.particleListNextIdx[i] = head; 374 | this.particleListHeads[bucketIdx] = i; 375 | } 376 | } 377 | 378 | applySpringDisplacements(dt) { } 379 | adjustSprings(dt) { } 380 | applyViscosity(dt) { } 381 | resolveCollisions(dt) { 382 | const boundaryMul = 0.5 * dt; // 1 is no bounce, 2 is full bounce 383 | const boundaryMinX = 5; 384 | const boundaryMaxX = this.width - 5; 385 | const boundaryMinY = 5; 386 | const boundaryMaxY = this.height - 5; 387 | 388 | 389 | for (let p of this.particles) { 390 | if (p.posX < boundaryMinX) { 391 | p.posX += boundaryMul * (boundaryMinX - p.posX); 392 | } else if (p.posX > boundaryMaxX) { 393 | p.posX += boundaryMul * (boundaryMaxX - p.posX); 394 | } 395 | 396 | if (p.posY < boundaryMinY) { 397 | p.posY += boundaryMul * (boundaryMinY - p.posY); 398 | } else if (p.posY > boundaryMaxY) { 399 | p.posY += boundaryMul * (boundaryMaxY - p.posY); 400 | } 401 | } 402 | } 403 | } 404 | -------------------------------------------------------------------------------- /sim_4_unfinished.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PVFS 2.3 - Local Bucket "Cache" 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

FPS: 0

18 |

19 | 20 | 26 |

27 |

28 | 29 | 30 |

31 |

32 | 33 | 34 | 35 | 36 |

37 | 38 |

39 | 40 | KB: (A)ttract, (R)epel, (E)mit, (D)rain 41 | 42 |
43 | 44 | You can drag mouse as well as window! 45 | 46 |

47 | 48 |

49 | Prev 50 | | 51 | Next 52 |

53 |
54 | 55 |

56 | 57 |

58 |
59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | -------------------------------------------------------------------------------- /sim_5.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | PVFS - Viscoelastic 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 |
16 |
17 |

FPS: 0

18 |

19 | 20 | 26 |

27 |

28 | 29 | 30 |

31 |

32 | 33 | 34 |

35 | 36 |

37 | 38 | 39 |

40 | 41 |

42 | 43 | 44 |

45 | 46 |

47 | 48 | 49 |

50 | 51 |

52 | 53 | 54 |

55 | 56 |

57 | 58 | 59 |

60 | 61 |

62 | 63 | 64 |

65 | 66 |

67 | 68 | 69 | 70 |

71 | 72 | 73 |

74 | 75 |

76 | 77 | 78 |

79 | 80 |

81 | 82 | 83 |

84 | 85 |

86 | 87 | 88 |

89 | 90 |

91 | 92 | 93 |

94 | 95 |

96 | 97 | 98 | 99 | 100 |

101 | 102 |

103 | 104 | KB: (A)ttract, (R)epel, (E)mit, (D)rain 105 | 106 |
107 | 108 | You can drag mouse as well as window! 109 | 110 |

111 |

112 | Prev 113 |

114 |
115 | 116 |

117 | 118 |

119 |
120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | -------------------------------------------------------------------------------- /sim_5.js: -------------------------------------------------------------------------------- 1 | class Particle { 2 | constructor(posX, posY, velX, velY) { 3 | this.posX = posX; 4 | this.posY = posY; 5 | 6 | this.prevX = posX; 7 | this.prevY = posY; 8 | 9 | this.velX = velX; 10 | this.velY = velY; 11 | 12 | this.springs = {} 13 | } 14 | } 15 | 16 | function moveParticleData(dst, src) { 17 | dst.posX = src.posX; 18 | dst.posY = src.posY; 19 | dst.prevX = src.prevX; 20 | dst.prevY = src.prevY; 21 | dst.velX = src.velX; 22 | dst.velY = src.velY; 23 | 24 | dst.springs = src.springs; 25 | } 26 | 27 | class Material { 28 | constructor(name, restDensity, stiffness, nearStiffness, kernelRadius) { 29 | this.name = name; 30 | this.restDensity = restDensity; 31 | this.stiffness = stiffness; 32 | this.nearStiffness = nearStiffness; 33 | this.kernelRadius = kernelRadius; 34 | this.pointSize = 5; 35 | this.gravX = 0.0; 36 | this.gravY = 0.5; 37 | this.dt = 1; 38 | 39 | this.springStiffness = 0.0; 40 | this.plasticity = 0.5; // alpha 41 | this.yieldRatio = 0.25; // gamma 42 | this.minDistRatio = .25; // prevents the springs from getting too short 43 | 44 | this.linViscosity = 0.0; 45 | this.quadViscosity = 0.1; 46 | 47 | this.maxPressure = 1; 48 | } 49 | } 50 | 51 | class Spring { 52 | constructor(particleIdxA, particleIdxB, restLength) { 53 | this.particleIdxA = particleIdxA; 54 | this.particleIdxB = particleIdxB; 55 | this.restLength = restLength; 56 | } 57 | } 58 | 59 | class Simulator { 60 | constructor(width, height, numParticles) { 61 | this.running = false; 62 | 63 | this.width = width; 64 | this.height = height; 65 | 66 | this.particles = []; 67 | this.addParticles(numParticles); 68 | 69 | this.screenX = window.screenX; 70 | this.screenY = window.screenY; 71 | this.screenMoveSmootherX = 0; 72 | this.screenMoveSmootherY = 0; 73 | 74 | this.mouseX = width / 2; 75 | this.mouseY = height / 2; 76 | this.attract = false; 77 | this.repel = false; 78 | this.emit = false; 79 | this.drain = false; 80 | this.drag = false; 81 | 82 | this.mousePrevX = this.mouseX; 83 | this.mousePrevY = this.mouseY; 84 | 85 | this.useSpatialHash = true; 86 | this.numHashBuckets = 5000; 87 | this.numActiveBuckets = 0; 88 | this.activeBuckets = []; 89 | this.particleListHeads = []; // Same size as numHashBuckets, each points to first particle in bucket list 90 | 91 | for (let i = 0; i < this.numHashBuckets; i++) { 92 | this.particleListHeads.push(-1); 93 | this.activeBuckets.push(0); 94 | } 95 | 96 | this.particleListNextIdx = []; // Same size as particles list, each points to next particle in bucket list 97 | 98 | this.material = new Material("water", 4, 0.5, 0.5, 40); 99 | 100 | this.springHash = {}; 101 | } 102 | 103 | start() { this.running = true; } 104 | pause() { this.running = false; } 105 | 106 | resize(width, height) { 107 | this.width = width; 108 | this.height = height; 109 | } 110 | 111 | addParticles(count) { 112 | for (let i = 0; i < count; i++) { 113 | const posX = Math.random() * this.width; 114 | const posY = Math.random() * this.height; 115 | const velX = Math.random() * 2 - 1; 116 | const velY = Math.random() * 2 - 1; 117 | 118 | this.particles.push(new Particle(posX, posY, velX, velY)); 119 | } 120 | } 121 | 122 | emitParticles() { 123 | const emitRate = 10; 124 | 125 | for (let i = 0; i < emitRate; i++) { 126 | const posX = this.mouseX + Math.random() * 10 - 5; 127 | const posY = this.mouseY + Math.random() * 10 - 5; 128 | const velX = Math.random() * 2 - 1; 129 | const velY = Math.random() * 2 - 1; 130 | 131 | this.particles.push(new Particle(posX, posY, velX, velY)); 132 | } 133 | } 134 | 135 | drainParticles() { 136 | let numParticles = this.particles.length; 137 | 138 | const affectedIds = []; 139 | 140 | for (let i = 0; i < numParticles; i++) { 141 | let p = this.particles[i]; 142 | 143 | const dx = p.posX - this.mouseX; 144 | const dy = p.posY - this.mouseY; 145 | const distSq = dx * dx + dy * dy; 146 | 147 | if (distSq < 10000) { 148 | affectedIds.push(i); 149 | affectedIds.push(numParticles - 1); 150 | moveParticleData(p, this.particles[numParticles - 1]); 151 | numParticles--; 152 | } 153 | } 154 | 155 | this.particles.length = numParticles; 156 | 157 | 158 | console.log(affectedIds); 159 | 160 | for (let p of this.particles) { 161 | for (let i of affectedIds) { 162 | delete p.springs[i]; 163 | } 164 | } 165 | } 166 | 167 | draw(ctx) { 168 | ctx.save(); 169 | 170 | const pointSize = this.material.pointSize; 171 | 172 | ctx.translate(-.5 * pointSize, -.5 * pointSize); 173 | 174 | ctx.fillStyle = "#00CC00"; 175 | 176 | for (let p of this.particles) { 177 | const speed = (p.velX * p.velX + p.velY * p.velY) * 2; 178 | ctx.fillStyle = `rgb(${speed}, 255, 0)`; 179 | 180 | ctx.fillRect(p.posX, p.posY, pointSize, pointSize); 181 | } 182 | 183 | ctx.restore(); 184 | 185 | // ctx.beginPath(); 186 | 187 | // ctx.strokeStyle = "#0066FF"; 188 | 189 | // for (let p of this.particles) { 190 | // ctx.moveTo(p.posX, p.posY); 191 | // ctx.lineTo(p.posX - p.velX, p.posY - p.velY); 192 | // } 193 | 194 | // ctx.stroke(); 195 | 196 | } 197 | 198 | // Algorithm 1: Simulation step 199 | update() { 200 | this.screenMoveSmootherX += window.screenX - this.screenX; 201 | this.screenMoveSmootherY += window.screenY - this.screenY; 202 | this.screenX = window.screenX; 203 | this.screenY = window.screenY; 204 | 205 | const maxScreenMove = 50; 206 | const screenMoveX = this.screenMoveSmootherX > maxScreenMove ? maxScreenMove : this.screenMoveSmootherX < -maxScreenMove ? -maxScreenMove : this.screenMoveSmootherX; 207 | const screenMoveY = this.screenMoveSmootherY > maxScreenMove ? maxScreenMove : this.screenMoveSmootherY < -maxScreenMove ? -maxScreenMove : this.screenMoveSmootherY; 208 | 209 | this.screenMoveSmootherX -= screenMoveX; 210 | this.screenMoveSmootherY -= screenMoveY; 211 | 212 | const dragX = this.mouseX - this.mousePrevX; 213 | const dragY = this.mouseY - this.mousePrevY; 214 | this.mousePrevX = this.mouseX; 215 | this.mousePrevY = this.mouseY; 216 | 217 | if (!this.running) { 218 | return; 219 | } 220 | 221 | if (this.emit) { 222 | this.emitParticles(); 223 | } 224 | 225 | if (this.drain) { 226 | this.drainParticles(); 227 | } 228 | 229 | this.populateHashGrid(); 230 | 231 | const dt = this.material.dt; 232 | 233 | const gravX = 0.02 * this.material.kernelRadius * this.material.gravX * dt; 234 | const gravY = 0.02 * this.material.kernelRadius * this.material.gravY * dt; 235 | 236 | let attractRepel = this.attract ? 0.01 * this.material.kernelRadius : 0; 237 | attractRepel -= this.repel ? 0.01 * this.material.kernelRadius : 0; 238 | const arNonZero = attractRepel !== 0; 239 | 240 | for (let p of this.particles) { 241 | // apply gravity 242 | p.velX += gravX; 243 | p.velY += gravY; 244 | 245 | if (arNonZero) { 246 | let dx = p.posX - this.mouseX; 247 | let dy = p.posY - this.mouseY; 248 | const distSq = dx * dx + dy * dy; 249 | 250 | if (distSq < 100000 && distSq > 0.1) { 251 | const dist = Math.sqrt(distSq); 252 | const invDist = 1 / dist; 253 | 254 | dx *= invDist; 255 | dy *= invDist; 256 | 257 | p.velX -= attractRepel * dx; 258 | p.velY -= attractRepel * dy; 259 | } 260 | } 261 | 262 | if (this.drag) { 263 | let dx = p.posX - this.mouseX; 264 | let dy = p.posY - this.mouseY; 265 | const distSq = dx * dx + dy * dy; 266 | 267 | if (distSq < 10000 && distSq > 0.1) { 268 | const dist = Math.sqrt(distSq); 269 | const invDist = 1 / dist; 270 | 271 | p.velX = dragX; 272 | p.velY = dragY; 273 | } 274 | } 275 | 276 | p.posX -= screenMoveX; 277 | p.posY -= screenMoveY; 278 | } 279 | 280 | this.applyViscosity(dt); 281 | 282 | const boundaryMul = 0.5 * dt; // 1 is no bounce, 2 is full bounce 283 | const boundaryMinX = 5; 284 | const boundaryMaxX = this.width - 5; 285 | const boundaryMinY = 5; 286 | const boundaryMaxY = this.height - 5; 287 | 288 | for (let p of this.particles) { 289 | // save previous position 290 | p.prevX = p.posX; 291 | p.prevY = p.posY; 292 | 293 | // advance to predicted position 294 | p.posX += p.velX * dt; 295 | p.posY += p.velY * dt; 296 | 297 | // Could do boundary both before and after density relaxation 298 | // if (p.posX < boundaryMinX) { 299 | // p.posX += boundaryMul * (boundaryMinX - p.posX); 300 | // } else if (p.posX > boundaryMaxX) { 301 | // p.posX += boundaryMul * (boundaryMaxX - p.posX); 302 | // } 303 | 304 | // if (p.posY < boundaryMinY) { 305 | // p.posY += boundaryMul * (boundaryMinY - p.posY); 306 | // } else if (p.posY > boundaryMaxY) { 307 | // p.posY += boundaryMul * (boundaryMaxY - p.posY); 308 | // } 309 | } 310 | 311 | this.adjustSprings(dt); 312 | this.applySpringDisplacements(dt); 313 | this.doubleDensityRelaxation(dt); 314 | this.resolveCollisions(dt); 315 | 316 | const dtInv = 1 / dt; 317 | 318 | for (let p of this.particles) { 319 | // use previous position to calculate new velocity 320 | p.velX = (p.posX - p.prevX) * dtInv; 321 | p.velY = (p.posY - p.prevY) * dtInv; 322 | } 323 | } 324 | 325 | doubleDensityRelaxation(dt) { 326 | const numParticles = this.particles.length; 327 | const kernelRadius = this.material.kernelRadius; // h 328 | const kernelRadiusSq = kernelRadius * kernelRadius; 329 | const kernelRadiusInv = 1.0 / kernelRadius; 330 | 331 | const restDensity = this.material.restDensity; 332 | const stiffness = this.material.stiffness * dt * dt; 333 | const nearStiffness = this.material.nearStiffness * dt * dt; 334 | 335 | const minDistRatio = this.material.minDistRatio; 336 | const minDist = minDistRatio * kernelRadius; 337 | 338 | // Neighbor cache 339 | const neighbors = []; 340 | const neighborUnitX = []; 341 | const neighborUnitY = []; 342 | const neighborCloseness = []; 343 | const visitedBuckets = []; 344 | 345 | const numActiveBuckets = this.numActiveBuckets; 346 | 347 | const wallCloseness = [0, 0]; // x, y 348 | const wallDirection = [0, 0]; // x, y 349 | 350 | const boundaryMinX = 5; 351 | const boundaryMaxX = this.width - 5; 352 | const boundaryMinY = 5; 353 | const boundaryMaxY = this.height - 5; 354 | 355 | const softMinX = boundaryMinX + kernelRadius; 356 | const softMaxX = boundaryMaxX - kernelRadius; 357 | const softMinY = boundaryMinY + kernelRadius; 358 | const softMaxY = boundaryMaxY - kernelRadius; 359 | 360 | const addSprings = this.material.springStiffness > 0; 361 | 362 | 363 | for (let abIdx = 0; abIdx < numActiveBuckets; abIdx++) { 364 | let selfIdx = this.particleListHeads[this.activeBuckets[abIdx]]; 365 | 366 | while (selfIdx != -1) { 367 | let p0 = this.particles[selfIdx]; 368 | 369 | let density = 0; 370 | let nearDensity = 0; 371 | 372 | let numNeighbors = 0; 373 | let numVisitedBuckets = 0; 374 | 375 | // Compute density and near-density 376 | const bucketX = Math.floor(p0.posX * kernelRadiusInv); 377 | const bucketY = Math.floor(p0.posY * kernelRadiusInv); 378 | 379 | for (let bucketDX = -1; bucketDX <= 1; bucketDX++) { 380 | for (let bucketDY = -1; bucketDY <= 1; bucketDY++) { 381 | const bucketIdx = this.getHashBucketIdx(Math.floor(bucketX + bucketDX), Math.floor(bucketY + bucketDY)); 382 | 383 | // Check hash collision 384 | let found = false; 385 | for (let k = 0; k < numVisitedBuckets; k++) { 386 | if (visitedBuckets[k] === bucketIdx) { 387 | found = true; 388 | break; 389 | } 390 | } 391 | 392 | if (found) { 393 | continue; 394 | } 395 | 396 | visitedBuckets[numVisitedBuckets] = bucketIdx; 397 | numVisitedBuckets++; 398 | 399 | let neighborIdx = this.particleListHeads[bucketIdx]; 400 | 401 | while (neighborIdx != -1) { 402 | if (neighborIdx === selfIdx) { 403 | neighborIdx = this.particleListNextIdx[neighborIdx]; 404 | continue; 405 | } 406 | 407 | let p1 = this.particles[neighborIdx]; 408 | 409 | const diffX = p1.posX - p0.posX; 410 | 411 | if (diffX > kernelRadius || diffX < -kernelRadius) { 412 | neighborIdx = this.particleListNextIdx[neighborIdx]; 413 | continue; 414 | } 415 | 416 | const diffY = p1.posY - p0.posY; 417 | 418 | if (diffY > kernelRadius || diffY < -kernelRadius) { 419 | neighborIdx = this.particleListNextIdx[neighborIdx]; 420 | continue; 421 | } 422 | 423 | const rSq = diffX * diffX + diffY * diffY; 424 | 425 | if (rSq < kernelRadiusSq) { 426 | const r = Math.sqrt(rSq); 427 | const q = r * kernelRadiusInv; 428 | const closeness = 1 - q; 429 | const closenessSq = closeness * closeness; 430 | 431 | density += closeness * closeness; 432 | nearDensity += closeness * closenessSq; 433 | 434 | neighbors[numNeighbors] = p1; 435 | neighborUnitX[numNeighbors] = diffX / r; 436 | neighborUnitY[numNeighbors] = diffY / r; 437 | neighborCloseness[numNeighbors] = closeness; 438 | numNeighbors++; 439 | 440 | // Add spring if not already present 441 | // TODO: this JS hash thing is absolutely crazy but curious how it performs 442 | if (addSprings && selfIdx < neighborIdx && r > minDist && !p0.springs[neighborIdx]) { 443 | p0.springs[neighborIdx] = r; 444 | } 445 | } 446 | 447 | neighborIdx = this.particleListNextIdx[neighborIdx]; 448 | } 449 | } 450 | } 451 | 452 | 453 | // Compute pressure and near-pressure 454 | let pressure = stiffness * (density - restDensity); 455 | let nearPressure = nearStiffness * nearDensity; 456 | let immisciblePressure = stiffness * (density - 0); 457 | 458 | // Optional: Clamp pressure for stability 459 | // const pressureSum = pressure + nearPressure; 460 | 461 | // if (pressureSum > 1) { 462 | // const pressureMul = 1 / pressureSum; 463 | // pressure *= pressureMul; 464 | // nearPressure *= pressureMul; 465 | // } 466 | 467 | 468 | if (pressure > 1) { 469 | pressure = 1; 470 | } 471 | 472 | if (nearPressure > 1) { 473 | nearPressure = 1; 474 | } 475 | 476 | let dispX = 0; 477 | let dispY = 0; 478 | 479 | for (let j = 0; j < numNeighbors; j++) { 480 | let p1 = neighbors[j]; 481 | 482 | const closeness = neighborCloseness[j]; 483 | const D = (pressure * closeness + nearPressure * closeness * closeness) / 2; 484 | const DX = D * neighborUnitX[j]; 485 | const DY = D * neighborUnitY[j]; 486 | 487 | p1.posX += DX; 488 | p1.posY += DY; 489 | 490 | dispX -= DX; 491 | dispY -= DY; 492 | 493 | // p0.posX -= DX; 494 | // p0.posY -= DY; 495 | } 496 | 497 | p0.posX += dispX; 498 | p0.posY += dispY; 499 | 500 | selfIdx = this.particleListNextIdx[selfIdx]; 501 | } 502 | } 503 | } 504 | 505 | // Mueller 10 minute physics 506 | getHashBucketIdx(bucketX, bucketY) { 507 | const h = ((bucketX * 92837111) ^ (bucketY * 689287499)); 508 | return Math.abs(h) % this.numHashBuckets; 509 | } 510 | 511 | populateHashGrid() { 512 | // Clear the hash grid 513 | for (let i = 0; i < this.numActiveBuckets; i++) { 514 | this.particleListHeads[this.activeBuckets[i]] = -1; 515 | } 516 | 517 | for (let i = 0; i < this.numHashBuckets; i++) { 518 | this.particleListHeads[i] = -1; 519 | } 520 | 521 | this.numActiveBuckets = 0; 522 | 523 | // Populate the hash grid 524 | const numParticles = this.particles.length; 525 | const bucketSize = this.material.kernelRadius; // Same as kernel radius 526 | const bucketSizeInv = 1.0 / bucketSize; 527 | 528 | for (let i = 0; i < numParticles; i++) { 529 | let p = this.particles[i]; 530 | 531 | const bucketX = Math.floor(p.posX * bucketSizeInv); 532 | const bucketY = Math.floor(p.posY * bucketSizeInv); 533 | 534 | const bucketIdx = this.getHashBucketIdx(bucketX, bucketY); 535 | 536 | const headIdx = this.particleListHeads[bucketIdx]; 537 | 538 | if (headIdx === -1) { 539 | this.activeBuckets[this.numActiveBuckets] = bucketIdx; 540 | this.numActiveBuckets++; 541 | } 542 | 543 | this.particleListNextIdx[i] = headIdx; 544 | this.particleListHeads[bucketIdx] = i; 545 | } 546 | } 547 | 548 | applySpringDisplacements(dt) { 549 | if (this.material.springStiffness === 0) { 550 | return; 551 | } 552 | 553 | 554 | const kernelRadius = this.material.kernelRadius; // h 555 | const kernelRadiusInv = 1.0 / kernelRadius; 556 | 557 | const springStiffness = this.material.springStiffness * dt * dt; 558 | const plasticity = this.material.plasticity * dt; // alpha 559 | const yieldRatio = this.material.yieldRatio; // gamma 560 | const minDistRatio = this.material.minDistRatio; 561 | const minDist = minDistRatio * kernelRadius; 562 | 563 | for (let particle of this.particles) { 564 | // TODO: maybe optimize this by using a list of springs instead of a hash 565 | for (let springIdx of Object.keys(particle.springs)) { 566 | let restLength = particle.springs[springIdx]; 567 | 568 | let springParticle = this.particles[springIdx]; 569 | 570 | let dx = particle.posX - springParticle.posX; 571 | let dy = particle.posY - springParticle.posY; 572 | let dist = Math.sqrt(dx * dx + dy * dy); 573 | 574 | const tolerableDeformation = yieldRatio * restLength; 575 | 576 | 577 | if (dist > restLength + tolerableDeformation) { 578 | restLength = restLength + plasticity * (dist - restLength - tolerableDeformation); 579 | particle.springs[springIdx] = restLength; 580 | } else if (dist < restLength - tolerableDeformation && dist > minDist) { 581 | restLength = restLength - plasticity * (restLength - tolerableDeformation - dist); 582 | particle.springs[springIdx] = restLength; 583 | } 584 | 585 | if (restLength < minDist) { 586 | restLength = minDist; 587 | particle.springs[springIdx] = restLength; 588 | } 589 | 590 | if (restLength > kernelRadius) { 591 | delete particle.springs[springIdx]; 592 | continue; 593 | } 594 | 595 | let D = springStiffness * (1 - restLength * kernelRadiusInv) * (dist - restLength) / dist; 596 | dx *= D; 597 | dy *= D; 598 | 599 | particle.posX -= dx; 600 | particle.posY -= dy; 601 | 602 | springParticle.posX += dx; 603 | springParticle.posY += dy; 604 | } 605 | } 606 | } 607 | adjustSprings(dt) { 608 | // adjust springs has been moved to be done together with apply spring displacements 609 | } 610 | applyViscosity(dt) { 611 | if (this.material.linViscosity === 0 && this.material.quadViscosity === 0) { 612 | return; 613 | } 614 | 615 | const numActiveBuckets = this.numActiveBuckets; 616 | const visitedBuckets = []; 617 | 618 | const kernelRadius = this.material.kernelRadius; // h 619 | const kernelRadiusSq = kernelRadius * kernelRadius; 620 | const kernelRadiusInv = 1.0 / kernelRadius; 621 | 622 | const linViscosity = this.material.linViscosity * dt; 623 | const quadViscosity = this.material.quadViscosity * dt; 624 | 625 | for (let abIdx = 0; abIdx < numActiveBuckets; abIdx++) { 626 | let selfIdx = this.particleListHeads[this.activeBuckets[abIdx]]; 627 | 628 | while (selfIdx != -1) { 629 | let p0 = this.particles[selfIdx]; 630 | 631 | let density = 0; 632 | let nearDensity = 0; 633 | 634 | let numNeighbors = 0; 635 | let numVisitedBuckets = 0; 636 | 637 | // Compute density and near-density 638 | const bucketX = Math.floor(p0.posX * kernelRadiusInv); 639 | const bucketY = Math.floor(p0.posY * kernelRadiusInv); 640 | 641 | for (let bucketDX = -1; bucketDX <= 1; bucketDX++) { 642 | for (let bucketDY = -1; bucketDY <= 1; bucketDY++) { 643 | const bucketIdx = this.getHashBucketIdx(Math.floor(bucketX + bucketDX), Math.floor(bucketY + bucketDY)); 644 | 645 | // Check hash collision 646 | let found = false; 647 | for (let k = 0; k < numVisitedBuckets; k++) { 648 | if (visitedBuckets[k] === bucketIdx) { 649 | found = true; 650 | break; 651 | } 652 | } 653 | 654 | if (found) { 655 | continue; 656 | } 657 | 658 | visitedBuckets[numVisitedBuckets] = bucketIdx; 659 | numVisitedBuckets++; 660 | 661 | let neighborIdx = this.particleListHeads[bucketIdx]; 662 | 663 | while (neighborIdx != -1) { 664 | if (neighborIdx === selfIdx) { 665 | neighborIdx = this.particleListNextIdx[neighborIdx]; 666 | continue; 667 | } 668 | 669 | let p1 = this.particles[neighborIdx]; 670 | 671 | const diffX = p1.posX - p0.posX; 672 | 673 | if (diffX > kernelRadius || diffX < -kernelRadius) { 674 | neighborIdx = this.particleListNextIdx[neighborIdx]; 675 | continue; 676 | } 677 | 678 | const diffY = p1.posY - p0.posY; 679 | 680 | if (diffY > kernelRadius || diffY < -kernelRadius) { 681 | neighborIdx = this.particleListNextIdx[neighborIdx]; 682 | continue; 683 | } 684 | 685 | const rSq = diffX * diffX + diffY * diffY; 686 | 687 | if (rSq < kernelRadiusSq) { 688 | const r = Math.sqrt(rSq); 689 | const q = r * kernelRadiusInv; 690 | const closeness = 1 - q; 691 | const closenessSq = closeness * closeness; 692 | 693 | // inward radial velocity 694 | const dx = diffX / r; 695 | const dy = diffY / r; 696 | let inwardVel = ((p0.velX - p1.velX) * dx + (p0.velY - p1.velY) * dy); 697 | 698 | if (inwardVel > 1) { 699 | inwardVel = 1; 700 | } 701 | 702 | if (inwardVel > 0) { 703 | // linear and quadratic impulses 704 | const I = closeness * (linViscosity * inwardVel + quadViscosity * inwardVel * inwardVel) * .5; 705 | const IX = I * dx; 706 | const IY = I * dy; 707 | p0.velX -= IX; 708 | p0.velY -= IY; 709 | p1.velX += IX; 710 | p1.velY += IY; 711 | } 712 | } 713 | 714 | neighborIdx = this.particleListNextIdx[neighborIdx]; 715 | } 716 | } 717 | } 718 | 719 | selfIdx = this.particleListNextIdx[selfIdx]; 720 | } 721 | } 722 | } 723 | resolveCollisions(dt) { 724 | const boundaryMul = 0.5 * dt * dt; 725 | const boundaryMinX = 5; 726 | const boundaryMaxX = this.width - 5; 727 | const boundaryMinY = 5; 728 | const boundaryMaxY = this.height - 5; 729 | 730 | const kWallStickiness = 0.5; 731 | const kWallStickDist = 2; 732 | const stickMinX = boundaryMinX + kWallStickDist; 733 | const stickMaxX = boundaryMaxX - kWallStickDist; 734 | const stickMinY = boundaryMinY + kWallStickDist; 735 | const stickMaxY = boundaryMaxY - kWallStickDist; 736 | 737 | 738 | for (let p of this.particles) { 739 | if (p.posX < boundaryMinX) { 740 | p.posX += boundaryMul * (boundaryMinX - p.posX); 741 | } else if (p.posX > boundaryMaxX) { 742 | p.posX += boundaryMul * (boundaryMaxX - p.posX); 743 | } 744 | 745 | if (p.posY < boundaryMinY) { 746 | p.posY += boundaryMul * (boundaryMinY - p.posY); 747 | } else if (p.posY > boundaryMaxY) { 748 | p.posY += boundaryMul * (boundaryMaxY - p.posY); 749 | } 750 | } 751 | } 752 | } 753 | -------------------------------------------------------------------------------- /style.css: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | padding: 0; 4 | overflow: hidden; 5 | background-color: #111; 6 | } 7 | 8 | canvas { 9 | width: 100%; 10 | height: 100%; 11 | } 12 | 13 | #overlay { 14 | position: fixed; 15 | top: 0; 16 | left: 0; 17 | background-color: rgba(0, 100, 200, 0.25); 18 | color: white; 19 | padding: 0 1em; 20 | font-size: large; 21 | } 22 | 23 | a { 24 | color: white; 25 | } 26 | --------------------------------------------------------------------------------