1+ # Material Point Method Simulation
2+ import numpy as np # numpy for linear algebra
3+ import taichi as ti # taichi for fast and parallelized computation
4+
5+ ti .init (arch = ti .cpu )
6+
7+ # sampling material particles with poisson-disk sampling
8+ ################################
9+ # Poisson Disk Sampling Tool
10+ ################################
11+ def poisson_disk_sampling (radius , domain_size , k = 30 ):
12+ """Bridson's algorithm for Poisson-disk sampling in 2D"""
13+ cell_size = radius / np .sqrt (2 )
14+ grid_shape = (int (domain_size [0 ] / cell_size ) + 1 , int (domain_size [1 ] / cell_size ) + 1 )
15+ grid = - np .ones (grid_shape , dtype = int )
16+ samples = []
17+ active_list = []
18+
19+ def in_domain (p ):
20+ return 0 <= p [0 ] < domain_size [0 ] and 0 <= p [1 ] < domain_size [1 ]
21+
22+ def get_cell_coords (p ):
23+ return int (p [0 ] / cell_size ), int (p [1 ] / cell_size )
24+
25+ def get_nearby_samples (p ):
26+ i , j = get_cell_coords (p )
27+ neighbors = []
28+ for di in [- 2 , - 1 , 0 , 1 , 2 ]:
29+ for dj in [- 2 , - 1 , 0 , 1 , 2 ]:
30+ ni , nj = i + di , j + dj
31+ if 0 <= ni < grid .shape [0 ] and 0 <= nj < grid .shape [1 ]:
32+ idx = grid [ni , nj ]
33+ if idx != - 1 :
34+ neighbors .append (samples [idx ])
35+ return neighbors
36+
37+ # Start with a random point
38+ first_point = np .array ([np .random .uniform (0 , domain_size [0 ]), np .random .uniform (0 , domain_size [1 ])])
39+ samples .append (first_point )
40+ active_list .append (0 )
41+ grid [get_cell_coords (first_point )] = 0
42+
43+ while active_list :
44+ idx = np .random .choice (active_list )
45+ base_point = samples [idx ]
46+ found = False
47+ for _ in range (k ):
48+ angle = np .random .uniform (0 , 2 * np .pi )
49+ r = np .random .uniform (radius , 2 * radius )
50+ new_point = base_point + r * np .array ([np .cos (angle ), np .sin (angle )])
51+ if in_domain (new_point ):
52+ neighbors = get_nearby_samples (new_point )
53+ if all (np .linalg .norm (new_point - n ) >= radius for n in neighbors ):
54+ samples .append (new_point )
55+ active_list .append (len (samples ) - 1 )
56+ grid [get_cell_coords (new_point )] = len (samples ) - 1
57+ found = True
58+ if not found :
59+ active_list .remove (idx )
60+ return np .array (samples )
61+
62+ # ANCHOR: data_def
63+ # simulation setup
64+ grid_size = 128 # background Eulerian grid's resolution, in 2D is [128, 128]
65+ dx = 1.0 / grid_size # the domain size is [1m, 1m] in 2D, so dx for each cell is (1/128)m
66+ dt = 2e-4 # time step size in second
67+ ppc = 8 # average particle-per-cell
68+
69+ density = 400 # kg / m^3
70+ E , nu = 3.537e5 , 0.3 # sand's Young's modulus and Poisson's ratio
71+ mu , lam = E / (2 * (1 + nu )), E * nu / ((1 + nu ) * (1 - 2 * nu )) # sand's Lame parameters
72+ sdf_friction = 0.5 # frictional coefficient of SDF boundary condition
73+ friction_angle_in_degrees = 25.0 # Drucker Prager friction angle
74+ # ANCHOR: D_def
75+ D = (1. / 4. ) * dx * dx # constant D for Quadratic B-spline used for APIC
76+ # ANCHOR_END: D_def
77+
78+ # sampling material particles with poisson-disk sampling
79+ poisson_samples = poisson_disk_sampling (dx / np .sqrt (ppc ), [0.2 , 0.4 ]) # simulating a [30cm, 50cm] sand block
80+
81+ # material particles data
82+ N_particles = len (poisson_samples )
83+ x = ti .Vector .field (2 , float , N_particles ) # the position of particles
84+ x .from_numpy (np .array (poisson_samples ) + [0.4 , 0.55 ])
85+ v = ti .Vector .field (2 , float , N_particles ) # the velocity of particles
86+ vol = ti .field (float , N_particles ) # the volume of particle
87+ vol .fill (0.2 * 0.4 / N_particles ) # get the volume of each particle as V_rest / N_particles
88+ m = ti .field (float , N_particles ) # the mass of particle
89+ m .fill (vol [0 ] * density )
90+ F = ti .Matrix .field (2 , 2 , float , N_particles ) # the deformation gradient of particles
91+ F .from_numpy (np .tile (np .eye (2 ), (N_particles , 1 , 1 )))
92+ C = ti .Matrix .field (2 , 2 , float , N_particles ) # the affine-matrix of particles
93+
94+ diff_log_J = ti .field (float , N_particles ) # tracks changes in the log of the volume gained during extension
95+
96+ # grid data
97+ grid_m = ti .field (float , (grid_size , grid_size ))
98+ grid_v = ti .Vector .field (2 , float , (grid_size , grid_size ))
99+ # ANCHOR_END: data_def
100+
101+ # ANCHOR: reset_grid
102+ def reset_grid ():
103+ # after each transfer, the grid is reset
104+ grid_m .fill (0 )
105+ grid_v .fill (0 )
106+ # ANCHOR_END: reset_grid
107+
108+ ################################
109+ # Stvk Hencky Elasticity
110+ ################################
111+ # ANCHOR: stvk
112+ @ti .func
113+ def StVK_Hencky_PK1_2D (F ):
114+ U , sig , V = ti .svd (F )
115+ inv_sig = sig .inverse ()
116+ e = ti .Matrix ([[ti .log (sig [0 , 0 ]), 0 ], [0 , ti .log (sig [1 , 1 ])]])
117+ return U @ (2 * mu * inv_sig @ e + lam * e .trace () * inv_sig ) @ V .transpose ()
118+ # ANCHOR_END: stvk
119+
120+ ################################
121+ # Drucker Prager plasticity
122+ ################################
123+ # ANCHOR: drucker_prager
124+ @ti .func
125+ def Drucker_Prager_return_mapping (F , diff_log_J ):
126+ dim = ti .static (F .n )
127+ sin_phi = ti .sin (friction_angle_in_degrees / 180.0 * ti .math .pi )
128+ friction_alpha = ti .sqrt (2.0 / 3.0 ) * 2.0 * sin_phi / (3.0 - sin_phi )
129+ U , sig_diag , V = ti .svd (F )
130+ sig = ti .Vector ([ti .max (sig_diag [i ,i ], 0.05 ) for i in ti .static (range (dim ))])
131+ epsilon = ti .log (sig )
132+ epsilon += diff_log_J / dim # volume correction treatment
133+ trace_epsilon = epsilon .sum ()
134+ shifted_trace = trace_epsilon
135+ if shifted_trace >= 0 :
136+ for d in ti .static (range (dim )):
137+ epsilon [d ] = 0.
138+ else :
139+ epsilon_hat = epsilon - (trace_epsilon / dim )
140+ epsilon_hat_norm = ti .sqrt (epsilon_hat .dot (epsilon_hat )+ 1e-8 )
141+ delta_gamma = epsilon_hat_norm + (dim * lam + 2. * mu ) / (2. * mu ) * (shifted_trace ) * friction_alpha
142+ epsilon -= (ti .max (delta_gamma , 0 ) / epsilon_hat_norm ) * epsilon_hat
143+ sig_out = ti .exp (epsilon )
144+ for d in ti .static (range (dim )):
145+ sig_diag [d ,d ] = sig_out [d ]
146+ return U @ sig_diag @ V .transpose ()
147+ # ANCHOR_END: drucker_prager
148+
149+ # Particle-to-Grid (P2G) Transfers
150+ # ANCHOR: p2g
151+ @ti .kernel
152+ def particle_to_grid_transfer ():
153+ for p in range (N_particles ):
154+ base = (x [p ] / dx - 0.5 ).cast (int )
155+ fx = x [p ] / dx - base .cast (float )
156+ # quadratic B-spline interpolating functions
157+ w = [0.5 * (1.5 - fx ) ** 2 , 0.75 - (fx - 1 ) ** 2 , 0.5 * (fx - 0.5 ) ** 2 ]
158+ # gradient of the interpolating function
159+ dw_dx = [fx - 1.5 , 2 * (1.0 - fx ), fx - 0.5 ]
160+
161+ P = StVK_Hencky_PK1_2D (F [p ])
162+ for i in ti .static (range (3 )):
163+ for j in ti .static (range (3 )):
164+ offset = ti .Vector ([i , j ])
165+ dpos = (offset .cast (float ) - fx ) * dx
166+ weight = w [i ][0 ] * w [j ][1 ]
167+ grad_weight = ti .Vector ([(1. / dx ) * dw_dx [i ][0 ] * w [j ][1 ],
168+ w [i ][0 ] * (1. / dx ) * dw_dx [j ][1 ]])
169+
170+ grid_m [base + offset ] += weight * m [p ] # mass transfer
171+ # ANCHOR: apic_p2g
172+ grid_v [base + offset ] += weight * m [p ] * (v [p ] + C [p ] @ dpos ) # momentum Transfer, APIC formulation
173+ # ANCHOR_END: apic_p2g
174+ # internal force (stress) transfer
175+ fi = - vol [p ] * P @ F [p ].transpose () @ grad_weight
176+ grid_v [base + offset ] += dt * fi
177+ # ANCHOR_END: p2g
178+
179+ # Grid Update
180+ @ti .kernel
181+ def update_grid ():
182+ for i , j in grid_m :
183+ if grid_m [i , j ] > 0 :
184+ grid_v [i , j ] = grid_v [i , j ] / grid_m [i , j ] # extract updated nodal velocity from transferred nodal momentum
185+ grid_v [i , j ] += ti .Vector ([0 , - 9.81 ]) * dt # gravity
186+
187+ # Dirichlet BC near the bounding box
188+ if i <= 3 or i > grid_size - 3 or j <= 2 or j > grid_size - 3 :
189+ grid_v [i , j ] = 0
190+
191+ x_i = (ti .Vector ([i , j ])) * dx # position of the grid-node
192+
193+ # ANCHOR: sphere_sdf
194+ # a sphere SDF as boundary condition
195+ sphere_center = ti .Vector ([0.5 , 0.5 ])
196+ sphere_radius = 0.05 + dx # add a dx-gap to avoid penetration
197+ if (x_i - sphere_center ).norm () < sphere_radius :
198+ normal = (x_i - sphere_center ).normalized ()
199+ diff_vel = - grid_v [i , j ]
200+ dotnv = normal .dot (diff_vel )
201+ dotnv_frac = dotnv * (1.0 - sdf_friction )
202+ grid_v [i , j ] += diff_vel * sdf_friction + normal * dotnv_frac
203+ # ANCHOR_END: sphere_sdf
204+
205+
206+ # Grid-to-Particle (G2P) Transfers
207+ # ANCHOR: g2p
208+ @ti .kernel
209+ def grid_to_particle_transfer ():
210+ for p in range (N_particles ):
211+ base = (x [p ] / dx - 0.5 ).cast (int )
212+ fx = x [p ] / dx - base .cast (float )
213+ # quadratic B-spline interpolating functions
214+ w = [0.5 * (1.5 - fx ) ** 2 , 0.75 - (fx - 1 ) ** 2 , 0.5 * (fx - 0.5 ) ** 2 ]
215+ # gradient of the interpolating function
216+ dw_dx = [fx - 1.5 , 2 * (1.0 - fx ), fx - 0.5 ]
217+
218+ new_v = ti .Vector .zero (float , 2 )
219+ new_C = ti .Matrix .zero (float , 2 , 2 )
220+ v_grad_wT = ti .Matrix .zero (float , 2 , 2 )
221+ for i in ti .static (range (3 )):
222+ for j in ti .static (range (3 )):
223+ offset = ti .Vector ([i , j ])
224+ dpos = (offset .cast (float ) - fx ) * dx
225+ weight = w [i ][0 ] * w [j ][1 ]
226+ grad_weight = ti .Vector ([(1. / dx ) * dw_dx [i ][0 ] * w [j ][1 ],
227+ w [i ][0 ] * (1. / dx ) * dw_dx [j ][1 ]])
228+
229+ new_v += weight * grid_v [base + offset ]
230+ # ANCHOR: apic_g2p
231+ new_C += weight * grid_v [base + offset ].outer_product (dpos ) / D
232+ # ANCHOR_END: apic_g2p
233+ v_grad_wT += grid_v [base + offset ].outer_product (grad_weight )
234+
235+ v [p ] = new_v
236+ C [p ] = new_C
237+ # note the updated F here is the trial elastic deformation gradient
238+ F [p ] = (ti .Matrix .identity (float , 2 ) + dt * v_grad_wT ) @ F [p ]
239+ # ANCHOR_END: g2p
240+
241+ # Deformation Gradient and Particle State Update
242+ # ANCHOR: particle_update
243+ @ti .kernel
244+ def update_particle_state ():
245+ for p in range (N_particles ):
246+ # trial elastic deformation gradient
247+ F_tr = F [p ]
248+ # apply return mapping to correct the trial elastic state, projecting the stress induced by F_tr
249+ # back onto the yield surface, following the direction specified by the plastic flow rule.
250+ new_F = Drucker_Prager_return_mapping (F_tr , diff_log_J [p ])
251+ # track the volume change incurred by return mapping to correct volume, following https://dl.acm.org/doi/10.1145/3072959.3073651 sec 4.3.4
252+ diff_log_J [p ] += - ti .log (new_F .determinant ()) + ti .log (F_tr .determinant ())
253+ F [p ] = new_F
254+ # advection
255+ x [p ] += dt * v [p ]
256+ # ANCHOR_END: particle_update
257+
258+ # ANCHOR: time_step
259+ def step ():
260+ # a single time step of the Material Point Method (MPM) simulation
261+ reset_grid ()
262+ particle_to_grid_transfer ()
263+ update_grid ()
264+ grid_to_particle_transfer ()
265+ update_particle_state ()
266+ # ANCHOR_END: time_step
267+
268+ ################################
269+ # Main
270+ ################################
271+ gui = ti .GUI ("2D MPM Sand" , res = 512 , background_color = 0xFFFFFF )
272+ frame = 0
273+ while True :
274+ for s in range (50 ):
275+ step ()
276+
277+ gui .circles (np .array ([[0.5 , 0.5 ]]), radius = 0.05 * gui .res [0 ], color = 0xFF0000 )
278+ gui .circles (x .to_numpy (), radius = 1.5 , color = 0xD6B588 )
279+ gui .show ()
0 commit comments