GPUPointSpatialFilter
Overview
GPUPointSpatialFilter evaluates exact bounds or radius predicates over packed 2D or 3D points.
It can scan every source row or refine a compact candidate list from GPUGridIndexQuery. Both paths
write the same source-row-aligned mask, so visibility, selection, compaction, picking, and indirect
drawing do not need separate indexed and unindexed implementations.
This primitive supplies the narrow phase that a grid deliberately does not. A grid knows which cells overlap a query; the filter knows whether each point is actually inside the bounds, circle, or sphere. Separating those jobs preserves exact results without teaching the index about every possible object representation.
Concepts
One predicate, two execution strategies
unindexed: all source rows ────────────────→ exact point predicate → source mask
indexed: grid query → candidate row IDs → exact point predicate → source mask
The unindexed path is not merely a fallback. It is the correctness oracle and is often the faster choice for small data, broad queries, or data that changes so frequently that index construction cannot be amortized. The indexed path helps when queries are selective enough that testing a compact candidate set saves more work than building and querying the grid costs.
Because both paths publish the same mask contract, an application can select a strategy from measurements without changing its renderer or interaction code.
What is exact
| Kind | Query layout | Exact rule | Example use cases |
|---|---|---|---|
bounds | 2D: [minX, minY, maxX, maxY]; 3D adds Z | Every coordinate is inside the inclusive bounds | Box selection, viewport points, simulation regions |
radius | Center followed by radius | Squared point distance is at most radius squared | Proximity, brush selection, influence neighborhoods |
Non-finite positions or query values, reversed bounds, and negative radii do not match. The filter handles points, not object extents: a circle intersecting a polygon or a box touching a sphere needs an application-specific exact predicate over that geometry.
Candidate rows and stable identity
Candidate IDs are interpreted as source-row addresses because the filter uses them to load packed
positions and set the corresponding mask row. This is intentionally narrower than
GPUGridIndexQuery, whose stable IDs may be arbitrary. Use this refinement path when the index was
built with generated row IDs. Applications with global or sparse IDs can keep a separate row-to-ID
vector for final visibility output, or provide a domain-specific refinement pass.
The candidate count is clamped to candidate storage capacity before dispatch. Invalid row IDs are
ignored rather than read. Candidate overflow is propagated, because an exact mask refined from a
truncated broad phase is incomplete even if every stored candidate was tested successfully.
Choosing the crossover
Do not infer the crossover from row count alone. Measure at least:
- index build or rebuild time and how many queries reuse it;
- cells touched and candidate count per query;
- exact predicate time for all rows versus candidates;
- grid storage, candidate storage, and source-mask memory;
- update rate and the percentage of points that change cells.
A useful first diagnostic is candidateCount / positionCount. A small ratio suggests that indexed
refinement may help, but GPU dispatch overhead, cell density, and index amortization still determine
the actual result. Performance guidance should report distributions across representative queries,
not a universal threshold.
Usage
const candidateMask = graph.createDataView(candidateMaskBuffer, {
format: 'uint32',
length: positions.length
});
new GPUPointSpatialFilter({
positions,
kind: 'radius',
query: centerAndRadius,
candidates: {
ids: gridCandidates,
count: gridCandidateCount,
overflow: gridCandidateOverflow
},
outputMask: candidateMask,
overflow: exactResultOverflow
}).addToGraph(graph);
new GPUVisibilityWorkflow({
predicates: [
{kind: 'bounds', mask: candidateMask},
{kind: 'selection', mask: selectedRows}
],
output: visibleIds,
count: visibleCount
}).addToGraph(graph);
Omit candidates to dispatch the identical exact predicate over every source point. Query-buffer
updates require no graph recompilation. The primitive clears its output mask and overflow word on
every encoding; it does not submit, read back, compact, or allocate caller-visible results.