├── .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 | 
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 | [](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 |
116 |
117 |
118 | Collapse
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 | # Particles:
19 |
20 | 1K
21 | 2K
22 | 5K
23 | 10K
24 |
25 |
26 |
27 | Use Spatial Hash:
28 |
29 |
30 |
31 | Start
32 | Pause
33 | Reset
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 | # Particles:
20 |
21 | 2K
22 | 10K
23 | 100K
24 | 1M
25 |
26 |
27 |
28 | Start
29 | Pause
30 | Step
31 | Reset
32 |
33 |
34 |
35 | You can drag window around.
36 |
37 |
38 |
39 | Next
40 |
41 |
42 |
43 | Collapse
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 | # Particles:
20 |
21 | 1K
22 | 2K
23 | 3K
24 | 4K
25 |
26 |
27 |
28 | Start
29 | Pause
30 | Step
31 | Reset
32 |
33 |
34 |
35 |
36 | You can drag window around.
37 |
38 |
39 |
40 |
41 | Prev
42 | |
43 | Next
44 |
45 |
46 |
47 |
48 | Collapse
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 | # Particles:
20 |
21 | 1K
22 | 2K
23 | 5K
24 | 10K
25 |
26 |
27 |
28 | Use Spatial Hash:
29 |
30 |
31 |
32 | Start
33 | Pause
34 | Step
35 | Reset
36 |
37 |
38 |
39 |
40 | You can drag window around.
41 |
42 |
43 |
44 |
45 | Prev
46 | |
47 | Next
48 |
49 |
50 |
51 |
52 | Collapse
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 | # Particles:
20 |
21 | 1K
22 | 2K
23 | 5K
24 | 10K
25 |
26 |
27 |
28 | Rest Density:
29 |
30 |
31 |
32 | Stiffness:
33 |
34 |
35 |
36 |
37 | Near Stiffness:
38 |
39 |
40 |
41 |
42 | Kernel Radius:
43 |
44 |
45 |
46 |
47 | Point Size:
48 |
49 |
50 |
51 |
52 | Gravity X:
53 |
54 |
55 |
56 |
57 | Gravity Y:
58 |
59 |
60 |
61 |
62 | Time Step:
63 |
64 |
65 |
66 |
67 | Start
68 | Pause
69 | Step
70 | Reset
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 | Collapse
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 | # Particles:
20 |
21 | 1K
22 | 2K
23 | 5K
24 | 10K
25 |
26 |
27 |
28 | Use Spatial Hash:
29 |
30 |
31 |
32 | Start
33 | Pause
34 | Step
35 | Reset
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 | Collapse
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 |
115 |
116 |
117 | Collapse
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 |
--------------------------------------------------------------------------------