numerics 0.1.0
Loading...
Searching...
No Matches
cell_list.hpp
Go to the documentation of this file.
1/// @file cell_list.hpp
2/// @brief Cache-coherent 2D cell list for O(1) amortized neighbour queries
3///
4/// @par Algorithm (CS theory: counting sort + prefix sums)
5///
6/// The standard spatial hash maps cell -> linked list of particles.
7/// Each linked-list traversal chases pointers through scattered heap memory,
8/// causing a cache miss per particle in the inner neighbour loop.
9///
10/// The cell list replaces this with a **counting sort**:
11///
12/// build() -- O(n + C) where C = total grid cells
13/// 1. Assign each particle i to cell id_i = cy_i*nx + cx_i O(n)
14/// 2. count[id]++ for each particle O(n)
15/// 3. Prefix sum: start[c] = sum(count[0..c-1]) O(C)
16/// 4. Scatter: sorted[start[id_i] + offset++] = i O(n)
17/// -> sorted[] contains particle indices grouped by cell.
18/// start[c]..start[c+1] is the contiguous range for cell c.
19///
20/// query(px, py, f) -- O(k) sequential reads
21/// For each of the 9 cells in the 3x3 neighbourhood:
22/// iterate sorted[start[c]..start[c+1]) -- sequential memory
23/// No pointer chasing. Particles in the same cell sit next to each
24/// other in sorted[], so the processor prefetches them correctly.
25///
26/// iterate_pairs(f) -- O(n*k/2) Newton's 3rd law half-shell
27/// Visits each unique unordered pair {i,j} exactly once.
28/// Based on the "forward half-shell" technique from molecular dynamics
29/// (Plimpton 1995, LAMMPS):
30///
31/// for each cell (cx, cy):
32/// intra-cell: pairs (a, b) where a < b in sorted[] index
33/// inter-cell: cross-product with 4 "forward" neighbours:
34/// (cx+1, cy-1), (cx+1, cy), (cx+1, cy+1), (cx, cy+1)
35///
36/// These 4 offsets + self cover all 9 neighbour directions uniquely:
37/// - dx > 0 -> handled when processing the left cell
38/// - dx = 0, dy > 0 -> handled by (0,+1) offset
39/// - dx < 0 or dy < 0 -> handled from the other side
40/// Every pair appears in exactly one (cell, forward-offset) combination.
41///
42/// @par Caller responsibilities
43/// - Particles must lie within the domain passed to the constructor.
44/// - get_pos(i) callable must return {x, y} for particle i.
45/// - Distance check (r < cutoff) must still be done in the callback.
46///
47/// @tparam Scalar float or double
48#pragma once
49
50#include <vector>
51#include <cmath>
52#include <algorithm>
53#include <cassert>
54#include <utility>
55
56namespace num {
57
58/// Lightweight read-only range over a contiguous int array (C++17-safe span).
59struct IntRange {
60 const int* first;
61 const int* last;
62 const int* begin() const noexcept {
63 return first;
64 }
65 const int* end() const noexcept {
66 return last;
67 }
68 int size() const noexcept {
69 return static_cast<int>(last - first);
70 }
71 bool empty() const noexcept {
72 return first == last;
73 }
74};
75
76template<typename Scalar>
78 public:
79 /// @param cell_size Width of one cell (use kernel support radius 2h).
80 /// @param xmin,xmax,ymin,ymax Simulation domain. Particles outside
81 /// are clamped to the boundary cell (safe, just no missed
82 /// neighbours).
83 CellList2D(Scalar cell_size,
84 Scalar xmin,
85 Scalar xmax,
86 Scalar ymin,
87 Scalar ymax)
88 : cs_(cell_size)
89 , xmin_(xmin)
90 , ymin_(ymin) {
91 // +2 padding cells on each axis prevents boundary checks in inner loops
92 nx_ = static_cast<int>(std::ceil((xmax - xmin) / cs_)) + 2;
93 ny_ = static_cast<int>(std::ceil((ymax - ymin) / cs_)) + 2;
94 const int total = nx_ * ny_;
95 start_.assign(total + 1, 0);
96 count_.assign(total, 0);
97 }
98
99 /// @brief Rebuild the cell list from n particles.
100 ///
101 /// PosAccessor: callable int -> std::pair<Scalar,Scalar> (x, y)
102 ///
103 /// Complexity: O(n + C) (two passes over particles + one prefix sum)
104 template<typename PosAccessor>
105 void build(PosAccessor&& get_pos, int n) {
106 sorted_.resize(n);
107 const int total = nx_ * ny_;
108
109 // Pass 1: count particles per cell
110 std::fill(count_.begin(), count_.end(), 0);
111 for (int i = 0; i < n; ++i)
112 ++count_[cell_id_of(get_pos(i))];
113
114 // Prefix sum -> start_[c] = first index in sorted_ for cell c
115 start_[0] = 0;
116 for (int c = 0; c < total; ++c)
117 start_[c + 1] = start_[c] + count_[c];
118
119 // Pass 2: scatter into sorted[] (counting sort, stable)
120 std::fill(count_.begin(), count_.end(), 0);
121 for (int i = 0; i < n; ++i) {
122 const int cid = cell_id_of(get_pos(i));
123 sorted_[start_[cid] + count_[cid]] = i;
124 ++count_[cid];
125 }
126 }
127
128 /// @brief Point query: calls f(int j) for every particle in the 3x3
129 /// cell neighbourhood of (px, py).
130 ///
131 /// Caller must still verify |r_ij| < cutoff -- this returns candidates.
132 template<typename F>
133 void query(Scalar px, Scalar py, F&& f) const {
134 const int cx = cell_x(px);
135 const int cy = cell_y(py);
136 for (int dy = -1; dy <= 1; ++dy) {
137 const int qy = cy + dy;
138 if (qy < 0 || qy >= ny_)
139 continue;
140 for (int dx = -1; dx <= 1; ++dx) {
141 const int qx = cx + dx;
142 if (qx < 0 || qx >= nx_)
143 continue;
144 const int cid = qy * nx_ + qx;
145 for (int k = start_[cid]; k < start_[cid + 1]; ++k)
146 f(sorted_[k]);
147 }
148 }
149 }
150
151 /// @brief Newton's 3rd law pair traversal.
152 ///
153 /// Calls f(int i, int j) for every unique unordered pair {i,j} whose
154 /// cells lie within the 3x3 neighbourhood. Each pair appears exactly
155 /// once. Caller can apply equal-and-opposite contributions to i and j.
156 ///
157 /// Complexity: O(n*k/2) where k = average neighbour count.
158 template<typename F>
159 void iterate_pairs(F&& f) const {
160 // 4 "forward" inter-cell offsets that, together with intra-cell
161 // pairs, cover all unique pairs in the 3x3 neighbourhood exactly once.
162 static constexpr int FDX[4] = {+1, 0, +1, -1};
163 static constexpr int FDY[4] = {0, +1, +1, +1};
164
165 for (int cy = 0; cy < ny_; ++cy) {
166 for (int cx = 0; cx < nx_; ++cx) {
167 const int cid = cy * nx_ + cx;
168 const int beg = start_[cid];
169 const int end = start_[cid + 1];
170 if (beg == end)
171 continue;
172
173 // 1. Intra-cell: pairs where a comes before b in sorted[]
174 for (int a = beg; a < end; ++a) {
175 for (int b = a + 1; b < end; ++b) {
176 f(sorted_[a], sorted_[b]);
177 }
178 }
179
180 // 2. Inter-cell: self x each forward neighbour cell
181 for (int d = 0; d < 4; ++d) {
182 const int ncx = cx + FDX[d];
183 const int ncy = cy + FDY[d];
184 if (ncx < 0 || ncx >= nx_ || ncy < 0 || ncy >= ny_)
185 continue;
186 const int ncid = ncy * nx_ + ncx;
187 const int nbeg = start_[ncid];
188 const int nend = start_[ncid + 1];
189 if (nbeg == nend)
190 continue;
191 for (int a = beg; a < end; ++a) {
192 for (int b = nbeg; b < nend; ++b) {
193 f(sorted_[a], sorted_[b]);
194 }
195 }
196 }
197 }
198 }
199 }
200
201 /// @brief Direct access to sorted particle indices for cell (cx, cy).
202 IntRange cell_particles(int cx, int cy) const noexcept {
203 const int cid = cy * nx_ + cx;
204 return {sorted_.data() + start_[cid], sorted_.data() + start_[cid + 1]};
205 }
206
207 int nx() const noexcept {
208 return nx_;
209 }
210 int ny() const noexcept {
211 return ny_;
212 }
213 int n_particles() const noexcept {
214 return static_cast<int>(sorted_.size());
215 }
216
217 private:
218 Scalar cs_ = 0, xmin_ = 0, ymin_ = 0;
219 int nx_ = 0, ny_ = 0;
220
221 std::vector<int> sorted_; ///< Particle indices sorted by cell id
222 std::vector<int>
223 start_; ///< start_[c] = first position in sorted_ for cell c
224 std::vector<int> count_; ///< Counting-sort scratch buffer
225
226 int cell_x(Scalar x) const noexcept {
227 // +1 for padding offset; clamp to [0, nx_-1]
228 const int cx = static_cast<int>(std::floor((x - xmin_) / cs_)) + 1;
229 return cx < 0 ? 0 : (cx >= nx_ ? nx_ - 1 : cx);
230 }
231 int cell_y(Scalar y) const noexcept {
232 const int cy = static_cast<int>(std::floor((y - ymin_) / cs_)) + 1;
233 return cy < 0 ? 0 : (cy >= ny_ ? ny_ - 1 : cy);
234 }
235 int cell_id_of(std::pair<Scalar, Scalar> p) const noexcept {
236 return cell_y(p.second) * nx_ + cell_x(p.first);
237 }
238};
239
240} // namespace num
int nx() const noexcept
void query(Scalar px, Scalar py, F &&f) const
Point query: calls f(int j) for every particle in the 3x3 cell neighbourhood of (px,...
void iterate_pairs(F &&f) const
Newton's 3rd law pair traversal.
CellList2D(Scalar cell_size, Scalar xmin, Scalar xmax, Scalar ymin, Scalar ymax)
Definition cell_list.hpp:83
int n_particles() const noexcept
IntRange cell_particles(int cx, int cy) const noexcept
Direct access to sorted particle indices for cell (cx, cy).
int ny() const noexcept
void build(PosAccessor &&get_pos, int n)
Rebuild the cell list from n particles.
Lightweight read-only range over a contiguous int array (C++17-safe span).
Definition cell_list.hpp:59
const int * begin() const noexcept
Definition cell_list.hpp:62
bool empty() const noexcept
Definition cell_list.hpp:71
const int * end() const noexcept
Definition cell_list.hpp:65
const int * last
Definition cell_list.hpp:61
int size() const noexcept
Definition cell_list.hpp:68
const int * first
Definition cell_list.hpp:60