diff --git a/CHANGELOG.md b/CHANGELOG.md
new file mode 100644
index 0000000..3e07bb1
--- /dev/null
+++ b/CHANGELOG.md
@@ -0,0 +1,41 @@
+# Changelog
+
+## UNRELEASED
+
+### New: `GridCell` Spatial Hashing
+
+Spatial hashing makes fast spatial queries and neighbor lookups possible. This release adds the `GridHashMap`, an automatically updated map of the entities in each grid cell. This makes it possible to query things like:
+
+- What other entities are in the same cell as this entity?
+- Are these two entities in the same cell?
+- What entities are in adjacent grid cells?
+
+This introduces a new component, the `GridHash`, which is automatically kept up to date, and stores a precomputed hash. This makes the spatial hash map especially fast, because hashing is only done when an entity moves between cells, not every time a hash map lookup is needed.
+
+The map has received a few rounds of optimization passes to make incremental updates and neighbor lookups especially fast. This map does not suffer from hash collisions.
+
+### New: Spatial Partitioning
+
+Built on top of the new spatial hashing feature is the `GridPartitionMap`. This map tracks groups of adjacent grid cells that have at least one entity. Each of these partitions contains many entities, and each partition is independent. That is, entities in partition A are guaranteed to be unable to collide with entities in partition B.
+
+This lays the groundwork for adding physics integrations. Because each partition is a clump of entities independent from all other entities, it should be possible to have independent physics simulations for each partition. Not only will this allow for extreme parallelism, it becomes possible to use 32-bit physics simulations in a 160-bit big_space.
+
+### `ReferenceFrame` Renamed `Grid`
+
+While revisiting documentation, it became clear that the naming scheme can be confusing and inconsistent. Most notably, it wasn't immediately clear there is a relationship between `ReferenceFrame` and `GridCell`. Additionally, there were multiple places where reference frames were clarified to be fixed precision grids.
+
+To clear this up, `ReferenceFrame` has been renamed `Grid`. The core spatial types in this library are now:
+
+- `Grid`: Defines the size of a grid for its child cells.
+- `GridCell`: Cell index of an entity within its parent's grid.
+- `GridPrecision`: Integer precision of a grid.
+
+The newly added types follow this pattern:
+
+- `GridHash`: The spatial hash of an entity's grid cell.
+- `GridHashMap`: A map for entity, grid cell, and neighbor lookups.
+- `GridPartition`: Group of adjacent grid cells.
+- `GridPartitionMap`: A map for finding independent partitions of entities.
+
+
+It should now be more clear how all of the `Grid` types are related to each other.
\ No newline at end of file
diff --git a/Cargo.toml b/Cargo.toml
index 5a3968e..0a9a49d 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -8,13 +8,20 @@ keywords = ["bevy", "floating-origin", "large-scale", "space"]
 repository = "https://github.com/aevyrie/big_space"
 documentation = "https://docs.rs/crate/big_space/latest"
 
+[features]
+default = []
+debug = ["bevy_gizmos", "bevy_color"]
+camera = ["bevy_render", "bevy_time", "bevy_input"]
+
 [dependencies]
-tracing = { version = "0.1", default-features = false }
+tracing = "0.1" # Less deps than pulling in bevy_log
+smallvec = "1.13.2" # Already used by bevy in commands
 bevy_app = { version = "0.15.0", default-features = false }
-bevy_ecs = { version = "0.15.0", default-features = false }
+bevy_ecs = { version = "0.15.0", default-features = true }
 bevy_hierarchy = { version = "0.15.0", default-features = false }
 bevy_math = { version = "0.15.0", default-features = false }
 bevy_reflect = { version = "0.15.0", default-features = false }
+bevy_tasks = { version = "0.15.0", default-features = false }
 bevy_transform = { version = "0.15.0", default-features = false, features = [
     "bevy-support",
 ] }
@@ -26,9 +33,12 @@ bevy_render = { version = "0.15.0", default-features = false, optional = true }
 bevy_input = { version = "0.15.0", default-features = false, optional = true }
 bevy_time = { version = "0.15.0", default-features = false, optional = true }
 
+
 [dev-dependencies]
+big_space = { path = "", features = ["debug", "camera"] }
 bevy = { version = "0.15.0", default-features = false, features = [
     "bevy_scene",
+    "bevy_asset",
     "bevy_gltf",
     "bevy_winit",
     "default_font",
@@ -41,45 +51,57 @@ bevy = { version = "0.15.0", default-features = false, features = [
     "tonemapping_luts",
     "multi_threaded",
 ] }
-rand = "0.8.5"
+noise = "0.9"
+turborand = "0.10"
+criterion = "0.5"
+bytemuck = "1.20"
+bevy_hanabi = "0.14"
 
-[features]
-default = ["debug", "camera", "bevy_render"]
-debug = ["bevy_gizmos", "bevy_color"]
-camera = ["bevy_render", "bevy_time", "bevy_input"]
+[[bench]]
+name = "benchmarks"
+harness = false
+
+[[example]]
+name = "debug"
+path = "examples/debug.rs"
+doc-scrape-examples = true
 
 [[example]]
 name = "demo"
 path = "examples/demo.rs"
-required-features = ["default"]
 doc-scrape-examples = true
 
 [[example]]
-name = "debug"
-path = "examples/debug.rs"
-required-features = ["default"]
+name = "error_child"
+path = "examples/error_child.rs"
 doc-scrape-examples = true
 
 [[example]]
 name = "error"
 path = "examples/error.rs"
-required-features = ["default"]
 doc-scrape-examples = true
 
 [[example]]
-name = "error_child"
-path = "examples/error_child.rs"
-required-features = ["default"]
+name = "infinite"
+path = "examples/infinite.rs"
+doc-scrape-examples = true
+
+[[example]]
+name = "minimal"
+path = "examples/minimal.rs"
+doc-scrape-examples = true
+
+[[example]]
+name = "particles"
+path = "examples/particles.rs"
 doc-scrape-examples = true
 
 [[example]]
 name = "planets"
 path = "examples/planets.rs"
-required-features = ["default"]
 doc-scrape-examples = true
 
 [[example]]
 name = "split_screen"
 path = "examples/split_screen.rs"
-required-features = ["default"]
 doc-scrape-examples = true
diff --git a/README.md b/README.md
index 3e9a9c0..6009e2b 100644
--- a/README.md
+++ b/README.md
@@ -1,40 +1,31 @@
 <div align="center">
-  
+
 # Big Space
 
+<img src="https://raw.githubusercontent.com/aevyrie/big_space/refs/heads/main/assets/bigspacebanner.svg" width="80%">
+
+Huge worlds, high performance, no dependencies, ecosystem compatibility. [Read the docs](https://docs.rs/big_space)
+ 
 [![crates.io](https://img.shields.io/crates/v/big_space)](https://crates.io/crates/big_space)
 [![docs.rs](https://docs.rs/big_space/badge.svg)](https://docs.rs/big_space)
 [![test suite](https://github.com/aevyrie/big_space/actions/workflows/rust.yml/badge.svg)](https://github.com/aevyrie/big_space/actions/workflows/rust.yml)
-[![Bevy tracking](https://img.shields.io/badge/Bevy%20tracking-main-lightblue)](https://github.com/bevyengine/bevy/blob/main/docs/plugins_guidelines.md#main-branch-tracking)
-
-A floating origin plugin for [Bevy](https://github.com/bevyengine/bevy).
-
-https://user-images.githubusercontent.com/2632925/215318129-5bab3095-a7dd-455b-a4b6-71840cde096c.mp4
-
-### [Read the docs](https://docs.rs/big_space)
 
 </div>
 
-## Features
-
-Lots of space to play in.
-
-This is a floating origin plugin, useful if you want to work with very large or very small scales. It works with bevy's existing `f32`-based `Transform`s, which means it's largely compatible with the bevy ecosystem. The plugin positions entities within large fixed precision grids, effectively adding precision to the location of objects.
-
-Additionally, you can use reference frames to nest high precision coordinate systems. For example you might want to put all entities on a planet's surface into the same reference frame. You can then rotate this reference frame with the planet, and orbit that planet around a star.
-
-The plugin is generic over a few integer types, to trade off scale and precision for memory use. Some fun numbers with a worst case precision of 0.5mm:
-  - `i8`: 2,560 km = 74% of the diameter of the Moon
-  - `i16`: 655,350 km = 85% of the diameter of the Moon's orbit around Earth
-  - `i32`: 0.0045 light years = ~4 times the width of the solar system
-  - `i64`: 19.5 million light years = ~100 times the width of the milky way galaxy
-  - `i128`: 3.6e+26 light years = ~3.9e+15 times the width of the observable universe
-
-This can also be used for small scales. With a cell edge length of `1e-11`, and using `i128`, there is enough precision to render objects the size of quarks anywhere in the observable universe.
+## Highlights
 
-From the docs: https://docs.rs/big_space/latest/big_space/precision/trait.GridPrecision.html
+- Enough precision to render proton-sized meshes across the observable universe.
+- Uses `Transform`, making it compatible with most of the Bevy ecosystem.
+- No added dependencies.
+- Absolute coordinates without drift, unlike camera-relative or periodic recentering solutions.
+- Chunks the world into integer grids, from `i8` up to `i128`.
+- Grids can be nested.
+- Spatial hashing for fast grid cell lookups and neighbor search.
+- Spatial partitioning to group sets of disconnected entities.
+- 3-5x faster than Bevy's transform propagation for wide hierarchies.
+- 👉 [Extensive documentation you should read.](https://docs.rs/big_space)
 
-# Bevy Version Support
+## Bevy Version Support
 
 | bevy | big_space |
 | ---- | --------- |
diff --git a/assets/fonts/FiraMono-LICENSE b/assets/fonts/FiraMono-LICENSE
new file mode 100644
index 0000000..5e4608f
--- /dev/null
+++ b/assets/fonts/FiraMono-LICENSE
@@ -0,0 +1,93 @@
+Digitized data copyright (c) 2012-2015, The Mozilla Foundation and Telefonica S.A.
+
+This Font Software is licensed under the SIL Open Font License, Version 1.1.
+This license is copied below, and is also available with a FAQ at:
+http://scripts.sil.org/OFL
+
+
+-----------------------------------------------------------
+SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007
+-----------------------------------------------------------
+
+PREAMBLE
+The goals of the Open Font License (OFL) are to stimulate worldwide
+development of collaborative font projects, to support the font creation
+efforts of academic and linguistic communities, and to provide a free and
+open framework in which fonts may be shared and improved in partnership
+with others.
+
+The OFL allows the licensed fonts to be used, studied, modified and
+redistributed freely as long as they are not sold by themselves. The
+fonts, including any derivative works, can be bundled, embedded, 
+redistributed and/or sold with any software provided that any reserved
+names are not used by derivative works. The fonts and derivatives,
+however, cannot be released under any other type of license. The
+requirement for fonts to remain under this license does not apply
+to any document created using the fonts or their derivatives.
+
+DEFINITIONS
+"Font Software" refers to the set of files released by the Copyright
+Holder(s) under this license and clearly marked as such. This may
+include source files, build scripts and documentation.
+
+"Reserved Font Name" refers to any names specified as such after the
+copyright statement(s).
+
+"Original Version" refers to the collection of Font Software components as
+distributed by the Copyright Holder(s).
+
+"Modified Version" refers to any derivative made by adding to, deleting,
+or substituting -- in part or in whole -- any of the components of the
+Original Version, by changing formats or by porting the Font Software to a
+new environment.
+
+"Author" refers to any designer, engineer, programmer, technical
+writer or other person who contributed to the Font Software.
+
+PERMISSION & CONDITIONS
+Permission is hereby granted, free of charge, to any person obtaining
+a copy of the Font Software, to use, study, copy, merge, embed, modify,
+redistribute, and sell modified and unmodified copies of the Font
+Software, subject to the following conditions:
+
+1) Neither the Font Software nor any of its individual components,
+in Original or Modified Versions, may be sold by itself.
+
+2) Original or Modified Versions of the Font Software may be bundled,
+redistributed and/or sold with any software, provided that each copy
+contains the above copyright notice and this license. These can be
+included either as stand-alone text files, human-readable headers or
+in the appropriate machine-readable metadata fields within text or
+binary files as long as those fields can be easily viewed by the user.
+
+3) No Modified Version of the Font Software may use the Reserved Font
+Name(s) unless explicit written permission is granted by the corresponding
+Copyright Holder. This restriction only applies to the primary font name as
+presented to the users.
+
+4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font
+Software shall not be used to promote, endorse or advertise any
+Modified Version, except to acknowledge the contribution(s) of the
+Copyright Holder(s) and the Author(s) or with their explicit written
+permission.
+
+5) The Font Software, modified or unmodified, in part or in whole,
+must be distributed entirely under this license, and must not be
+distributed under any other license. The requirement for fonts to
+remain under this license does not apply to any document created
+using the Font Software.
+
+TERMINATION
+This license becomes null and void if any of the above conditions are
+not met.
+
+DISCLAIMER
+THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
+EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF
+MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT
+OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE
+COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
+INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL
+DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
+FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM
+OTHER DEALINGS IN THE FONT SOFTWARE.
diff --git a/assets/fonts/FiraMono-Regular.ttf b/assets/fonts/FiraMono-Regular.ttf
new file mode 100644
index 0000000..67bbd42
Binary files /dev/null and b/assets/fonts/FiraMono-Regular.ttf differ
diff --git a/benches/benchmarks.rs b/benches/benchmarks.rs
new file mode 100644
index 0000000..2287e2d
--- /dev/null
+++ b/benches/benchmarks.rs
@@ -0,0 +1,445 @@
+#![allow(clippy::type_complexity)]
+
+use bevy::prelude::*;
+use big_space::prelude::*;
+use criterion::{black_box, criterion_group, criterion_main, Criterion};
+use std::{iter::repeat_with, ops::Neg};
+use turborand::prelude::*;
+
+criterion_group!(
+    benches,
+    global_transform,
+    spatial_hashing,
+    hash_filtering,
+    deep_hierarchy,
+    wide_hierarchy,
+    vs_bevy,
+);
+criterion_main!(benches);
+
+#[allow(clippy::unit_arg)]
+fn global_transform(c: &mut Criterion) {
+    let mut group = c.benchmark_group("propagation");
+    group.bench_function("global_transform", |b| {
+        let grid = Grid::default();
+        let local_cell = GridCell { x: 1, y: 1, z: 1 };
+        let local_transform = Transform::from_xyz(9.0, 200.0, 500.0);
+        b.iter(|| {
+            black_box(grid.global_transform(&local_cell, &local_transform));
+        });
+    });
+}
+
+#[allow(clippy::unit_arg)]
+fn deep_hierarchy(c: &mut Criterion) {
+    /// Total number of entities to spawn
+    const N_SPAWN: usize = 100;
+
+    let mut group = c.benchmark_group(format!("deep_hierarchy {N_SPAWN}"));
+
+    fn setup(mut commands: Commands) {
+        commands.spawn_big_space::<i32>(Grid::new(10000.0, 0.0), |root| {
+            let mut parent = root.spawn_grid_default(()).id();
+            for _ in 0..N_SPAWN {
+                let child = root.commands().spawn(BigGridBundle::<i32>::default()).id();
+                root.commands().entity(parent).add_child(child);
+                parent = child;
+            }
+            root.spawn_spatial(FloatingOrigin);
+        });
+    }
+
+    fn translate(mut transforms: Query<&mut Transform>) {
+        transforms.iter_mut().for_each(|mut transform| {
+            transform.translation += Vec3::ONE;
+        })
+    }
+
+    let mut app = App::new();
+    app.add_plugins((
+        MinimalPlugins,
+        GridHashPlugin::<i32>::default(),
+        BigSpacePlugin::<i32>::default(),
+    ))
+    .add_systems(Startup, setup)
+    .add_systems(Update, translate)
+    .update();
+
+    group.bench_function("Baseline", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+}
+
+#[allow(clippy::unit_arg)]
+fn wide_hierarchy(c: &mut Criterion) {
+    /// Total number of entities to spawn
+    const N_SPAWN: usize = 100_000;
+
+    let mut group = c.benchmark_group(format!("wide_hierarchy {N_SPAWN}"));
+
+    fn setup(mut commands: Commands) {
+        commands.spawn_big_space::<i32>(Grid::new(10000.0, 0.0), |root| {
+            for _ in 0..N_SPAWN {
+                root.spawn_spatial(());
+            }
+            root.spawn_spatial(FloatingOrigin);
+        });
+    }
+
+    fn translate(mut transforms: Query<&mut Transform>) {
+        transforms.iter_mut().for_each(|mut transform| {
+            transform.translation += Vec3::ONE;
+        })
+    }
+
+    let mut app = App::new();
+    app.add_plugins((
+        MinimalPlugins,
+        GridHashPlugin::<i32>::default(),
+        BigSpacePlugin::<i32>::default(),
+    ))
+    .add_systems(Startup, setup)
+    .add_systems(Update, translate)
+    .update();
+
+    group.bench_function("Baseline", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+}
+
+#[allow(clippy::unit_arg)]
+fn spatial_hashing(c: &mut Criterion) {
+    let mut group = c.benchmark_group("spatial_hashing");
+
+    const HALF_WIDTH: i32 = 100;
+    /// Total number of entities to spawn
+    const N_SPAWN: usize = 10_000;
+    /// Number of entities that move into a different cell each update
+    const N_MOVE: usize = 1_000;
+
+    fn setup(mut commands: Commands) {
+        commands.spawn_big_space::<i32>(Grid::new(1.0, 0.0), |root| {
+            let rng = Rng::with_seed(342525);
+            let values: Vec<_> = repeat_with(|| {
+                [
+                    rng.i32(-HALF_WIDTH..=HALF_WIDTH),
+                    rng.i32(-HALF_WIDTH..=HALF_WIDTH),
+                    rng.i32(-HALF_WIDTH..=HALF_WIDTH),
+                ]
+            })
+            .take(N_SPAWN)
+            .collect();
+
+            for pos in values {
+                root.spawn_spatial(GridCell::new(pos[0], pos[1], pos[2]));
+            }
+        });
+    }
+
+    fn translate(mut cells: Query<&mut GridCell<i32>>) {
+        cells.iter_mut().take(N_MOVE).for_each(|mut cell| {
+            *cell += GridCell::ONE;
+        })
+    }
+
+    let mut app = App::new();
+    app.add_plugins(GridHashPlugin::<i32>::default())
+        .add_systems(Startup, setup)
+        .update();
+
+    group.bench_function("Baseline", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+
+    app.add_systems(Update, translate).update();
+    group.bench_function("Translation and rehashing", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+
+    let map = app.world().resource::<GridHashMap<i32>>();
+    let first = map
+        .all_entries()
+        .find(|(_, entry)| !entry.entities.is_empty())
+        .unwrap();
+    group.bench_function("GridHashMap::get", |b| {
+        b.iter(|| {
+            black_box(map.get(first.0).unwrap());
+        });
+    });
+
+    let ent = *first.1.entities.iter().next().unwrap();
+    group.bench_function("Find entity", |b| {
+        b.iter(|| {
+            black_box(
+                map.get(first.0)
+                    .map(|entry| entry.entities.iter().find(|e| *e == &ent)),
+            );
+        });
+    });
+
+    // let parent = app .world_mut() .query::<&GridHash<i32>>() .get(app.world(), ent)
+    //     .unwrap(); let map = app.world().resource::<GridHashMap<i32>>(); let entry =
+    //     map.get(parent).unwrap();
+
+    // group.bench_function("Neighbors radius: 4", |b| {
+    //     b.iter(|| {
+    //         black_box(map.neighbors(entry).count());
+    //     });
+    // });
+
+    // group.bench_function(format!("Neighbors radius: {}", HALF_WIDTH), |b| {
+    //     b.iter(|| {
+    //         black_box(
+    //             map.neighbors(entry)x
+    //                 .count(),
+    //         );
+    //     });
+    // });
+
+    fn setup_uniform<const HALF_EXTENT: i32>(mut commands: Commands) {
+        commands.spawn_big_space::<i32>(Grid::new(1.0, 0.0), |root| {
+            for x in HALF_EXTENT.neg()..HALF_EXTENT {
+                for y in HALF_EXTENT.neg()..HALF_EXTENT {
+                    for z in HALF_EXTENT.neg()..HALF_EXTENT {
+                        root.spawn_spatial(GridCell::new(x, y, z));
+                    }
+                }
+            }
+        });
+    }
+
+    // Uniform Grid Population 1_000
+
+    let mut app = App::new();
+    app.add_plugins(GridHashPlugin::<i32>::default())
+        .add_systems(Startup, setup_uniform::<5>)
+        .update();
+
+    let parent = app
+        .world_mut()
+        .query_filtered::<Entity, With<BigSpace>>()
+        .single(app.world());
+    let spatial_map = app.world().resource::<GridHashMap<i32>>();
+    let hash = GridHash::__new_manual(parent, &GridCell { x: 0, y: 0, z: 0 });
+    let entry = spatial_map.get(&hash).unwrap();
+
+    assert_eq!(spatial_map.nearby(entry).count(), 27);
+    group.bench_function("nearby 1 population 1_000", |b| {
+        b.iter(|| {
+            black_box(spatial_map.nearby(entry).count());
+        });
+    });
+
+    assert_eq!(spatial_map.flood(&hash, None).count(), 1_000);
+    let flood = || spatial_map.flood(&hash, None).count();
+    group.bench_function("nearby flood population 1_000", |b| {
+        b.iter(|| black_box(flood()));
+    });
+
+    // Uniform Grid Population 1_000_000
+
+    let mut app = App::new();
+    app.add_plugins(GridHashPlugin::<i32>::default())
+        .add_systems(Startup, setup_uniform::<50>)
+        .update();
+
+    let parent = app
+        .world_mut()
+        .query_filtered::<Entity, With<BigSpace>>()
+        .single(app.world());
+    let spatial_map = app.world().resource::<GridHashMap<i32>>();
+    let hash = GridHash::__new_manual(parent, &GridCell { x: 0, y: 0, z: 0 });
+    let entry = spatial_map.get(&hash).unwrap();
+
+    assert_eq!(spatial_map.nearby(entry).count(), 27);
+    group.bench_function("nearby 1 population 1_000_000", |b| {
+        b.iter(|| {
+            black_box(spatial_map.nearby(entry).count());
+        });
+    });
+
+    assert_eq!(spatial_map.flood(&hash, None).count(), 1_000_000);
+    group.bench_function("nearby flood population 1_000_000", |b| {
+        b.iter(|| black_box(spatial_map.flood(&hash, None).count()));
+    });
+}
+
+#[allow(clippy::unit_arg)]
+fn hash_filtering(c: &mut Criterion) {
+    let mut group = c.benchmark_group("hash_filtering");
+
+    const N_ENTITIES: usize = 100_000;
+    const N_PLAYERS: usize = 100;
+    const N_MOVE: usize = 1_000;
+    const HALF_WIDTH: i32 = 100;
+
+    #[derive(Component)]
+    struct Player;
+
+    fn setup(mut commands: Commands) {
+        let rng = Rng::with_seed(342525);
+        let values: Vec<_> = repeat_with(|| {
+            [
+                rng.i32(-HALF_WIDTH..=HALF_WIDTH),
+                rng.i32(-HALF_WIDTH..=HALF_WIDTH),
+                rng.i32(-HALF_WIDTH..=HALF_WIDTH),
+            ]
+        })
+        .take(N_ENTITIES)
+        .collect();
+
+        commands.spawn_big_space_default::<i32>(|root| {
+            for (i, pos) in values.iter().enumerate() {
+                let mut cmd = root.spawn_spatial(GridCell::new(pos[0], pos[1], pos[2]));
+                if i < N_PLAYERS {
+                    cmd.insert(Player);
+                }
+            }
+        });
+    }
+
+    fn translate(mut cells: Query<&mut GridCell<i32>>) {
+        cells.iter_mut().take(N_MOVE).for_each(|mut cell| {
+            *cell += IVec3::ONE;
+        });
+    }
+
+    let mut app = App::new();
+    app.add_systems(Startup, setup)
+        .add_systems(Update, translate)
+        .update();
+    app.update();
+    app.add_plugins((GridHashPlugin::<i32>::default(),));
+    group.bench_function("No Filter Plugin", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+
+    let mut app = App::new();
+    app.add_systems(Startup, setup)
+        .add_systems(Update, translate)
+        .update();
+    app.update();
+    app.add_plugins((GridHashPlugin::<i32, With<Player>>::default(),));
+    group.bench_function("With Player Plugin", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+
+    let mut app = App::new();
+    app.add_systems(Startup, setup)
+        .add_systems(Update, translate)
+        .update();
+    app.update();
+    app.add_plugins((GridHashPlugin::<i32, Without<Player>>::default(),));
+    group.bench_function("Without Player Plugin", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+
+    let mut app = App::new();
+    app.add_systems(Startup, setup)
+        .add_systems(Update, translate)
+        .update();
+    app.update();
+    app.add_plugins((GridHashPlugin::<i32>::default(),))
+        .add_plugins((GridHashPlugin::<i32, With<Player>>::default(),))
+        .add_plugins((GridHashPlugin::<i32, Without<Player>>::default(),));
+    group.bench_function("All Plugins", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+}
+
+#[allow(clippy::unit_arg)]
+fn vs_bevy(c: &mut Criterion) {
+    let mut group = c.benchmark_group("transform_prop");
+
+    use bevy::prelude::*;
+    use BigSpacePlugin;
+
+    const N_ENTITIES: usize = 1_000_000;
+
+    fn setup_bevy(mut commands: Commands) {
+        commands
+            .spawn((Transform::default(), Visibility::default()))
+            .with_children(|builder| {
+                for _ in 0..N_ENTITIES {
+                    builder.spawn((Transform::default(), Visibility::default()));
+                }
+            });
+    }
+
+    fn setup_big(mut commands: Commands) {
+        commands.spawn_big_space_default::<i32>(|root| {
+            for _ in 0..N_ENTITIES {
+                root.spawn_spatial(());
+            }
+            root.spawn_spatial(FloatingOrigin);
+        });
+    }
+
+    let mut app = App::new();
+    app.add_plugins((MinimalPlugins, TransformPlugin))
+        .add_systems(Startup, setup_bevy)
+        .update();
+
+    group.bench_function("Bevy Propagation Static", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+
+    let mut app = App::new();
+    app.add_plugins((MinimalPlugins, BigSpacePlugin::<i32>::default()))
+        .add_systems(Startup, setup_big)
+        .update();
+
+    group.bench_function("Big Space Propagation Static", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+
+    fn translate(mut transforms: Query<&mut Transform>) {
+        transforms.iter_mut().for_each(|mut transform| {
+            transform.translation += 1.0;
+        });
+    }
+
+    let mut app = App::new();
+    app.add_plugins((MinimalPlugins, TransformPlugin))
+        .add_systems(Startup, setup_bevy)
+        .add_systems(Update, translate)
+        .update();
+
+    group.bench_function("Bevy Propagation", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+
+    let mut app = App::new();
+    app.add_plugins((MinimalPlugins, BigSpacePlugin::<i32>::default()))
+        .add_systems(Startup, setup_big)
+        .add_systems(Update, translate)
+        .update();
+
+    group.bench_function("Big Space Propagation", |b| {
+        b.iter(|| {
+            black_box(app.update());
+        });
+    });
+}
diff --git a/examples/debug.rs b/examples/debug.rs
index 1c576bc..3d0eff6 100644
--- a/examples/debug.rs
+++ b/examples/debug.rs
@@ -1,17 +1,15 @@
 #![allow(clippy::type_complexity)]
 
-use bevy::prelude::*;
-use bevy_color::palettes;
-use big_space::{commands::BigSpaceCommands, reference_frame::ReferenceFrame, FloatingOrigin};
+use bevy::{color::palettes, prelude::*};
+use big_space::prelude::*;
 
 fn main() {
     App::new()
         .add_plugins((
-            DefaultPlugins.build().disable::<TransformPlugin>(),
-            big_space::BigSpacePlugin::<i64>::default(),
+            DefaultPlugins,
+            BigSpacePlugin::<i64>::default(),
             big_space::debug::FloatingOriginDebugPlugin::<i64>::default(),
         ))
-        .insert_resource(ClearColor(Color::BLACK))
         .add_systems(Startup, setup)
         .add_systems(Update, (movement, rotation))
         .run();
@@ -62,11 +60,11 @@ fn setup(
 ) {
     let mesh_handle = meshes.add(Sphere::new(0.1).mesh().ico(16).unwrap());
     let matl_handle = materials.add(StandardMaterial {
-        base_color: Color::Srgba(palettes::basic::YELLOW),
+        base_color: Color::Srgba(palettes::basic::WHITE),
         ..default()
     });
 
-    commands.spawn_big_space(ReferenceFrame::<i64>::new(1.0, 0.01), |root| {
+    commands.spawn_big_space::<i64>(Grid::new(1.0, 0.01), |root| {
         root.spawn_spatial((
             Mesh3d(mesh_handle.clone()),
             MeshMaterial3d(matl_handle.clone()),
@@ -81,15 +79,15 @@ fn setup(
             Mover::<2>,
         ));
 
-        root.with_frame(ReferenceFrame::new(0.2, 0.01), |new_frame| {
-            new_frame.insert((
+        root.with_grid(Grid::new(0.2, 0.01), |new_grid| {
+            new_grid.insert((
                 Mesh3d(mesh_handle.clone()),
                 MeshMaterial3d(matl_handle.clone()),
                 Transform::from_xyz(0.0, 1.0, 0.0),
                 Rotator,
                 Mover::<3>,
             ));
-            new_frame.spawn_spatial((
+            new_grid.spawn_spatial((
                 Mesh3d(mesh_handle),
                 MeshMaterial3d(matl_handle),
                 Transform::from_xyz(0.0, 0.5, 0.0),
diff --git a/examples/demo.rs b/examples/demo.rs
index 6952a31..e84e80e 100644
--- a/examples/demo.rs
+++ b/examples/demo.rs
@@ -1,23 +1,21 @@
 use bevy::{
+    color::palettes,
     prelude::*,
     transform::TransformSystem,
     window::{CursorGrabMode, PrimaryWindow},
 };
-use bevy_color::palettes;
 use big_space::{
     camera::{CameraController, CameraInput},
-    commands::BigSpaceCommands,
-    reference_frame::{local_origin::ReferenceFrames, ReferenceFrame},
+    prelude::*,
     world_query::GridTransformReadOnly,
-    FloatingOrigin,
 };
 
 fn main() {
     App::new()
         .add_plugins((
-            DefaultPlugins.build().disable::<TransformPlugin>(),
-            big_space::BigSpacePlugin::<i128>::default(),
-            big_space::debug::FloatingOriginDebugPlugin::<i128>::default(),
+            DefaultPlugins,
+            BigSpacePlugin::<i128>::default(),
+            FloatingOriginDebugPlugin::<i128>::default(),
             big_space::camera::CameraControllerPlugin::<i128>::default(),
         ))
         .insert_resource(ClearColor(Color::BLACK))
@@ -35,7 +33,7 @@ fn setup(
     mut meshes: ResMut<Assets<Mesh>>,
     mut materials: ResMut<Assets<StandardMaterial>>,
 ) {
-    commands.spawn_big_space(ReferenceFrame::<i128>::default(), |root| {
+    commands.spawn_big_space_default::<i128>(|root| {
         root.spawn_spatial((
             Camera3d::default(),
             Projection::Perspective(PerspectiveProjection {
@@ -152,7 +150,7 @@ fn ui_text_system(
         (With<BigSpaceDebugText>, Without<FunFactText>),
     >,
     mut fun_text: Query<&mut Text, (With<FunFactText>, Without<BigSpaceDebugText>)>,
-    ref_frames: ReferenceFrames<i128>,
+    grids: Grids<i128>,
     time: Res<Time>,
     origin: Query<(Entity, GridTransformReadOnly<i128>), With<FloatingOrigin>>,
     camera: Query<&CameraController>,
@@ -171,11 +169,11 @@ fn ui_text_system(
         translation.x, translation.y, translation.z
     );
 
-    let Some(ref_frame) = ref_frames.parent_frame(origin_entity) else {
+    let Some(grid) = grids.parent_grid(origin_entity) else {
         return;
     };
 
-    let real_position = ref_frame.grid_position_double(origin_pos.cell, origin_pos.transform);
+    let real_position = grid.grid_position_double(origin_pos.cell, origin_pos.transform);
     let real_position_f64_text = format!(
         "Combined (f64): {}x, {}y, {}z",
         real_position.x, real_position.y, real_position.z
diff --git a/examples/error.rs b/examples/error.rs
index 554cdc1..a7ab2cb 100644
--- a/examples/error.rs
+++ b/examples/error.rs
@@ -6,18 +6,11 @@
 //! origin when not using this plugin.
 
 use bevy::prelude::*;
-use big_space::{
-    commands::BigSpaceCommands,
-    reference_frame::{local_origin::ReferenceFrames, ReferenceFrame},
-    FloatingOrigin, GridCell,
-};
+use big_space::prelude::*;
 
 fn main() {
     App::new()
-        .add_plugins((
-            DefaultPlugins.build().disable::<TransformPlugin>(),
-            big_space::BigSpacePlugin::<i128>::default(),
-        ))
+        .add_plugins((DefaultPlugins, BigSpacePlugin::<i128>::default()))
         .add_systems(Startup, (setup_scene, setup_ui))
         .add_systems(Update, (rotator_system, toggle_plugin))
         .run();
@@ -37,7 +30,7 @@ const DISTANCE: i128 = 2_000_000;
 /// this issue.
 fn toggle_plugin(
     input: Res<ButtonInput<KeyCode>>,
-    ref_frames: ReferenceFrames<i128>,
+    grids: Grids<i128>,
     mut text: Query<&mut Text>,
     mut disabled: Local<bool>,
     mut floating_origin: Query<(Entity, &mut GridCell<i128>), With<FloatingOrigin>>,
@@ -46,10 +39,10 @@ fn toggle_plugin(
         *disabled = !*disabled;
     }
 
-    let this_frame = ref_frames.parent_frame(floating_origin.single().0).unwrap();
+    let this_grid = grids.parent_grid(floating_origin.single().0).unwrap();
 
     let mut origin_cell = floating_origin.single_mut().1;
-    let index_max = DISTANCE / this_frame.cell_edge_length() as i128;
+    let index_max = DISTANCE / this_grid.cell_edge_length() as i128;
     let increment = index_max / 100;
 
     let msg = if *disabled {
@@ -71,7 +64,7 @@ fn toggle_plugin(
         "Floating Origin Enabled"
     };
 
-    let dist = index_max.saturating_sub(origin_cell.x) * this_frame.cell_edge_length() as i128;
+    let dist = index_max.saturating_sub(origin_cell.x) * this_grid.cell_edge_length() as i128;
 
     let thousands = |num: i128| {
         num.to_string()
@@ -114,8 +107,8 @@ fn setup_ui(mut commands: Commands) {
 }
 
 fn setup_scene(mut commands: Commands, asset_server: Res<AssetServer>) {
-    commands.spawn_big_space(ReferenceFrame::<i128>::default(), |root| {
-        let d = DISTANCE / root.frame().cell_edge_length() as i128;
+    commands.spawn_big_space_default::<i128>(|root| {
+        let d = DISTANCE / root.grid().cell_edge_length() as i128;
         let distant_grid_cell = GridCell::<i128>::new(d, d, d);
 
         // Normally, we would put the floating origin on the camera. However in this example, we
diff --git a/examples/error_child.rs b/examples/error_child.rs
index 49fafe5..bccd465 100644
--- a/examples/error_child.rs
+++ b/examples/error_child.rs
@@ -1,14 +1,13 @@
-//! This example demonstrates error accumulating from parent to children in nested reference frames.
+//! This example demonstrates error accumulating from parent to children in nested grids.
 use bevy::{math::DVec3, prelude::*};
 use bevy_color::palettes;
-use big_space::{commands::BigSpaceCommands, reference_frame::ReferenceFrame, FloatingOrigin};
+use big_space::prelude::*;
 
 fn main() {
     App::new()
         .add_plugins((
-            DefaultPlugins.build().disable::<TransformPlugin>(),
-            // bevy_inspector_egui::quick::WorldInspectorPlugin::new(),
-            big_space::BigSpacePlugin::<i64>::default(),
+            DefaultPlugins,
+            BigSpacePlugin::<i64>::default(),
             big_space::camera::CameraControllerPlugin::<i64>::default(),
             big_space::debug::FloatingOriginDebugPlugin::<i64>::default(),
         ))
@@ -18,8 +17,17 @@ fn main() {
 
 // The nearby object is NEARBY meters away from us. The distance object is DISTANT meters away from
 // us, and has a child that is DISTANT meters toward us (relative its parent) minus NEARBY meters.
-const DISTANT: DVec3 = DVec3::new(1e10, 1e10, 1e10);
-const SPHERE_RADIUS: f32 = 10.0;
+//
+// The result is two spheres that should perfectly overlap, even though one of those spheres is a
+// child of an object more than one quadrillion meters away. This example intentionally results in a
+// small amount of error, to demonstrate the scales and precision available even between different
+// grids.
+//
+// Note that as you increase the distance further, there are still no rendering errors, and the
+// green sphere does not vanish, however, as you move farther away, you will see that the green
+// sphere will pop into neighboring cells due to rounding error.
+const DISTANT: DVec3 = DVec3::new(1e17, 1e17, 1e17);
+const SPHERE_RADIUS: f32 = 1.0;
 const NEARBY: Vec3 = Vec3::new(SPHERE_RADIUS * 20.0, SPHERE_RADIUS * 20.0, 0.0);
 
 fn setup_scene(
@@ -28,70 +36,60 @@ fn setup_scene(
     mut materials: ResMut<Assets<StandardMaterial>>,
 ) {
     let mesh_handle = meshes.add(Sphere::new(SPHERE_RADIUS).mesh());
-    let matl_handle = materials.add(StandardMaterial {
-        base_color: Color::srgb(0.8, 0.7, 0.6),
-        ..default()
-    });
 
-    commands.spawn_big_space(
-        ReferenceFrame::<i64>::new(SPHERE_RADIUS * 100.0, 0.0),
-        |root_frame| {
-            root_frame.spawn_spatial((
+    commands.spawn_big_space::<i64>(Grid::new(SPHERE_RADIUS * 100.0, 0.0), |root_grid| {
+        root_grid.spawn_spatial((
+            Mesh3d(mesh_handle.clone()),
+            MeshMaterial3d(materials.add(Color::from(palettes::css::BLUE))),
+            Transform::from_translation(NEARBY),
+        ));
+
+        let parent = root_grid.grid().translation_to_grid(DISTANT);
+        root_grid.with_grid(Grid::new(SPHERE_RADIUS * 100.0, 0.0), |parent_grid| {
+            // This function introduces a small amount of error, because it can only work up
+            // to double precision floats. (f64).
+            let child = parent_grid
+                .grid()
+                .translation_to_grid(-DISTANT + NEARBY.as_dvec3());
+            parent_grid.insert((
                 Mesh3d(mesh_handle.clone()),
-                MeshMaterial3d(materials.add(Color::from(palettes::css::BLUE))),
-                Transform::from_translation(NEARBY),
+                MeshMaterial3d(materials.add(Color::from(palettes::css::RED))),
+                Transform::from_translation(parent.1),
             ));
+            parent_grid.insert(parent.0);
 
-            let parent = root_frame.frame().translation_to_grid(DISTANT);
-            root_frame.with_frame(
-                ReferenceFrame::new(SPHERE_RADIUS * 100.0, 0.0),
-                |parent_frame| {
-                    // This function introduces a small amount of error, because it can only work up
-                    // to double precision floats. (f64).
-                    let child = parent_frame
-                        .frame()
-                        .translation_to_grid(-DISTANT + NEARBY.as_dvec3());
-                    parent_frame.insert((
-                        Mesh3d(mesh_handle.clone()),
-                        MeshMaterial3d(matl_handle.clone()),
-                        Transform::from_translation(parent.1),
-                    ));
-                    parent_frame.insert(parent.0);
-
-                    // A green sphere that is a child of the sphere very far from the origin.
-                    // This child is very far from its parent, and should be located exactly at
-                    // the origin (if there was no floating point error). The distance from the
-                    // green sphere to the red sphere is the error caused by float imprecision.
-                    // Note that the sphere does not have any rendering artifacts, its position
-                    // just has a fixed error.
-                    parent_frame.with_child((
-                        Mesh3d(mesh_handle),
-                        MeshMaterial3d(materials.add(Color::from(palettes::css::GREEN))),
-                        Transform::from_translation(child.1),
-                        child.0,
-                    ));
-                },
-            );
-
-            root_frame.spawn_spatial((
-                DirectionalLight::default(),
-                Transform::from_xyz(4.0, -10.0, -4.0),
+            // A green sphere that is a child of the sphere very far from the origin. This
+            // child is very far from its parent, and should be located exactly at the
+            // NEARBY position (if there was no floating point error). The distance from the
+            // green sphere to the blue sphere is the error caused by float imprecision.
+            // Note that the sphere does not have any rendering artifacts, its position just
+            // has a fixed error.
+            parent_grid.spawn((
+                Mesh3d(mesh_handle),
+                MeshMaterial3d(materials.add(Color::from(palettes::css::GREEN))),
+                Transform::from_translation(child.1),
+                child.0,
             ));
+        });
 
-            root_frame.spawn_spatial((
-                Camera3d::default(),
-                Transform::from_translation(NEARBY + Vec3::new(0.0, 0.0, SPHERE_RADIUS * 10.0))
-                    .looking_at(NEARBY, Vec3::Y),
-                Projection::Perspective(PerspectiveProjection {
-                    near: (SPHERE_RADIUS * 0.1).min(0.1),
-                    ..default()
-                }),
-                FloatingOrigin,
-                big_space::camera::CameraController::default() // Built-in camera controller
-                    .with_speed_bounds([10e-18, 10e35])
-                    .with_smoothness(0.9, 0.8)
-                    .with_speed(1.0),
-            ));
-        },
-    );
+        root_grid.spawn_spatial((
+            DirectionalLight::default(),
+            Transform::from_xyz(4.0, -10.0, -4.0),
+        ));
+
+        root_grid.spawn_spatial((
+            Camera3d::default(),
+            Transform::from_translation(NEARBY + Vec3::new(0.0, 0.0, SPHERE_RADIUS * 10.0))
+                .looking_at(NEARBY, Vec3::Y),
+            Projection::Perspective(PerspectiveProjection {
+                near: (SPHERE_RADIUS * 0.1).min(0.1),
+                ..default()
+            }),
+            FloatingOrigin,
+            big_space::camera::CameraController::default() // Built-in camera controller
+                .with_speed_bounds([10e-18, 10e35])
+                .with_smoothness(0.9, 0.8)
+                .with_speed(1.0),
+        ));
+    });
 }
diff --git a/examples/infinite.rs b/examples/infinite.rs
new file mode 100644
index 0000000..5c0c4e7
--- /dev/null
+++ b/examples/infinite.rs
@@ -0,0 +1,52 @@
+//! Big spaces are infinite, looping back on themselves smoothly.
+
+use bevy::prelude::*;
+use big_space::prelude::*;
+
+fn main() {
+    App::new()
+        .add_plugins((
+            DefaultPlugins,
+            BigSpacePlugin::<i8>::default(),
+            FloatingOriginDebugPlugin::<i8>::default(), // Draws cell AABBs and grids
+            big_space::camera::CameraControllerPlugin::<i8>::default(), // Compatible controller
+        ))
+        .add_systems(Startup, setup_scene)
+        .run();
+}
+
+fn setup_scene(
+    mut commands: Commands,
+    mut meshes: ResMut<Assets<Mesh>>,
+    mut materials: ResMut<Assets<StandardMaterial>>,
+) {
+    let sphere = Mesh3d(meshes.add(Sphere::default()));
+    let matl = MeshMaterial3d(materials.add(Color::WHITE));
+
+    commands.spawn_big_space::<i8>(Grid::default(), |root_grid| {
+        let width = || -8..8;
+        for (x, y, z) in width()
+            .flat_map(|x| width().map(move |y| (x, y)))
+            .flat_map(|(x, y)| width().map(move |z| (x, y, z)))
+        {
+            root_grid.spawn_spatial((
+                sphere.clone(),
+                matl.clone(),
+                GridCell::<i8> {
+                    x: x * 16,
+                    y: y * 16,
+                    z: z * 16,
+                },
+            ));
+        }
+        root_grid.spawn_spatial(DirectionalLight::default());
+        root_grid.spawn_spatial((
+            Camera3d::default(),
+            Transform::from_xyz(0.0, 0.0, 10.0),
+            FloatingOrigin,
+            big_space::camera::CameraController::default()
+                .with_speed(10.)
+                .with_smoothness(0.99, 0.95),
+        ));
+    });
+}
diff --git a/examples/minimal.rs b/examples/minimal.rs
new file mode 100644
index 0000000..3576a4c
--- /dev/null
+++ b/examples/minimal.rs
@@ -0,0 +1,73 @@
+//! Minimal example of spawning meshes and a floating origin camera.
+
+use bevy::prelude::*;
+use bevy_math::DVec3;
+use big_space::prelude::*;
+
+// Spawn the camera and mesh really, stupidly, far from the origin .
+const BIG_DISTANCE: f64 = 1_000_000_000_000_000_000.0;
+
+fn main() {
+    App::new()
+        .add_plugins((
+            DefaultPlugins,
+            BigSpacePlugin::<i64>::default(),
+            FloatingOriginDebugPlugin::<i64>::default(), // Draws cell AABBs and grids
+            big_space::camera::CameraControllerPlugin::<i64>::default(), // Compatible controller
+        ))
+        .add_systems(Startup, setup_scene)
+        .run();
+}
+
+fn setup_scene(
+    mut commands: Commands,
+    asset_server: Res<AssetServer>,
+    mut meshes: ResMut<Assets<Mesh>>,
+    mut materials: ResMut<Assets<StandardMaterial>>,
+) {
+    // Using `spawn_big_space` helps you avoid mistakes when building hierarchies. Most notably,
+    // it will allow you to only write out the `GridPrecision` generic value (i64 in this case)
+    // once, without needing to repeat this generic when spawning `GridCell<i64>`s
+    //
+    // A world can have multiple independent BigSpaces, with their own floating origins. This can
+    // come in handy if you want to have two cameras very far from each other, rendering at the same
+    // time like split screen, or portals.
+    commands.spawn_big_space_default::<i64>(|root_grid| {
+        // Because BIG_DISTANCE is so large, we want to avoid using bevy's f32 transforms alone and
+        // experience rounding errors. Instead, we use this helper to convert an f64 position into a
+        // grid cell and f32 offset.
+        let (grid_cell, cell_offset) = root_grid
+            .grid()
+            .translation_to_grid(DVec3::splat(BIG_DISTANCE));
+
+        // `spawn_spatial` will spawn a high-precision spatial entity with floating origin support.
+        root_grid.spawn_spatial(DirectionalLight::default());
+
+        // Spawn a sphere mesh with high precision.
+        root_grid.spawn_spatial((
+            Mesh3d(meshes.add(Sphere::default())),
+            MeshMaterial3d(materials.add(Color::WHITE)),
+            Transform::from_translation(cell_offset),
+            grid_cell,
+        ));
+
+        // Spawning low-precision entities (without a GridCell) as children of high-precision
+        // entities (with a GridCell), is also supported. We demonstrate this here by loading in a
+        // GLTF scene, which will be added as a child of this entity using low precision Transforms.
+        root_grid.spawn_spatial((
+            SceneRoot(asset_server.load("models/low_poly_spaceship/scene.gltf#Scene0")),
+            Transform::from_translation(cell_offset - 10.0),
+            grid_cell,
+        ));
+
+        // Any spatial entity can be the floating origin. Attaching it to the camera ensures the
+        // camera will never see floating point precision rendering artifacts.
+        root_grid.spawn_spatial((
+            Camera3d::default(),
+            Transform::from_translation(cell_offset + Vec3::new(0.0, 0.0, 10.0)),
+            grid_cell,
+            FloatingOrigin,
+            big_space::camera::CameraController::default(),
+        ));
+    });
+}
diff --git a/examples/particles.rs b/examples/particles.rs
new file mode 100644
index 0000000..a0fe98c
--- /dev/null
+++ b/examples/particles.rs
@@ -0,0 +1,164 @@
+//! Demonstration of using `bevy_hanabi` gpu particles with `big_space` to render a particle trail
+//! that follows the camera even when it moves between cells.
+
+use bevy::prelude::*;
+use big_space::prelude::*;
+
+fn main() {
+    App::new()
+        .add_plugins((
+            DefaultPlugins,
+            BigSpacePlugin::<i64>::default(),
+            big_space::camera::CameraControllerPlugin::<i64>::default(),
+            big_space::debug::FloatingOriginDebugPlugin::<i64>::default(),
+            bevy_hanabi::HanabiPlugin, // TODO fix once hanabi updates to bevy 0.15
+        ))
+        .add_systems(Startup, setup_scene)
+        .add_systems(
+            PostUpdate,
+            update_trail.after(TransformSystem::TransformPropagate),
+        )
+        .run();
+}
+
+fn setup_scene(
+    mut commands: Commands,
+    mut meshes: ResMut<Assets<Mesh>>,
+    mut materials: ResMut<Assets<StandardMaterial>>,
+    mut effects: ResMut<Assets<bevy_hanabi::EffectAsset>>,
+) {
+    let effect = effects.add(particle_effect());
+    commands.spawn_big_space_default::<i64>(|root| {
+        root.spawn_spatial(DirectionalLight::default());
+        root.spawn_spatial((
+            Mesh3d(meshes.add(Sphere::default())),
+            MeshMaterial3d(materials.add(Color::BLACK)),
+        ));
+
+        root.spawn_spatial((
+            Transform::from_xyz(0.0, 0.0, 50.0),
+            Camera {
+                hdr: true,
+                clear_color: ClearColorConfig::Custom(Color::BLACK),
+                ..default()
+            },
+            Camera3d::default(),
+            bevy::core_pipeline::bloom::Bloom {
+                intensity: 0.2,
+                ..default()
+            },
+            FloatingOrigin,
+            big_space::camera::CameraController::default().with_smoothness(0.98, 0.9),
+        ));
+
+        // Because we want the trail to be fixed in the root grid, we spawn it here,
+        // instead of on the camera itself.
+        root.spawn_spatial((
+            Name::new("effect"),
+            bevy_hanabi::ParticleEffectBundle {
+                effect: bevy_hanabi::ParticleEffect::new(effect.clone()),
+                ..Default::default()
+            },
+        ));
+    });
+}
+
+/// Update the trail with the latest camera position.
+///
+/// Working with `GlobalTransform` is preferred when working on a rendering feature like this with
+/// big_space. This is because you will be working with the same coordinates that are being sent to
+/// the GPU, allowing you to ignore GridCells and other implementation details of big_space.
+///
+/// To update our trail, all we need to do is update the latest position of the camera, from the
+/// perspective of the emitter, which is simply `cam_translation - emitter_translation`.
+///
+/// IMPORTANT: The only thing this example is missing is handling when the object with a trail moves
+/// far from the emitter. If you move too far away, you will need to spawn a new emitter at the
+/// current location of the moving object, and keep the old emitter around until the trail fades
+/// away. In other words, the object with a trail should leave behind a series of emitters behind
+/// it, like breadcrumbs, as it moves across large distances.
+fn update_trail(
+    cam: Query<&GlobalTransform, With<Camera>>,
+    query: Query<&GlobalTransform, With<bevy_hanabi::ParticleEffect>>,
+    mut effect: Query<&mut bevy_hanabi::EffectProperties>,
+) {
+    let cam = cam.single();
+    let Ok(mut properties) = effect.get_single_mut() else {
+        return;
+    };
+    for emitter in query.iter() {
+        let pos = cam.translation() - emitter.translation();
+        properties.set("latest_pos", (pos).into());
+    }
+}
+
+// Below is copied from bevy_hanabi's example. The one modification is that you always want to be
+// using `SimulationSpace::Local`. Using the global space will not work with `big_space` when
+// entities move between cells.
+
+const LIFETIME: f32 = 10.0;
+const TRAIL_SPAWN_RATE: f32 = 256.0;
+
+fn particle_effect() -> bevy_hanabi::EffectAsset {
+    use bevy_hanabi::prelude::*;
+    use bevy_math::vec4;
+
+    let writer = ExprWriter::new();
+
+    let init_position_attr = SetAttributeModifier {
+        attribute: Attribute::POSITION,
+        value: writer.lit(Vec3::ZERO).expr(),
+    };
+
+    let init_velocity_attr = SetAttributeModifier {
+        attribute: Attribute::VELOCITY,
+        value: writer.lit(Vec3::ZERO).expr(),
+    };
+
+    let init_age_attr = SetAttributeModifier {
+        attribute: Attribute::AGE,
+        value: writer.lit(0.0).expr(),
+    };
+
+    let init_lifetime_attr = SetAttributeModifier {
+        attribute: Attribute::LIFETIME,
+        value: writer.lit(999999.0).expr(),
+    };
+
+    let init_size_attr = SetAttributeModifier {
+        attribute: Attribute::SIZE,
+        value: writer.lit(20.5).expr(),
+    };
+
+    let pos = writer.add_property("latest_pos", Vec3::ZERO.into());
+    let pos = writer.prop(pos);
+
+    let move_modifier = SetAttributeModifier {
+        attribute: Attribute::POSITION,
+        value: pos.expr(),
+    };
+
+    let render_color = ColorOverLifetimeModifier {
+        gradient: Gradient::linear(vec4(3.0, 0.0, 0.0, 1.0), vec4(3.0, 0.0, 0.0, 0.0)),
+    };
+
+    EffectAsset::new(256, Spawner::once(1.0.into(), true), writer.finish())
+        .with_ribbons(32768, 1.0 / TRAIL_SPAWN_RATE, LIFETIME, 0)
+        .with_simulation_space(SimulationSpace::Local)
+        .init_groups(init_position_attr, ParticleGroupSet::single(0))
+        .init_groups(init_velocity_attr, ParticleGroupSet::single(0))
+        .init_groups(init_age_attr, ParticleGroupSet::single(0))
+        .init_groups(init_lifetime_attr, ParticleGroupSet::single(0))
+        .init_groups(init_size_attr, ParticleGroupSet::single(0))
+        .update_groups(move_modifier, ParticleGroupSet::single(0))
+        .render(SizeOverLifetimeModifier {
+            gradient: Gradient::from_keys([
+                (0., Vec3::splat(0.0)),
+                (0.1, Vec3::splat(0.0)),
+                (0.2, Vec3::splat(200.0)),
+                (1.0, Vec3::splat(0.0)),
+            ]),
+            ..default()
+        })
+        .render_groups(render_color, ParticleGroupSet::single(1))
+}
diff --git a/examples/planets.rs b/examples/planets.rs
index cdbd184..4d2e4d6 100644
--- a/examples/planets.rs
+++ b/examples/planets.rs
@@ -1,9 +1,7 @@
 use std::collections::VecDeque;
 
-/// Example with spheres at the scale and distance of the earth and moon around the sun, at 1:1
-/// scale. The earth is rotating on its axis, and the camera is in this reference frame, to
-/// demonstrate how high precision nested reference frames work at large scales.
 use bevy::{
+    color::palettes,
     core_pipeline::bloom::Bloom,
     math::DVec3,
     pbr::{CascadeShadowConfigBuilder, NotShadowCaster},
@@ -11,22 +9,14 @@ use bevy::{
     render::camera::Exposure,
     transform::TransformSystem,
 };
-use bevy_color::palettes;
-use big_space::{
-    camera::{CameraController, CameraInput},
-    commands::BigSpaceCommands,
-    reference_frame::ReferenceFrame,
-    FloatingOrigin,
-};
-use rand::Rng;
+use big_space::prelude::*;
+use turborand::{rng::Rng, TurboRand};
 
 fn main() {
     App::new()
         .add_plugins((
-            DefaultPlugins.build().disable::<TransformPlugin>(),
-            // bevy_inspector_egui::quick::WorldInspectorPlugin::new(),
-            big_space::BigSpacePlugin::<i64>::new(true),
-            // big_space::debug::FloatingOriginDebugPlugin::<i64>::default(),
+            DefaultPlugins,
+            BigSpacePlugin::<i64>::new(true),
             big_space::camera::CameraControllerPlugin::<i64>::default(),
         ))
         .insert_resource(ClearColor(Color::BLACK))
@@ -43,7 +33,7 @@ fn main() {
                     .in_set(TransformSystem::TransformPropagate)
                     .after(bevy::transform::systems::sync_simple_transforms)
                     .after(bevy::transform::systems::propagate_transforms)
-                    .after(big_space::FloatingOriginSet::PropagateLowPrecision),
+                    .after(FloatingOriginSystem::PropagateLowPrecision),
                 cursor_grab_system,
                 springy_ship
                     .after(big_space::camera::default_camera_inputs)
@@ -89,7 +79,7 @@ fn lighting(
 }
 
 fn springy_ship(
-    cam_input: Res<CameraInput>,
+    cam_input: Res<big_space::camera::CameraInput>,
     mut ship: Query<&mut Transform, With<Spaceship>>,
     mut desired_dir: Local<(Vec3, Quat)>,
     mut smoothed_rot: Local<VecDeque<Vec3>>,
@@ -150,8 +140,8 @@ fn spawn_solar_system(
         .build(),
     ));
 
-    commands.spawn_big_space(ReferenceFrame::<i64>::default(), |root_frame| {
-        root_frame.with_frame_default(|sun| {
+    commands.spawn_big_space_default::<i64>(|root_grid| {
+        root_grid.with_grid_default(|sun| {
             sun.insert((Sun, Name::new("Sun")));
             sun.spawn_spatial((
                 Mesh3d(sun_mesh_handle),
@@ -164,8 +154,8 @@ fn spawn_solar_system(
             ));
 
             let earth_pos = DVec3::Z * EARTH_ORBIT_RADIUS_M;
-            let (earth_cell, earth_pos) = sun.frame().translation_to_grid(earth_pos);
-            sun.with_frame_default(|earth| {
+            let (earth_cell, earth_pos) = sun.grid().translation_to_grid(earth_pos);
+            sun.with_grid_default(|earth| {
                 earth.insert((
                     Name::new("Earth"),
                     earth_cell,
@@ -183,7 +173,7 @@ fn spawn_solar_system(
 
                 let moon_orbit_radius_m = 385e6;
                 let moon_pos = DVec3::NEG_Z * moon_orbit_radius_m;
-                let (moon_cell, moon_pos) = earth.frame().translation_to_grid(moon_pos);
+                let (moon_cell, moon_pos) = earth.grid().translation_to_grid(moon_pos);
                 earth.spawn_spatial((
                     Name::new("Moon"),
                     Mesh3d(moon_mesh_handle),
@@ -199,7 +189,7 @@ fn spawn_solar_system(
 
                 let ball_pos =
                     DVec3::X * (EARTH_RADIUS_M + 1.0) + DVec3::NEG_Z * 30.0 + DVec3::Y * 10.0;
-                let (ball_cell, ball_pos) = earth.frame().translation_to_grid(ball_pos);
+                let (ball_cell, ball_pos) = earth.grid().translation_to_grid(ball_pos);
                 earth
                     .spawn_spatial((ball_cell, Transform::from_translation(ball_pos)))
                     .with_children(|children| {
@@ -225,11 +215,11 @@ fn spawn_solar_system(
                     });
 
                 let cam_pos = DVec3::X * (EARTH_RADIUS_M + 1.0);
-                let (cam_cell, cam_pos) = earth.frame().translation_to_grid(cam_pos);
-                earth.with_frame_default(|camera| {
+                let (cam_cell, cam_pos) = earth.grid().translation_to_grid(cam_pos);
+                earth.with_grid_default(|camera| {
                     camera.insert((
                         Transform::from_translation(cam_pos).looking_to(Vec3::NEG_Z, Vec3::X),
-                        CameraController::default() // Built-in camera controller
+                        big_space::camera::CameraController::default() // Built-in camera controller
                             .with_speed_bounds([0.1, 10e35])
                             .with_smoothness(0.98, 0.98)
                             .with_speed(1.0),
@@ -263,15 +253,15 @@ fn spawn_solar_system(
             ..default()
         });
         let star_mesh_handle = meshes.add(Sphere::new(1e10).mesh().ico(5).unwrap());
-        let mut rng = rand::thread_rng();
+        let rng = Rng::new();
         (0..1000).for_each(|_| {
-            root_frame.spawn_spatial((
+            root_grid.spawn_spatial((
                 Mesh3d(star_mesh_handle.clone()),
                 MeshMaterial3d(star_mat.clone()),
                 Transform::from_xyz(
-                    (rng.gen::<f32>() - 0.5) * 1e14,
-                    (rng.gen::<f32>() - 0.5) * 1e14,
-                    (rng.gen::<f32>() - 0.5) * 1e14,
+                    (rng.f32() - 0.5) * 1e14,
+                    (rng.f32() - 0.5) * 1e14,
+                    (rng.f32() - 0.5) * 1e14,
                 ),
             ));
         });
diff --git a/examples/small_scale.rs b/examples/small_scale.rs
new file mode 100644
index 0000000..8998cb7
--- /dev/null
+++ b/examples/small_scale.rs
@@ -0,0 +1,116 @@
+//! `big_space` isn't only useful for objects that are large, it's useful any time you want to work
+//! with big *differences* in scale. You might normally think of human scale and solar system scale
+//! being mixed in games that use double precision (f64) worlds, but you can use this floating
+//! origin plugin to work on almost any set of scales.
+//!
+//! In this example, we will be spawning spheres the size of carbon atoms, across the width of the
+//! milky way galaxy.
+
+use bevy::prelude::*;
+use bevy_math::DVec3;
+use big_space::prelude::*;
+
+const BIG_DISTANCE: f64 = 100_000_000_000_000_000_000.0; // Diameter of the milky way galaxy
+const SMALL_SCALE: f32 = 0.000_000_000_154; // Diameter of a carbon atom
+
+fn main() {
+    App::new()
+        .add_plugins((
+            DefaultPlugins,
+            BigSpacePlugin::<i128>::default(),
+            FloatingOriginDebugPlugin::<i128>::default(), // Draws cell AABBs and grids
+            big_space::camera::CameraControllerPlugin::<i128>::default(), // Compatible controller
+        ))
+        .add_systems(Startup, setup_scene)
+        .add_systems(Update, (bounce_atoms, toggle_cam_pos))
+        .insert_resource(ClearColor(Color::BLACK))
+        .run();
+}
+
+#[derive(Component)]
+struct Atom;
+
+fn setup_scene(
+    mut commands: Commands,
+    asset_server: Res<AssetServer>,
+    mut meshes: ResMut<Assets<Mesh>>,
+    mut materials: ResMut<Assets<StandardMaterial>>,
+) {
+    // Because we are working on such small scales, we need to make the grid very small. This
+    // ensures that the maximum floating point error is also very small, because no entities can
+    // ever get farther than `SMALL_SCALE * 500` units from the origin.
+    let small_grid = Grid::<i128>::new(SMALL_SCALE * 1_000.0, 0.0);
+
+    commands.spawn_big_space(small_grid, |root_grid| {
+        root_grid.spawn_spatial(DirectionalLight::default());
+
+        // A carbon atom at the origin
+        root_grid.spawn_spatial((
+            Atom,
+            Mesh3d(meshes.add(Sphere::default())),
+            MeshMaterial3d(materials.add(Color::WHITE)),
+            Transform::from_scale(Vec3::splat(SMALL_SCALE)),
+        ));
+
+        // Compute the grid cell for the far away objects
+        let (grid_cell, cell_offset) = root_grid
+            .grid()
+            .translation_to_grid(DVec3::X * BIG_DISTANCE);
+
+        // A carbon atom at the other side of the milky way
+        root_grid.spawn_spatial((
+            Atom,
+            Mesh3d(meshes.add(Sphere::default())),
+            MeshMaterial3d(materials.add(Color::WHITE)),
+            Transform::from_translation(cell_offset).with_scale(Vec3::splat(SMALL_SCALE)),
+            grid_cell,
+        ));
+
+        root_grid.spawn_spatial((
+            Camera3d::default(),
+            Projection::Perspective(PerspectiveProjection {
+                near: SMALL_SCALE * 0.01, // Without this, the atom would be clipped
+                ..Default::default()
+            }),
+            Transform::from_xyz(0.0, 0.0, SMALL_SCALE * 2.0),
+            grid_cell,
+            FloatingOrigin,
+            big_space::camera::CameraController::default(),
+        ));
+
+        // A space ship
+        root_grid.spawn_spatial((
+            SceneRoot(asset_server.load("models/low_poly_spaceship/scene.gltf#Scene0")),
+            Transform::from_xyz(0.0, 0.0, 2.5)
+                .with_rotation(Quat::from_rotation_y(std::f32::consts::PI)),
+            grid_cell,
+        ));
+    });
+
+    commands.spawn(Text::new(format!(
+        "Press `T` to teleport between the origin and ship {BIG_DISTANCE}m away."
+    )));
+}
+
+fn bounce_atoms(mut atoms: Query<&mut Transform, With<Atom>>, time: Res<Time>) {
+    for mut atom in atoms.iter_mut() {
+        atom.translation.y = time.elapsed_secs().sin() * SMALL_SCALE;
+    }
+}
+
+fn toggle_cam_pos(
+    mut cam: Query<&mut GridCell<i128>, With<Camera>>,
+    mut toggle: Local<bool>,
+    grid: Query<&Grid<i128>>,
+    keyboard: Res<ButtonInput<KeyCode>>,
+) {
+    if !keyboard.just_pressed(KeyCode::KeyT) {
+        return;
+    }
+    *cam.single_mut() = if *toggle {
+        grid.single().translation_to_grid(DVec3::X * BIG_DISTANCE).0
+    } else {
+        GridCell::ZERO
+    };
+    *toggle = !*toggle;
+}
diff --git a/examples/spatial_hash.rs b/examples/spatial_hash.rs
new file mode 100644
index 0000000..c72487f
--- /dev/null
+++ b/examples/spatial_hash.rs
@@ -0,0 +1,370 @@
+use std::hash::Hasher;
+
+use bevy::{
+    core_pipeline::{bloom::Bloom, fxaa::Fxaa, tonemapping::Tonemapping},
+    prelude::*,
+};
+use bevy_ecs::entity::EntityHasher;
+use bevy_math::DVec3;
+use big_space::prelude::*;
+use noise::{NoiseFn, Perlin};
+
+fn main() {
+    App::new()
+        .add_plugins((
+            DefaultPlugins,
+            BigSpacePlugin::<i32>::default(),
+            GridHashPlugin::<i32>::default(),
+            GridPartitionPlugin::<i32>::default(),
+            big_space::camera::CameraControllerPlugin::<i32>::default(),
+        ))
+        .add_systems(Startup, (spawn, setup_ui))
+        .add_systems(
+            PostUpdate,
+            (
+                move_player.after(TransformSystem::TransformPropagate),
+                draw_partitions.after(GridHashMapSystem::UpdatePartition),
+            ),
+        )
+        .add_systems(Update, cursor_grab)
+        .init_resource::<MaterialPresets>()
+        .run();
+}
+
+// Try bumping this up to really stress test. I'm able to push a million entities with an M3 Max.
+const N_ENTITIES: usize = 100_000;
+const HALF_WIDTH: f32 = 40.0;
+const CELL_WIDTH: f32 = 10.0;
+// How fast the entities should move, causing them to move into neighboring cells.
+const MOVEMENT_SPEED: f32 = 5.0;
+const PERCENT_STATIC: f32 = 0.9;
+
+#[derive(Component)]
+struct Player;
+
+#[derive(Component)]
+struct NonPlayer;
+
+#[derive(Resource)]
+struct MaterialPresets {
+    default: Handle<StandardMaterial>,
+    highlight: Handle<StandardMaterial>,
+    flood: Handle<StandardMaterial>,
+}
+
+impl FromWorld for MaterialPresets {
+    fn from_world(world: &mut World) -> Self {
+        let mut materials = world.resource_mut::<Assets<StandardMaterial>>();
+
+        let d: StandardMaterial = StandardMaterial {
+            base_color: Color::from(Srgba::new(0.5, 0.5, 0.5, 1.0)),
+            perceptual_roughness: 0.2,
+            metallic: 0.0,
+            ..Default::default()
+        };
+        let h: StandardMaterial = Color::from(Srgba::new(2.0, 0.0, 8.0, 1.0)).into();
+        let f: StandardMaterial = Color::from(Srgba::new(1.1, 0.1, 1.0, 1.0)).into();
+
+        Self {
+            default: materials.add(d),
+            highlight: materials.add(h),
+            flood: materials.add(f),
+        }
+    }
+}
+
+fn draw_partitions(
+    mut gizmos: Gizmos,
+    partitions: Res<GridPartitionMap<i32>>,
+    grids: Query<(&GlobalTransform, &Grid<i32>)>,
+    camera: Query<&GridHash<i32>, With<Camera>>,
+) {
+    for (id, p) in partitions.iter() {
+        let Ok((transform, grid)) = grids.get(p.grid()) else {
+            return;
+        };
+        let l = grid.cell_edge_length();
+
+        let mut hasher = EntityHasher::default();
+        hasher.write_u64(id.id());
+        let f = hasher.finish();
+        let hue = (f % 360) as f32;
+
+        p.iter()
+            .filter(|hash| *hash != camera.single())
+            .for_each(|h| {
+                let center = [h.cell().x, h.cell().y, h.cell().z];
+                let local_trans = Transform::from_translation(IVec3::from(center).as_vec3() * l)
+                    .with_scale(Vec3::splat(l));
+                gizmos.cuboid(
+                    transform.mul_transform(local_trans),
+                    Hsla::new(hue, 1.0, 0.5, 0.2),
+                );
+            });
+
+        let Some(min) = p
+            .iter()
+            .filter(|hash| *hash != camera.single())
+            .map(|h| [h.cell().x, h.cell().y, h.cell().z])
+            .reduce(|[ax, ay, az], [ix, iy, iz]| [ax.min(ix), ay.min(iy), az.min(iz)])
+            .map(|v| IVec3::from(v).as_vec3() * l)
+        else {
+            continue;
+        };
+
+        let Some(max) = p
+            .iter()
+            .filter(|hash| *hash != camera.single())
+            .map(|h| [h.cell().x, h.cell().y, h.cell().z])
+            .reduce(|[ax, ay, az], [ix, iy, iz]| [ax.max(ix), ay.max(iy), az.max(iz)])
+            .map(|v| IVec3::from(v).as_vec3() * l)
+        else {
+            continue;
+        };
+
+        let size = max - min;
+        let center = min + (size) * 0.5;
+        let local_trans = Transform::from_translation(center).with_scale(size + l * 2.0);
+
+        gizmos.cuboid(
+            transform.mul_transform(local_trans),
+            Hsla::new(hue, 1.0, 0.5, 0.2),
+        );
+    }
+}
+
+#[allow(clippy::too_many_arguments)]
+#[allow(clippy::type_complexity)]
+fn move_player(
+    time: Res<Time>,
+    mut _gizmos: Gizmos,
+    mut player: Query<(&mut Transform, &mut GridCell<i32>, &Parent, &GridHash<i32>), With<Player>>,
+    mut non_player: Query<
+        (&mut Transform, &mut GridCell<i32>, &Parent),
+        (Without<Player>, With<NonPlayer>),
+    >,
+    mut materials: Query<&mut MeshMaterial3d<StandardMaterial>, Without<Player>>,
+    mut neighbors: Local<Vec<Entity>>,
+    grids: Query<&Grid<i32>>,
+    hash_grid: Res<GridHashMap<i32>>,
+    material_presets: Res<MaterialPresets>,
+    mut text: Query<&mut Text>,
+    hash_stats: Res<big_space::timing::SmoothedStat<big_space::timing::GridHashStats>>,
+    prop_stats: Res<big_space::timing::SmoothedStat<big_space::timing::PropagationStats>>,
+) {
+    for neighbor in neighbors.iter() {
+        if let Ok(mut material) = materials.get_mut(*neighbor) {
+            **material = material_presets.default.clone_weak();
+        }
+    }
+
+    let t = time.elapsed_secs() * 1.0;
+    let scale = MOVEMENT_SPEED / HALF_WIDTH;
+    if scale.abs() > 0.0 {
+        // Avoid change detection
+        for (i, (mut transform, _, _)) in non_player.iter_mut().enumerate() {
+            if i > (PERCENT_STATIC * N_ENTITIES as f32) as usize {
+                transform.translation.x += t.sin() * scale;
+                transform.translation.y += t.cos() * scale;
+                transform.translation.z += (t * 2.3).sin() * scale;
+            }
+        }
+    }
+
+    let t = time.elapsed_secs() * 0.01;
+    let (mut transform, mut cell, parent, hash) = player.single_mut();
+    let absolute_pos = HALF_WIDTH
+        * CELL_WIDTH
+        * 0.8
+        * Vec3::new((5.0 * t).sin(), (7.0 * t).cos(), (20.0 * t).sin());
+    (*cell, transform.translation) = grids
+        .get(parent.get())
+        .unwrap()
+        .imprecise_translation_to_grid(absolute_pos);
+
+    neighbors.clear();
+
+    hash_grid.flood(hash, None).entities().for_each(|entity| {
+        neighbors.push(entity);
+        if let Ok(mut material) = materials.get_mut(entity) {
+            **material = material_presets.flood.clone_weak();
+        }
+
+        // let grid = grid.get(entry.grid).unwrap();
+        // let transform = grid.global_transform(
+        //     &entry.cell,
+        //     &Transform::from_scale(Vec3::splat(grid.cell_edge_length() * 0.99)),
+        // );
+        // gizmos.cuboid(transform, Color::linear_rgba(1.0, 1.0, 1.0, 0.2));
+    });
+
+    hash_grid
+        .get(hash)
+        .unwrap()
+        .nearby(&hash_grid)
+        .entities()
+        .for_each(|entity| {
+            neighbors.push(entity);
+            if let Ok(mut material) = materials.get_mut(entity) {
+                **material = material_presets.highlight.clone_weak();
+            }
+        });
+
+    let mut text = text.single_mut();
+    text.0 = format!(
+        "\
+Population: {: >8} Entities
+
+Transform Propagation
+Cell Recentering: {: >11.1?}
+LP Root: {: >20.1?}
+Frame Origin: {: >15.1?}
+LP Propagation: {: >13.1?}
+HP Propagation: {: >13.1?}
+
+Spatial Hashing
+Moved Cells: {: >7?} Entities
+Compute Hashes: {: >13.1?}
+Update Maps: {: >16.1?}
+Update Partitions: {: >10.1?}
+
+Total: {: >22.1?}",
+        N_ENTITIES,
+        //
+        prop_stats.avg().grid_recentering(),
+        prop_stats.avg().low_precision_root_tagging(),
+        prop_stats.avg().local_origin_propagation(),
+        prop_stats.avg().low_precision_propagation(),
+        prop_stats.avg().high_precision_propagation(),
+        //
+        hash_stats.avg().moved_cell_entities(),
+        hash_stats.avg().hash_update_duration(),
+        hash_stats.avg().map_update_duration(),
+        hash_stats.avg().update_partition(),
+        //
+        prop_stats.avg().total() + hash_stats.avg().total(),
+    );
+}
+
+fn spawn(
+    mut commands: Commands,
+    mut materials: ResMut<Assets<StandardMaterial>>,
+    mut meshes: ResMut<Assets<Mesh>>,
+    material_presets: Res<MaterialPresets>,
+) {
+    use turborand::prelude::*;
+    let rng = Rng::with_seed(342525);
+    let noise = Perlin::new(345612);
+
+    let rng = || loop {
+        let noise_scale = 5.0;
+        let threshold = 0.70;
+        let rng_val = || rng.f64_normalized() * noise_scale;
+        let coord = [rng_val(), rng_val(), rng_val()];
+        if noise.get(coord) > threshold {
+            return DVec3::from_array(coord).as_vec3() * HALF_WIDTH * CELL_WIDTH
+                / noise_scale as f32;
+        }
+    };
+
+    let values: Vec<_> = std::iter::repeat_with(rng).take(N_ENTITIES).collect();
+
+    let sphere_mesh_lq = meshes.add(
+        Sphere::new(HALF_WIDTH / (N_ENTITIES as f32).powf(0.33) * 0.2)
+            .mesh()
+            .ico(0)
+            .unwrap(),
+    );
+
+    commands.spawn_big_space::<i32>(Grid::new(CELL_WIDTH, 0.0), |root| {
+        root.spawn_spatial((
+            FloatingOrigin,
+            Camera3d::default(),
+            Camera {
+                hdr: true,
+                ..Default::default()
+            },
+            Tonemapping::AcesFitted,
+            Transform::from_xyz(0.0, 0.0, HALF_WIDTH * CELL_WIDTH * 2.0),
+            big_space::camera::CameraController::default()
+                .with_smoothness(0.98, 0.93)
+                .with_slowing(false)
+                .with_speed(15.0),
+            Fxaa::default(),
+            Bloom::default(),
+            GridCell::new(0, 0, HALF_WIDTH as i32 / 2),
+        ))
+        .with_children(|b| {
+            b.spawn(DirectionalLight::default());
+        });
+
+        for (i, value) in values.iter().enumerate() {
+            let mut sphere_builder = root.spawn((BigSpatialBundle::<i32> {
+                transform: Transform::from_xyz(value.x, value.y, value.z),
+                ..default()
+            },));
+            if i == 0 {
+                sphere_builder.insert((
+                    Player,
+                    Mesh3d(meshes.add(Sphere::new(1.0))),
+                    MeshMaterial3d(materials.add(Color::from(Srgba::new(20.0, 20.0, 0.0, 1.0)))),
+                    Transform::from_scale(Vec3::splat(2.0)),
+                ));
+            } else {
+                sphere_builder.insert((
+                    NonPlayer,
+                    Mesh3d(sphere_mesh_lq.clone()),
+                    MeshMaterial3d(material_presets.default.clone_weak()),
+                    bevy_render::view::VisibilityRange {
+                        start_margin: 1.0..5.0,
+                        end_margin: HALF_WIDTH * CELL_WIDTH * 0.5..HALF_WIDTH * CELL_WIDTH * 0.8,
+                        use_aabb: false,
+                    },
+                ));
+            }
+        }
+    });
+}
+
+fn setup_ui(mut commands: Commands, asset_server: Res<AssetServer>) {
+    commands
+        .spawn((
+            Node {
+                width: Val::Auto,
+                height: Val::Auto,
+                padding: UiRect::all(Val::Px(16.)),
+                margin: UiRect::all(Val::Px(12.)),
+                border: UiRect::all(Val::Px(1.)),
+                ..default()
+            },
+            BorderRadius::all(Val::Px(8.0)),
+            BorderColor(Color::linear_rgba(0.03, 0.03, 0.03, 0.95)),
+            BackgroundColor(Color::linear_rgba(0.012, 0.012, 0.012, 0.95)),
+        ))
+        .with_children(|parent| {
+            parent.spawn((
+                Text::default(),
+                TextFont {
+                    font: asset_server.load("fonts/FiraMono-Regular.ttf"),
+                    font_size: 14.0,
+                    ..default()
+                },
+            ));
+        });
+}
+
+fn cursor_grab(
+    keyboard: Res<ButtonInput<KeyCode>>,
+    mouse: Res<ButtonInput<MouseButton>>,
+    mut windows: Query<&mut Window, With<bevy::window::PrimaryWindow>>,
+) {
+    let mut primary_window = windows.single_mut();
+    if mouse.just_pressed(MouseButton::Left) {
+        primary_window.cursor_options.grab_mode = bevy::window::CursorGrabMode::Locked;
+        primary_window.cursor_options.visible = false;
+    }
+    if keyboard.just_pressed(KeyCode::Escape) {
+        primary_window.cursor_options.grab_mode = bevy::window::CursorGrabMode::None;
+        primary_window.cursor_options.visible = true;
+    }
+}
diff --git a/examples/split_screen.rs b/examples/split_screen.rs
index 1715978..7539c98 100644
--- a/examples/split_screen.rs
+++ b/examples/split_screen.rs
@@ -10,21 +10,15 @@ use bevy::{
     transform::TransformSystem,
 };
 use bevy_color::palettes;
-use big_space::{
-    camera::{CameraController, CameraControllerPlugin},
-    commands::BigSpaceCommands,
-    reference_frame::ReferenceFrame,
-    world_query::{GridTransform, GridTransformReadOnly},
-    BigSpacePlugin, FloatingOrigin,
-};
+use big_space::prelude::*;
 
 fn main() {
     App::new()
         .add_plugins((
-            DefaultPlugins.build().disable::<TransformPlugin>(),
+            DefaultPlugins,
             BigSpacePlugin::<i32>::default(),
-            big_space::debug::FloatingOriginDebugPlugin::<i32>::default(),
-            CameraControllerPlugin::<i32>::default(),
+            FloatingOriginDebugPlugin::<i32>::default(),
+            big_space::camera::CameraControllerPlugin::<i32>::default(),
         ))
         .add_systems(Startup, setup)
         .add_systems(Update, set_camera_viewports)
@@ -61,27 +55,26 @@ fn setup(
     ));
 
     // Big Space 1
-    commands.spawn_big_space(ReferenceFrame::<i32>::default(), |root_frame| {
-        root_frame
-            .spawn_spatial((
-                Camera3d::default(),
-                Transform::from_xyz(1_000_000.0 - 10.0, 100_005.0, 0.0)
-                    .looking_to(Vec3::NEG_X, Vec3::Y),
-                CameraController::default().with_smoothness(0.8, 0.8),
-                RenderLayers::layer(2),
-                LeftCamera,
-                FloatingOrigin,
-            ))
-            .with_child((
-                Mesh3d(meshes.add(Cuboid::new(1.0, 2.0, 1.0))),
-                MeshMaterial3d(materials.add(StandardMaterial {
-                    base_color: Color::Srgba(palettes::css::YELLOW),
-                    ..default()
-                })),
-                RenderLayers::layer(2),
-            ));
-
-        root_frame.spawn_spatial((
+    commands.spawn_big_space_default::<i32>(|root| {
+        root.spawn_spatial((
+            Camera3d::default(),
+            Transform::from_xyz(1_000_000.0 - 10.0, 100_005.0, 0.0)
+                .looking_to(Vec3::NEG_X, Vec3::Y),
+            big_space::camera::CameraController::default().with_smoothness(0.8, 0.8),
+            RenderLayers::layer(2),
+            LeftCamera,
+            FloatingOrigin,
+        ))
+        .with_child((
+            Mesh3d(meshes.add(Cuboid::new(1.0, 2.0, 1.0))),
+            MeshMaterial3d(materials.add(StandardMaterial {
+                base_color: Color::Srgba(palettes::css::YELLOW),
+                ..default()
+            })),
+            RenderLayers::layer(2),
+        ));
+
+        root.spawn_spatial((
             RightCameraReplicated,
             Mesh3d(meshes.add(Cuboid::new(1.0, 2.0, 1.0))),
             MeshMaterial3d(materials.add(StandardMaterial {
@@ -91,7 +84,7 @@ fn setup(
             RenderLayers::layer(2),
         ));
 
-        root_frame.spawn_spatial((
+        root.spawn_spatial((
             Mesh3d(meshes.add(Sphere::new(1.0).mesh().ico(35).unwrap())),
             MeshMaterial3d(materials.add(StandardMaterial {
                 base_color: Color::Srgba(palettes::css::BLUE),
@@ -101,7 +94,7 @@ fn setup(
             RenderLayers::layer(2),
         ));
 
-        root_frame.spawn_spatial((
+        root.spawn_spatial((
             Mesh3d(meshes.add(Sphere::new(1.0).mesh().ico(35).unwrap())),
             MeshMaterial3d(materials.add(StandardMaterial {
                 base_color: Color::Srgba(palettes::css::GREEN),
@@ -113,30 +106,29 @@ fn setup(
     });
 
     // Big Space 2
-    commands.spawn_big_space(ReferenceFrame::<i32>::default(), |root_frame| {
-        root_frame
-            .spawn_spatial((
-                Camera3d::default(),
-                Transform::from_xyz(1_000_000.0, 100_005.0, 0.0).looking_to(Vec3::NEG_X, Vec3::Y),
-                Camera {
-                    order: 1,
-                    clear_color: ClearColorConfig::None,
-                    ..default()
-                },
-                RenderLayers::layer(1),
-                RightCamera,
-                FloatingOrigin,
-            ))
-            .with_child((
-                Mesh3d(meshes.add(Cuboid::new(1.0, 2.0, 1.0))),
-                MeshMaterial3d(materials.add(StandardMaterial {
-                    base_color: Color::Srgba(palettes::css::PINK),
-                    ..default()
-                })),
-                RenderLayers::layer(1),
-            ));
-
-        root_frame.spawn_spatial((
+    commands.spawn_big_space_default::<i32>(|root| {
+        root.spawn_spatial((
+            Camera3d::default(),
+            Camera {
+                order: 1,
+                clear_color: ClearColorConfig::None,
+                ..default()
+            },
+            Transform::from_xyz(1_000_000.0, 100_005.0, 0.0).looking_to(Vec3::NEG_X, Vec3::Y),
+            RenderLayers::layer(1),
+            RightCamera,
+            FloatingOrigin,
+        ))
+        .with_child((
+            Mesh3d(meshes.add(Cuboid::new(1.0, 2.0, 1.0))),
+            MeshMaterial3d(materials.add(StandardMaterial {
+                base_color: Color::Srgba(palettes::css::FUCHSIA),
+                ..default()
+            })),
+            RenderLayers::layer(1),
+        ));
+
+        root.spawn_spatial((
             LeftCameraReplicated,
             Mesh3d(meshes.add(Cuboid::new(1.0, 2.0, 1.0))),
             MeshMaterial3d(materials.add(StandardMaterial {
@@ -146,7 +138,7 @@ fn setup(
             RenderLayers::layer(1),
         ));
 
-        root_frame.spawn_spatial((
+        root.spawn_spatial((
             Mesh3d(meshes.add(Sphere::new(1.0).mesh().ico(35).unwrap())),
             MeshMaterial3d(materials.add(StandardMaterial {
                 base_color: Color::Srgba(palettes::css::BLUE),
@@ -156,7 +148,7 @@ fn setup(
             RenderLayers::layer(1),
         ));
 
-        root_frame.spawn_spatial((
+        root.spawn_spatial((
             Mesh3d(meshes.add(Sphere::new(1.0).mesh().ico(35).unwrap())),
             MeshMaterial3d(materials.add(StandardMaterial {
                 base_color: Color::Srgba(palettes::css::GREEN),
diff --git a/src/bundles.rs b/src/bundles.rs
index 125b072..d120c09 100644
--- a/src/bundles.rs
+++ b/src/bundles.rs
@@ -1,7 +1,6 @@
 //! Component bundles for big_space.
 
-use crate::{precision::GridPrecision, reference_frame::ReferenceFrame, BigSpace, GridCell};
-
+use crate::prelude::*;
 use bevy_ecs::prelude::*;
 use bevy_transform::prelude::*;
 
@@ -27,12 +26,12 @@ pub struct BigSpatialBundle<P: GridPrecision> {
     pub cell: GridCell<P>,
 }
 
-/// A `SpatialBundle` that also has a reference frame, allowing other high precision spatial bundles
-/// to be nested within that reference frame.
+/// A `SpatialBundle` that also has a grid, allowing other high precision spatial bundles to be
+/// nested within that grid.
 ///
 /// This is the floating origin equivalent of the `bevy` `SpatialBundle`.
 #[derive(Bundle, Default)]
-pub struct BigReferenceFrameBundle<P: GridPrecision> {
+pub struct BigGridBundle<P: GridPrecision> {
     /// The visibility of the entity.
     #[cfg(feature = "bevy_render")]
     pub visibility: bevy_render::view::Visibility,
@@ -40,10 +39,10 @@ pub struct BigReferenceFrameBundle<P: GridPrecision> {
     pub transform: Transform,
     /// The global transform of the entity for rendering, computed relative to the floating origin.
     pub global_transform: GlobalTransform,
-    /// The grid position of the entity within
+    /// The grid position of the grid within its parent grid.
     pub cell: GridCell<P>,
-    /// The reference frame
-    pub reference_frame: ReferenceFrame<P>,
+    /// The grid.
+    pub grid: Grid<P>,
 }
 
 /// The root of any [`BigSpace`] needs these components to function.
@@ -52,8 +51,10 @@ pub struct BigSpaceRootBundle<P: GridPrecision> {
     /// The visibility of the entity.
     #[cfg(feature = "bevy_render")]
     pub visibility: bevy_render::view::Visibility,
-    /// The root reference frame
-    pub reference_frame: ReferenceFrame<P>,
-    /// Tracks the current floating origin
+    /// The root grid
+    pub grid: Grid<P>,
+    /// The rendered position of the root grid relative to the floating origin.
+    pub global_transform: GlobalTransform,
+    /// Tracks the current floating origin.
     pub root: BigSpace,
 }
diff --git a/src/camera.rs b/src/camera.rs
index 4fb08dc..3a84a35 100644
--- a/src/camera.rs
+++ b/src/camera.rs
@@ -2,6 +2,7 @@
 
 use std::marker::PhantomData;
 
+use crate::prelude::*;
 use bevy_app::prelude::*;
 use bevy_ecs::prelude::*;
 use bevy_hierarchy::prelude::*;
@@ -16,11 +17,6 @@ use bevy_time::prelude::*;
 use bevy_transform::{prelude::*, TransformSystem};
 use bevy_utils::HashSet;
 
-use crate::{
-    precision::GridPrecision, reference_frame::local_origin::ReferenceFrames,
-    world_query::GridTransform,
-};
-
 /// Adds the `big_space` camera controller
 #[derive(Default)]
 pub struct CameraControllerPlugin<P: GridPrecision>(PhantomData<P>);
@@ -32,7 +28,7 @@ impl<P: GridPrecision> Plugin for CameraControllerPlugin<P> {
                 default_camera_inputs
                     .before(camera_controller::<P>)
                     .run_if(|input: Res<CameraInput>| !input.defaults_disabled),
-                nearest_objects_in_frame::<P>.before(camera_controller::<P>),
+                nearest_objects_in_grid::<P>.before(camera_controller::<P>),
                 camera_controller::<P>.before(TransformSystem::TransformPropagate),
             ),
         );
@@ -122,11 +118,11 @@ impl CameraController {
 impl Default for CameraController {
     fn default() -> Self {
         Self {
-            smoothness: 0.8,
-            rotational_smoothness: 0.5,
+            smoothness: 0.85,
+            rotational_smoothness: 0.8,
             speed: 1.0,
-            speed_pitch: 1.0,
-            speed_yaw: 1.0,
+            speed_pitch: 2.0,
+            speed_yaw: 2.0,
             speed_roll: 1.0,
             speed_bounds: [1e-17, 1e30],
             slow_near_objects: true,
@@ -137,8 +133,9 @@ impl Default for CameraController {
     }
 }
 
-/// ButtonInput state used to command camera motion. Reset every time the values are read to update the
-/// camera. Allows you to map any input to camera motions. Uses aircraft principle axes conventions.
+/// ButtonInput state used to command camera motion. Reset every time the values are read to update
+/// the camera. Allows you to map any input to camera motions. Uses aircraft principle axes
+/// conventions.
 #[derive(Clone, Debug, Default, Reflect, Resource)]
 pub struct CameraInput {
     /// When disabled, the camera input system is not run.
@@ -160,7 +157,7 @@ pub struct CameraInput {
 }
 
 impl CameraInput {
-    /// Reset the controller back to zero to ready fro the next frame.
+    /// Reset the controller back to zero to ready fro the next grid.
     pub fn reset(&mut self) {
         *self = CameraInput {
             defaults_disabled: self.defaults_disabled,
@@ -213,15 +210,15 @@ pub fn default_camera_inputs(
     }
 }
 
-/// Find the object nearest the camera, within the same reference frame as the camera.
-pub fn nearest_objects_in_frame<P: GridPrecision>(
+/// Find the object nearest the camera, within the same grid as the camera.
+pub fn nearest_objects_in_grid<P: GridPrecision>(
     objects: Query<(
         Entity,
         &Transform,
         &GlobalTransform,
         &Aabb,
         Option<&RenderLayers>,
-        Option<&InheritedVisibility>,
+        &InheritedVisibility,
     )>,
     mut camera: Query<(
         Entity,
@@ -234,6 +231,9 @@ pub fn nearest_objects_in_frame<P: GridPrecision>(
     let Ok((cam_entity, mut camera, cam_pos, cam_layer)) = camera.get_single_mut() else {
         return;
     };
+    if !camera.slow_near_objects {
+        return;
+    }
     let cam_layer = cam_layer.to_owned().unwrap_or_default();
     let cam_children: HashSet<Entity> = children.iter_descendants(cam_entity).collect();
 
@@ -244,10 +244,7 @@ pub fn nearest_objects_in_frame<P: GridPrecision>(
             let obj_layer = obj_layer.unwrap_or_default();
             cam_layer.intersects(obj_layer)
         })
-        .filter(|(.., visibility)| {
-            let visibility = visibility.copied().unwrap_or(InheritedVisibility::VISIBLE);
-            visibility.get()
-        })
+        .filter(|(.., visibility)| visibility.get())
         .map(|(entity, object_local, obj_pos, aabb, ..)| {
             let center_distance =
                 obj_pos.translation().as_dvec3() - cam_pos.translation().as_dvec3();
@@ -265,12 +262,17 @@ pub fn nearest_objects_in_frame<P: GridPrecision>(
 /// Uses [`CameraInput`] state to update the camera position.
 pub fn camera_controller<P: GridPrecision>(
     time: Res<Time>,
-    frames: ReferenceFrames<P>,
+    grids: crate::grid::local_origin::Grids<P>,
     mut input: ResMut<CameraInput>,
-    mut camera: Query<(Entity, GridTransform<P>, &mut CameraController)>,
+    mut camera: Query<(
+        Entity,
+        &mut GridCell<P>,
+        &mut Transform,
+        &mut CameraController,
+    )>,
 ) {
-    for (camera, mut position, mut controller) in camera.iter_mut() {
-        let Some(frame) = frames.parent_frame(camera) else {
+    for (camera, mut cell, mut transform, mut controller) in camera.iter_mut() {
+        let Some(grid) = grids.parent_grid(camera) else {
             continue;
         };
         let speed = match (controller.nearest_object, controller.slow_near_objects) {
@@ -288,18 +290,19 @@ pub fn camera_controller<P: GridPrecision>(
         let (vel_t_target, vel_r_target) =
             input.target_velocity(&controller, speed, time.delta_secs_f64());
 
-        let cam_rot = position.transform.rotation.as_dquat();
+        let cam_rot = transform.rotation.as_dquat();
         let vel_t_next = cam_rot * vel_t_target; // Orients the translation to match the camera
         let vel_t_next = vel_t_current.lerp(vel_t_next, lerp_translation);
         // Convert the high precision translation to a grid cell and low precision translation
-        let (cell_offset, new_translation) = frame.translation_to_grid(vel_t_next);
-        *position.cell += cell_offset;
-        position.transform.translation += new_translation;
+        let (cell_offset, new_translation) = grid.translation_to_grid(vel_t_next);
+        let new = *cell.bypass_change_detection() + cell_offset;
+        cell.set_if_neq(new);
+        transform.translation += new_translation;
 
         let new_rotation = vel_r_current.slerp(vel_r_target, lerp_rotation);
-        position.transform.rotation *= new_rotation.as_quat();
+        transform.rotation *= new_rotation.as_quat();
 
-        // Store the new velocity to be used in the next frame
+        // Store the new velocity to be used in the next grid
         controller.vel_translation = vel_t_next;
         controller.vel_rotation = new_rotation;
 
diff --git a/src/commands.rs b/src/commands.rs
index 5d9e1f3..5a0ab12 100644
--- a/src/commands.rs
+++ b/src/commands.rs
@@ -1,72 +1,88 @@
 //! Adds `big_space`-specific commands to bevy's `Commands`.
 
+use crate::prelude::*;
+use bevy_ecs::prelude::*;
+use bevy_hierarchy::prelude::*;
+use bevy_transform::prelude::*;
+use smallvec::SmallVec;
 use std::marker::PhantomData;
 
-use crate::{reference_frame::ReferenceFrame, *};
-
-use self::precision::GridPrecision;
-
 /// Adds `big_space` commands to bevy's `Commands`.
-pub trait BigSpaceCommands<P: GridPrecision> {
-    /// Spawn a root [`BigSpace`] [`ReferenceFrame`].
-    fn spawn_big_space(
+pub trait BigSpaceCommands {
+    /// Spawn a root [`BigSpace`] [`Grid`].
+    fn spawn_big_space<P: GridPrecision>(
+        &mut self,
+        root_grid: Grid<P>,
+        child_builder: impl FnOnce(&mut GridCommands<P>),
+    );
+
+    /// Spawn a root [`BigSpace`] with default [`Grid`] settings.
+    fn spawn_big_space_default<P: GridPrecision>(
         &mut self,
-        root_frame: ReferenceFrame<P>,
-        child_builder: impl FnOnce(&mut ReferenceFrameCommands<P>),
+        child_builder: impl FnOnce(&mut GridCommands<P>),
     );
 }
 
-impl<P: GridPrecision> BigSpaceCommands<P> for Commands<'_, '_> {
-    fn spawn_big_space(
+impl BigSpaceCommands for Commands<'_, '_> {
+    fn spawn_big_space<P: GridPrecision>(
         &mut self,
-        reference_frame: ReferenceFrame<P>,
-        root_frame: impl FnOnce(&mut ReferenceFrameCommands<P>),
+        grid: Grid<P>,
+        root_grid: impl FnOnce(&mut GridCommands<P>),
     ) {
-        let mut entity_commands = self.spawn((
-            #[cfg(feature = "bevy_render")]
-            bevy_render::view::Visibility::default(),
-            #[cfg(feature = "bevy_render")]
-            bevy_render::view::InheritedVisibility::default(),
-            #[cfg(feature = "bevy_render")]
-            bevy_render::view::ViewVisibility::default(),
-            BigSpace::default(),
-        ));
-        let mut cmd = ReferenceFrameCommands {
+        let mut entity_commands = self.spawn(BigSpaceRootBundle::<P>::default());
+        let mut cmd = GridCommands {
             entity: entity_commands.id(),
             commands: entity_commands.commands(),
-            reference_frame,
+            grid,
+            children: Default::default(),
         };
-        root_frame(&mut cmd);
+        root_grid(&mut cmd);
+    }
+
+    fn spawn_big_space_default<P: GridPrecision>(
+        &mut self,
+        child_builder: impl FnOnce(&mut GridCommands<P>),
+    ) {
+        self.spawn_big_space(Grid::default(), child_builder);
     }
 }
 
-/// Build [`big_space`](crate) hierarchies more easily, with access to reference frames.
-pub struct ReferenceFrameCommands<'a, P: GridPrecision> {
+/// Build [`big_space`](crate) hierarchies more easily, with access to grids.
+pub struct GridCommands<'a, P: GridPrecision> {
     entity: Entity,
     commands: Commands<'a, 'a>,
-    reference_frame: ReferenceFrame<P>,
+    grid: Grid<P>,
+    children: SmallVec<[Entity; 8]>,
 }
 
-impl<P: GridPrecision> ReferenceFrameCommands<'_, P> {
-    /// Get a reference to the current reference frame.
-    pub fn frame(&mut self) -> &ReferenceFrame<P> {
-        &self.reference_frame
+impl<'a, P: GridPrecision> GridCommands<'a, P> {
+    /// Get a reference to the current grid.
+    pub fn grid(&mut self) -> &Grid<P> {
+        &self.grid
     }
 
-    /// Insert a component on this reference frame
+    /// Insert a component on this grid
     pub fn insert(&mut self, bundle: impl Bundle) -> &mut Self {
         self.commands.entity(self.entity).insert(bundle);
         self
     }
 
-    /// Add a high-precision spatial entity ([`GridCell`]) to this reference frame, and insert the
-    /// provided bundle.
-    pub fn spawn_spatial(&mut self, bundle: impl Bundle) -> SpatialEntityCommands<P> {
-        let mut entity_commands = self.commands.entity(self.entity);
-        let parent = entity_commands.id();
-        let mut commands = entity_commands.commands();
+    /// Spawn an entity in this grid.
+    pub fn spawn(&mut self, bundle: impl Bundle) -> SpatialEntityCommands<P> {
+        let entity = self.commands.spawn(bundle).id();
+        self.children.push(entity);
+        SpatialEntityCommands {
+            entity,
+            commands: self.commands.reborrow(),
+            phantom: PhantomData,
+        }
+    }
 
-        let entity = commands
+    /// Add a high-precision spatial entity ([`GridCell`]) to this grid, and insert the provided
+    /// bundle.
+    pub fn spawn_spatial(&mut self, bundle: impl Bundle) -> SpatialEntityCommands<P> {
+        let entity = self
+            .commands
             .spawn((
                 #[cfg(feature = "bevy_render")]
                 bevy_render::view::Visibility::default(),
@@ -76,7 +92,7 @@ impl<P: GridPrecision> ReferenceFrameCommands<'_, P> {
             .insert(bundle)
             .id();
 
-        commands.entity(entity).set_parent(parent);
+        self.children.push(entity);
 
         SpatialEntityCommands {
             entity,
@@ -90,7 +106,9 @@ impl<P: GridPrecision> ReferenceFrameCommands<'_, P> {
         self.entity
     }
 
-    /// Add a high-precision spatial entity ([`GridCell`]) to this reference frame, and apply entity commands to it via the closure. This allows you to insert bundles on this new spatial entities, and add more children to it.
+    /// Add a high-precision spatial entity ([`GridCell`]) to this grid, and apply entity commands
+    /// to it via the closure. This allows you to insert bundles on this new spatial entities, and
+    /// add more children to it.
     pub fn with_spatial(
         &mut self,
         spatial: impl FnOnce(&mut SpatialEntityCommands<P>),
@@ -99,32 +117,26 @@ impl<P: GridPrecision> ReferenceFrameCommands<'_, P> {
         self
     }
 
-    /// Add a high-precision spatial entity ([`GridCell`]) to this reference frame, and apply entity commands to it via the closure. This allows you to insert bundles on this new spatial entities, and add more children to it.
-    pub fn with_frame(
+    /// Add a high-precision spatial entity ([`GridCell`]) to this grid, and apply entity commands
+    /// to it via the closure. This allows you to insert bundles on this new spatial entities, and
+    /// add more children to it.
+    pub fn with_grid(
         &mut self,
-        new_frame: ReferenceFrame<P>,
-        builder: impl FnOnce(&mut ReferenceFrameCommands<P>),
+        new_grid: Grid<P>,
+        builder: impl FnOnce(&mut GridCommands<P>),
     ) -> &mut Self {
-        builder(&mut self.spawn_frame(new_frame, ()));
+        builder(&mut self.spawn_grid(new_grid, ()));
         self
     }
 
-    /// Same as [`Self::with_frame`], but using the default [`ReferenceFrame`] value.
-    pub fn with_frame_default(
-        &mut self,
-        builder: impl FnOnce(&mut ReferenceFrameCommands<P>),
-    ) -> &mut Self {
-        self.with_frame(ReferenceFrame::default(), builder)
+    /// Same as [`Self::with_grid`], but using the default [`Grid`] value.
+    pub fn with_grid_default(&mut self, builder: impl FnOnce(&mut GridCommands<P>)) -> &mut Self {
+        self.with_grid(Grid::default(), builder)
     }
 
-    /// Spawn a reference frame as a child of the current reference frame.
-    pub fn spawn_frame(
-        &mut self,
-        new_frame: ReferenceFrame<P>,
-        bundle: impl Bundle,
-    ) -> ReferenceFrameCommands<P> {
+    /// Spawn a grid as a child of the current grid.
+    pub fn spawn_grid(&mut self, new_grid: Grid<P>, bundle: impl Bundle) -> GridCommands<P> {
         let mut entity_commands = self.commands.entity(self.entity);
-        let parent = entity_commands.id();
         let mut commands = entity_commands.commands();
 
         let entity = commands
@@ -133,65 +145,72 @@ impl<P: GridPrecision> ReferenceFrameCommands<'_, P> {
                 bevy_render::view::Visibility::default(),
                 Transform::default(),
                 GridCell::<P>::default(),
-                ReferenceFrame::<P>::default(),
+                Grid::<P>::default(),
             ))
             .insert(bundle)
             .id();
 
-        commands.entity(entity).set_parent(parent);
+        self.children.push(entity);
 
-        ReferenceFrameCommands {
+        GridCommands {
             entity,
             commands: self.commands.reborrow(),
-            reference_frame: new_frame,
+            grid: new_grid,
+            children: Default::default(),
         }
     }
 
-    /// Spawn a reference frame as a child of the current reference frame. The first argument in the
-    /// closure is the paren't reference frame.
-    pub fn spawn_frame_default(&mut self, bundle: impl Bundle) -> ReferenceFrameCommands<P> {
-        self.spawn_frame(ReferenceFrame::default(), bundle)
+    /// Spawn a grid as a child of the current grid.
+    pub fn spawn_grid_default(&mut self, bundle: impl Bundle) -> GridCommands<P> {
+        self.spawn_grid(Grid::default(), bundle)
     }
 
-    /// Takes a closure which provides this reference frame and a [`ChildBuilder`].
-    pub fn with_children(&mut self, spawn_children: impl FnOnce(&mut ChildBuilder)) -> &mut Self {
-        self.commands
-            .entity(self.entity)
-            .with_children(|child_builder| spawn_children(child_builder));
-        self
+    /// Access the underlying commands.
+    pub fn commands(&mut self) -> &mut Commands<'a, 'a> {
+        &mut self.commands
     }
 
-    /// Spawns the passed bundle which provides this reference frame,
-    /// and adds it to this entity as a child.
+    /// Spawns the passed bundle which provides this grid, and adds it to this entity as a child.
     pub fn with_child<B: Bundle>(&mut self, bundle: B) -> &mut Self {
         self.commands.entity(self.entity).with_child(bundle);
         self
     }
 }
 
-/// Insert the reference frame on drop.
-impl<P: GridPrecision> Drop for ReferenceFrameCommands<'_, P> {
+/// Insert the grid on drop.
+impl<P: GridPrecision> Drop for GridCommands<'_, P> {
     fn drop(&mut self) {
+        let entity = self.entity;
         self.commands
-            .entity(self.entity)
-            .insert(std::mem::take(&mut self.reference_frame));
+            .entity(entity)
+            .insert(std::mem::take(&mut self.grid))
+            .add_children(&self.children);
     }
 }
 
-/// Build [`big_space`](crate) hierarchies more easily, with access to reference frames.
+/// Build [`big_space`](crate) hierarchies more easily, with access to grids.
 pub struct SpatialEntityCommands<'a, P: GridPrecision> {
     entity: Entity,
     commands: Commands<'a, 'a>,
     phantom: PhantomData<P>,
 }
 
-impl<P: GridPrecision> SpatialEntityCommands<'_, P> {
-    /// Insert a component on this reference frame
+impl<'a, P: GridPrecision> SpatialEntityCommands<'a, P> {
+    /// Insert a component on this grid
     pub fn insert(&mut self, bundle: impl Bundle) -> &mut Self {
         self.commands.entity(self.entity).insert(bundle);
         self
     }
 
+    /// Removes a `Bundle`` of components from the entity.
+    pub fn remove<T>(&mut self) -> &mut Self
+    where
+        T: Bundle,
+    {
+        self.commands.entity(self.entity).remove::<T>();
+        self
+    }
+
     /// Takes a closure which provides a [`ChildBuilder`].
     pub fn with_children(&mut self, spawn_children: impl FnOnce(&mut ChildBuilder)) -> &mut Self {
         self.commands
@@ -210,4 +229,9 @@ impl<P: GridPrecision> SpatialEntityCommands<'_, P> {
     pub fn id(&self) -> Entity {
         self.entity
     }
+
+    /// Access the underlying commands.
+    pub fn commands(&mut self) -> &mut Commands<'a, 'a> {
+        &mut self.commands
+    }
 }
diff --git a/src/debug.rs b/src/debug.rs
index 907fdd7..571510e 100644
--- a/src/debug.rs
+++ b/src/debug.rs
@@ -2,68 +2,88 @@
 
 use std::marker::PhantomData;
 
+use crate::prelude::*;
 use bevy_app::prelude::*;
-use bevy_color::{
-    palettes::css::{BLUE, GREEN, RED},
-    prelude::*,
-};
+use bevy_color::prelude::*;
 use bevy_ecs::prelude::*;
 use bevy_gizmos::prelude::*;
 use bevy_math::prelude::*;
+use bevy_reflect::Reflect;
 use bevy_transform::prelude::*;
 
-use crate::{
-    precision::GridPrecision,
-    reference_frame::{local_origin::ReferenceFrames, ReferenceFrame},
-    FloatingOrigin, GridCell,
-};
-
 /// This plugin will render the bounds of occupied grid cells.
 #[derive(Default)]
 pub struct FloatingOriginDebugPlugin<P: GridPrecision>(PhantomData<P>);
 impl<P: GridPrecision> Plugin for FloatingOriginDebugPlugin<P> {
     fn build(&self, app: &mut App) {
-        app.add_systems(
-            PostUpdate,
-            (update_debug_bounds::<P>, update_reference_frame_axes::<P>)
-                .chain()
-                .after(bevy_transform::TransformSystem::TransformPropagate),
-        );
+        app.init_gizmo_group::<BigSpaceGizmoConfig>()
+            .add_systems(Startup, setup_gizmos)
+            .add_systems(
+                PostUpdate,
+                (update_debug_bounds::<P>, update_grid_axes::<P>)
+                    .chain()
+                    .after(bevy_transform::TransformSystem::TransformPropagate),
+            );
     }
 }
 
+fn setup_gizmos(mut store: ResMut<GizmoConfigStore>) {
+    let (config, _) = store.config_mut::<BigSpaceGizmoConfig>();
+    config.line_perspective = false;
+    config.line_joints = GizmoLineJoint::Round(4);
+    config.line_width = 1.0;
+}
+
 /// Update the rendered debug bounds to only highlight occupied [`GridCell`]s.
-pub fn update_debug_bounds<P: GridPrecision>(
-    mut gizmos: Gizmos,
-    reference_frames: ReferenceFrames<P>,
+fn update_debug_bounds<P: GridPrecision>(
+    mut gizmos: Gizmos<BigSpaceGizmoConfig>,
+    grids: Grids<P>,
     occupied_cells: Query<(Entity, &GridCell<P>, Option<&FloatingOrigin>)>,
 ) {
     for (cell_entity, cell, origin) in occupied_cells.iter() {
-        let Some(frame) = reference_frames.parent_frame(cell_entity) else {
+        let Some(grid) = grids.parent_grid(cell_entity) else {
             continue;
         };
-        let transform = frame.global_transform(
+        let transform = grid.global_transform(
             cell,
-            &Transform::from_scale(Vec3::splat(frame.cell_edge_length() * 0.999)),
+            &Transform::from_scale(Vec3::splat(grid.cell_edge_length() * 0.999)),
         );
         if origin.is_none() {
-            gizmos.cuboid(transform, Color::Srgba(GREEN))
+            gizmos.cuboid(transform, Color::linear_rgb(0.0, 1.0, 0.0))
         } else {
             // gizmos.cuboid(transform, Color::rgba(0.0, 0.0, 1.0, 0.5))
         }
     }
 }
 
-/// Draw axes for reference frames.
-pub fn update_reference_frame_axes<P: GridPrecision>(
-    mut gizmos: Gizmos,
-    frames: Query<(&GlobalTransform, &ReferenceFrame<P>)>,
+#[derive(Default, Reflect)]
+struct BigSpaceGizmoConfig;
+
+impl GizmoConfigGroup for BigSpaceGizmoConfig {}
+
+/// Draw axes for grids.
+fn update_grid_axes<P: GridPrecision>(
+    mut gizmos: Gizmos<BigSpaceGizmoConfig>,
+    grids: Query<(&GlobalTransform, &Grid<P>)>,
 ) {
-    for (transform, frame) in frames.iter() {
+    for (transform, grid) in grids.iter() {
         let start = transform.translation();
-        let len = frame.cell_edge_length() * 2.0;
-        gizmos.ray(start, transform.right() * len, Color::Srgba(RED));
-        gizmos.ray(start, transform.up() * len, Color::Srgba(GREEN));
-        gizmos.ray(start, transform.back() * len, Color::Srgba(BLUE));
+        // Scale with distance
+        let len = (start.length().powf(0.9)).max(grid.cell_edge_length()) * 0.5;
+        gizmos.ray(
+            start,
+            transform.right() * len,
+            Color::linear_rgb(1.0, 0.0, 0.0),
+        );
+        gizmos.ray(
+            start,
+            transform.up() * len,
+            Color::linear_rgb(0.0, 1.0, 0.0),
+        );
+        gizmos.ray(
+            start,
+            transform.back() * len,
+            Color::linear_rgb(0.0, 0.0, 1.0),
+        );
     }
 }
diff --git a/src/floating_origins.rs b/src/floating_origins.rs
index 9610ff3..097b935 100644
--- a/src/floating_origins.rs
+++ b/src/floating_origins.rs
@@ -4,41 +4,48 @@ use bevy_ecs::prelude::*;
 use bevy_hierarchy::prelude::*;
 use bevy_reflect::prelude::*;
 use bevy_utils::HashMap;
-use tracing::error;
 
 /// Marks the entity to use as the floating origin.
 ///
+/// This can also be thought of as the location of the low precision 32 bit rendering origin. More
+/// accurately, the *cell* that this entity is located in defines the position of the rendering
+/// origin. As this entity moves through space, the floating origin used for computing
+/// [`GlobalTransform`](bevy_transform::components::GlobalTransform)s will only change when the
+/// entity moves into a new cell.
+///
 /// The [`GlobalTransform`](bevy_transform::components::GlobalTransform) of all entities within this
-/// [`BigSpace`] will be computed relative to this floating origin. There should always be exactly
-/// one entity marked with this component within a [`BigSpace`].
+/// [`BigSpace`] will be computed relative to this floating origin's cell. There should always be
+/// exactly one entity marked with this component within a [`BigSpace`].
 #[derive(Component, Reflect)]
 #[reflect(Component)]
 pub struct FloatingOrigin;
 
-/// A "big space" is a hierarchy of high precision reference frames, rendered with a floating
-/// origin. It is the root of this high precision hierarchy, and tracks the [`FloatingOrigin`]
-/// inside this hierarchy.
+/// A "big space" is a hierarchy of high precision [`Grid`](crate::Grid)s, rendered relative to a
+/// [`FloatingOrigin`]. This component marks the root of a high precision hierarchy, and tracks the
+/// [`FloatingOrigin`] inside this hierarchy.
 ///
-/// This component must also be paired with a [`ReferenceFrame`](crate::ReferenceFrame), which
-/// defines the properties of this root reference frame. A hierarchy can have many nested
-/// `ReferenceFrame`s, but only one `BigSpace`, at the root.
+/// This component must also be paired with a [`Grid`](crate::Grid), which defines the properties of
+/// this root grid. A hierarchy can have many nested [`Grid`](crate::Grid)s, but only one
+/// [`BigSpace`], at the root.
 ///
 /// Your world can have multiple [`BigSpace`]s, and they will remain completely independent. Each
 /// big space uses the floating origin contained within it to compute the
 /// [`GlobalTransform`](bevy_transform::components::GlobalTransform) of all spatial entities within
-/// that `BigSpace`.
+/// that [`BigSpace`]. This is needed for features like split screen, where you may need to render
+/// the world from viewpoints that are very far from each other.
 #[derive(Debug, Default, Component, Reflect)]
 #[reflect(Component)]
-// We do not require ReferenceFrame, because we want more control over when the reference frame is
-// inserted, especially with the command extension.
+// We do not require Grid, because we want more control over when the grid is inserted, especially
+// with the command extension.
 pub struct BigSpace {
     /// Set the entity to use as the floating origin within this high precision hierarchy.
+    ///
+    /// This is automatically set by [`Self::find_floating_origin`].
     pub floating_origin: Option<Entity>,
 }
 
 impl BigSpace {
-    /// Return the this reference frame's floating origin if it exists and is a descendent of this
-    /// root.
+    /// Return the this grid's floating origin if it exists and is a descendent of this root.
     ///
     /// `this_entity`: the entity this component belongs to.
     pub(crate) fn validate_floating_origin(
@@ -65,7 +72,9 @@ impl BigSpace {
             space.floating_origin = None;
             spaces_set.insert(entity, 0);
         }
-        // Navigate to the root of the hierarchy, starting from each floating origin. This is faster than the reverse direction because it is a tree, and an entity can only have a single parent, but many children. The root should have an empty floating_origin field.
+        // Navigate to the root of the hierarchy, starting from each floating origin. This is faster
+        // than the reverse direction because it is a tree, and an entity can only have a single
+        // parent, but many children. The root should have an empty floating_origin field.
         for origin in &floating_origins {
             let maybe_root = parent_query.iter_ancestors(origin).last();
             if let Some((root, mut space)) =
@@ -74,7 +83,7 @@ impl BigSpace {
                 let space_origins = spaces_set.entry(root).or_default();
                 *space_origins += 1;
                 if *space_origins > 1 {
-                    error!(
+                    tracing::error!(
                         "BigSpace {root:#?} has multiple floating origins. There must be exactly one. Resetting this big space and disabling the floating origin to avoid unexpected propagation behavior.",
                     );
                     space.floating_origin = None
@@ -90,7 +99,7 @@ impl BigSpace {
             .filter(|(_k, v)| **v == 0)
             .map(|(k, _v)| k)
         {
-            error!("BigSpace {space:#?} has no floating origins. There must be exactly one. Transform propagation will not work until there is a FloatingOrigin in the hierarchy.",)
+            tracing::error!("BigSpace {space:#} has no floating origins. There must be exactly one. Transform propagation will not work until there is a FloatingOrigin in the hierarchy.",)
         }
     }
 }
diff --git a/src/grid/cell.rs b/src/grid/cell.rs
new file mode 100644
index 0000000..7953dbf
--- /dev/null
+++ b/src/grid/cell.rs
@@ -0,0 +1,268 @@
+//! Contains the grid cell implementation
+
+use crate::prelude::*;
+use bevy_ecs::{component::ComponentId, prelude::*, world::DeferredWorld};
+use bevy_hierarchy::prelude::*;
+use bevy_math::{DVec3, IVec3};
+use bevy_reflect::prelude::*;
+use bevy_transform::prelude::*;
+use bevy_utils::Instant;
+
+/// Marks entities with any generic [`GridCell`] component. Allows you to query for high precision
+/// spatial entities of any [`GridPrecision`].
+///
+/// Also useful for filtering. You might want to run queries on things without a grid cell, however
+/// there could by many generic types of grid cell. `Without<GridCellAny>` will cover all of these
+/// cases.
+///
+/// This is automatically added and removed by the component lifecycle hooks on [`GridCell`].
+#[derive(Component, Default, Debug, Clone, Copy, Reflect)]
+#[reflect(Component, Default)]
+pub struct GridCellAny;
+
+/// Locates an entity in a cell within its parent's [`Grid`]. The [`Transform`] of an entity with
+/// this component is a transformation from the center of this cell.
+///
+/// All entities with a [`GridCell`] must be children of an entity with a [`Grid`].
+///
+/// This component adds precision to the translation of an entity's [`Transform`]. In a
+/// high-precision [`BigSpace`], the position of an entity is described by a [`Transform`] *and* a
+/// [`GridCell`]. This component is the index of a cell inside a large [`Grid`], and the
+/// [`Transform`] is the floating point position of the entity relative to the center of this cell.
+///
+/// If an entity's [`Transform`] becomes large enough that the entity leaves the bounds of its cell,
+/// the [`GridCell`] and [`Transform`] will be automatically recomputed to keep the [`Transform`]
+/// small.
+///
+/// [`BigSpace`]s are only allowed to have a single type of `GridCell`, you cannot mix
+/// [`GridPrecision`]s.
+#[derive(Component, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Reflect)]
+#[reflect(Component, Default, PartialEq)]
+#[require(Transform, GlobalTransform)]
+#[component(storage = "Table", on_add = Self::on_add, on_remove = Self::on_remove)]
+pub struct GridCell<P: GridPrecision> {
+    /// The x-index of the cell.
+    pub x: P,
+    /// The y-index of the cell.
+    pub y: P,
+    /// The z-index of the cell.
+    pub z: P,
+}
+
+impl<P: GridPrecision> GridCell<P> {
+    fn on_add(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
+        assert!(world.get::<GridCellAny>(entity).is_none(), "Adding multiple GridCell<P>s with different generic values on the same entity is not supported");
+        world.commands().entity(entity).insert(GridCellAny);
+    }
+
+    fn on_remove(mut world: DeferredWorld, entity: Entity, _: ComponentId) {
+        world.commands().entity(entity).remove::<GridCellAny>();
+    }
+
+    /// Construct a new [`GridCell`].
+    pub fn new(x: P, y: P, z: P) -> Self {
+        Self { x, y, z }
+    }
+
+    /// The origin [`GridCell`].
+    pub const ZERO: Self = GridCell {
+        x: P::ZERO,
+        y: P::ZERO,
+        z: P::ZERO,
+    };
+
+    /// A unit value [`GridCell`]. Useful for offsets.
+    pub const ONE: Self = GridCell {
+        x: P::ONE,
+        y: P::ONE,
+        z: P::ONE,
+    };
+
+    /// Convert this grid cell to a floating point translation within this `grid`.
+    pub fn as_dvec3(&self, grid: &Grid<P>) -> DVec3 {
+        DVec3 {
+            x: self.x.as_f64() * grid.cell_edge_length() as f64,
+            y: self.y.as_f64() * grid.cell_edge_length() as f64,
+            z: self.z.as_f64() * grid.cell_edge_length() as f64,
+        }
+    }
+
+    /// If an entity's transform translation becomes larger than the limit specified in its
+    /// [`Grid`], it will be relocated to the nearest grid cell to reduce the size of the transform.
+    pub fn recenter_large_transforms(
+        mut stats: ResMut<crate::timing::PropagationStats>,
+        grids: Query<&Grid<P>>,
+        mut changed_transform: Query<(&mut Self, &mut Transform, &Parent), Changed<Transform>>,
+    ) {
+        let start = Instant::now();
+        changed_transform
+            .par_iter_mut()
+            .for_each(|(mut grid_pos, mut transform, parent)| {
+                let Ok(grid) = grids.get(parent.get()) else {
+                    return;
+                };
+                if transform
+                    .bypass_change_detection()
+                    .translation
+                    .abs()
+                    .max_element()
+                    > grid.maximum_distance_from_origin()
+                {
+                    let (grid_cell_delta, translation) = grid.imprecise_translation_to_grid(
+                        transform.bypass_change_detection().translation,
+                    );
+                    *grid_pos += grid_cell_delta;
+                    transform.translation = translation;
+                }
+            });
+        stats.grid_recentering += start.elapsed();
+    }
+}
+
+impl<P: GridPrecision> std::ops::Add for GridCell<P> {
+    type Output = GridCell<P>;
+
+    fn add(self, rhs: Self) -> Self::Output {
+        GridCell {
+            x: self.x.wrapping_add(rhs.x),
+            y: self.y.wrapping_add(rhs.y),
+            z: self.z.wrapping_add(rhs.z),
+        }
+    }
+}
+
+impl<P: GridPrecision> std::ops::Add<IVec3> for GridCell<P> {
+    type Output = GridCell<P>;
+
+    fn add(self, rhs: IVec3) -> Self::Output {
+        GridCell {
+            x: self.x.wrapping_add_i32(rhs.x),
+            y: self.y.wrapping_add_i32(rhs.y),
+            z: self.z.wrapping_add_i32(rhs.z),
+        }
+    }
+}
+
+impl<P: GridPrecision> std::ops::Sub for GridCell<P> {
+    type Output = GridCell<P>;
+
+    fn sub(self, rhs: Self) -> Self::Output {
+        GridCell {
+            x: self.x.wrapping_sub(rhs.x),
+            y: self.y.wrapping_sub(rhs.y),
+            z: self.z.wrapping_sub(rhs.z),
+        }
+    }
+}
+
+impl<P: GridPrecision> std::ops::Sub<IVec3> for GridCell<P> {
+    type Output = GridCell<P>;
+
+    fn sub(self, rhs: IVec3) -> Self::Output {
+        GridCell {
+            x: self.x.wrapping_add_i32(-rhs.x),
+            y: self.y.wrapping_add_i32(-rhs.y),
+            z: self.z.wrapping_add_i32(-rhs.z),
+        }
+    }
+}
+
+impl<P: GridPrecision> std::ops::Add for &GridCell<P> {
+    type Output = GridCell<P>;
+
+    fn add(self, rhs: Self) -> Self::Output {
+        (*self).add(*rhs)
+    }
+}
+
+impl<P: GridPrecision> std::ops::Add<IVec3> for &GridCell<P> {
+    type Output = GridCell<P>;
+
+    fn add(self, rhs: IVec3) -> Self::Output {
+        (*self).add(rhs)
+    }
+}
+
+impl<P: GridPrecision> std::ops::Sub for &GridCell<P> {
+    type Output = GridCell<P>;
+
+    fn sub(self, rhs: Self) -> Self::Output {
+        (*self).sub(*rhs)
+    }
+}
+
+impl<P: GridPrecision> std::ops::Sub<IVec3> for &GridCell<P> {
+    type Output = GridCell<P>;
+
+    fn sub(self, rhs: IVec3) -> Self::Output {
+        (*self).sub(rhs)
+    }
+}
+
+impl<P: GridPrecision> std::ops::AddAssign for GridCell<P> {
+    fn add_assign(&mut self, rhs: Self) {
+        use std::ops::Add;
+        *self = self.add(rhs);
+    }
+}
+
+impl<P: GridPrecision> std::ops::AddAssign<IVec3> for GridCell<P> {
+    fn add_assign(&mut self, rhs: IVec3) {
+        use std::ops::Add;
+        *self = self.add(rhs);
+    }
+}
+
+impl<P: GridPrecision> std::ops::SubAssign for GridCell<P> {
+    fn sub_assign(&mut self, rhs: Self) {
+        use std::ops::Sub;
+        *self = self.sub(rhs);
+    }
+}
+
+impl<P: GridPrecision> std::ops::SubAssign<IVec3> for GridCell<P> {
+    fn sub_assign(&mut self, rhs: IVec3) {
+        use std::ops::Sub;
+        *self = self.sub(rhs);
+    }
+}
+
+impl<P: GridPrecision> std::ops::Mul<P> for GridCell<P> {
+    type Output = GridCell<P>;
+
+    fn mul(self, rhs: P) -> Self::Output {
+        GridCell {
+            x: GridPrecision::mul(self.x, rhs),
+            y: GridPrecision::mul(self.y, rhs),
+            z: GridPrecision::mul(self.z, rhs),
+        }
+    }
+}
+
+impl<P: GridPrecision> std::ops::Mul<P> for &GridCell<P> {
+    type Output = GridCell<P>;
+
+    fn mul(self, rhs: P) -> Self::Output {
+        (*self).mul(rhs)
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use bevy::prelude::*;
+
+    #[test]
+    #[should_panic(
+        expected = "Adding multiple GridCell<P>s with different generic values on the same entity is not supported"
+    )]
+    fn disallow_multiple_grid_cells_on_same_entity() {
+        App::new()
+            .add_systems(Startup, |mut commands: Commands| {
+                commands
+                    .spawn_empty()
+                    .insert(super::GridCell::<i8>::default())
+                    .insert(super::GridCell::<i16>::default());
+            })
+            .run();
+    }
+}
diff --git a/src/grid/local_origin.rs b/src/grid/local_origin.rs
new file mode 100644
index 0000000..1972865
--- /dev/null
+++ b/src/grid/local_origin.rs
@@ -0,0 +1,748 @@
+//! Describes how the floating origin's position is propagated through the hierarchy of reference
+//! grids, and used to compute the floating origin's position relative to each grid. See
+//! [`LocalFloatingOrigin`].
+
+use crate::prelude::*;
+use bevy_ecs::{
+    prelude::*,
+    system::{
+        lifetimeless::{Read, Write},
+        SystemParam,
+    },
+};
+use bevy_hierarchy::prelude::*;
+use bevy_math::{prelude::*, DAffine3, DQuat};
+use bevy_transform::prelude::*;
+
+pub use inner::LocalFloatingOrigin;
+
+use super::Grid;
+
+/// A module kept private to enforce use of setters and getters within the parent module.
+mod inner {
+    use crate::prelude::*;
+    use bevy_math::{prelude::*, DAffine3, DMat3, DQuat};
+    use bevy_reflect::prelude::*;
+
+    /// An isometry that describes the location of the floating origin's grid cell's origin, in the
+    /// local grid.
+    ///
+    /// Used to compute the [`GlobalTransform`](bevy_transform::components::GlobalTransform) of
+    /// every entity within a grid. Because this tells us where the floating origin cell is located
+    /// in the local grid, we can compute the inverse transform once, then use it to transform every
+    /// entity relative to the floating origin.
+    ///
+    /// If the floating origin is in this local grid, the `float` fields will be identity. The
+    /// `float` fields will be non-identity when the floating origin is in a different grid that
+    /// does not perfectly align with this one. Different grids can be rotated and offset from each
+    /// other - consider the grid of a planet, spinning about its axis and orbiting about a star, it
+    /// will not align with the grid of the star system!
+    #[derive(Default, Debug, Clone, PartialEq, Reflect)]
+    pub struct LocalFloatingOrigin<P: GridPrecision> {
+        /// The local cell that the floating origin's grid cell origin falls into.
+        cell: GridCell<P>,
+        /// The translation of the floating origin's grid cell relative to the origin of
+        /// [`LocalFloatingOrigin::cell`].
+        translation: Vec3,
+        /// The rotation of the floating origin's grid cell relative to the origin of
+        /// [`LocalFloatingOrigin::cell`].
+        rotation: DQuat,
+        /// Transform from the local grid to the floating origin's grid cell. This is used to
+        /// compute the `GlobalTransform` of all entities in this grid.
+        ///
+        /// Imagine you have the local grid and the floating origin's grid overlapping in space,
+        /// misaligned. This transform is the smallest possible that will align the two grid grids,
+        /// going from the local grid, to the floating origin's grid.
+        ///
+        /// This is like a camera's "view transform", but instead of transforming an object into a
+        /// camera's view space, this will transform an object into the floating origin's reference
+        /// grid.
+        ///   - That object must be positioned in the same [`super::Grid`] that this
+        ///     [`LocalFloatingOrigin`] is part of.
+        ///   - That object's position must be relative to the same grid cell as defined by
+        ///     [`Self::cell`].
+        ///
+        /// The above requirements help to ensure this transform has a small magnitude, maximizing
+        /// precision, and minimizing floating point error.
+        grid_transform: DAffine3,
+        /// Returns `true` iff the position of the floating origin's grid origin has not moved
+        /// relative to this grid.
+        ///
+        /// When true, this means that any entities in this grid that have not moved do not need to
+        /// have their `GlobalTransform` recomputed.
+        is_local_origin_unchanged: bool,
+    }
+
+    impl<P: GridPrecision> LocalFloatingOrigin<P> {
+        /// The grid transform from the local grid, to the floating origin's grid. See
+        /// [Self::grid_transform].
+        #[inline]
+        pub fn grid_transform(&self) -> DAffine3 {
+            self.grid_transform
+        }
+
+        /// Gets [`Self::cell`].
+        #[inline]
+        pub fn cell(&self) -> GridCell<P> {
+            self.cell
+        }
+
+        /// Gets [`Self::translation`].
+        #[inline]
+        pub fn translation(&self) -> Vec3 {
+            self.translation
+        }
+
+        /// Gets [`Self::rotation`].
+        #[inline]
+        pub fn rotation(&self) -> DQuat {
+            self.rotation
+        }
+
+        /// Update this local floating origin, and compute the new inverse transform.
+        pub fn set(
+            &mut self,
+            translation_grid: GridCell<P>,
+            translation_float: Vec3,
+            rotation_float: DQuat,
+        ) {
+            let prev = self.clone();
+
+            self.cell = translation_grid;
+            self.translation = translation_float;
+            self.rotation = rotation_float;
+            self.grid_transform = DAffine3 {
+                matrix3: DMat3::from_quat(self.rotation),
+                translation: self.translation.as_dvec3(),
+            }
+            .inverse();
+            self.is_local_origin_unchanged = prev.eq(self);
+        }
+
+        /// Create a new [`LocalFloatingOrigin`].
+        pub fn new(cell: GridCell<P>, translation: Vec3, rotation: DQuat) -> Self {
+            let grid_transform = DAffine3 {
+                matrix3: DMat3::from_quat(rotation),
+                translation: translation.as_dvec3(),
+            }
+            .inverse();
+
+            Self {
+                cell,
+                translation,
+                rotation,
+                grid_transform,
+                is_local_origin_unchanged: false,
+            }
+        }
+
+        /// Returns true iff the local origin has not changed relative to the floating origin.
+        #[inline]
+        pub fn is_local_origin_unchanged(&self) -> bool {
+            self.is_local_origin_unchanged
+        }
+    }
+}
+
+fn propagate_origin_to_parent<P: GridPrecision>(
+    this_grid_entity: Entity,
+    grids: &mut GridsMut<P>,
+    parent_grid_entity: Entity,
+) {
+    let (this_grid, this_cell, this_transform) = grids.get(this_grid_entity);
+    let (parent_grid, _parent_cell, _parent_transform) = grids.get(parent_grid_entity);
+
+    // Get this grid's double precision transform, relative to its cell. We ignore the grid cell
+    // here because we don't want to lose precision - we can do these calcs relative to this cell,
+    // then add the grid cell offset at the end.
+    let this_transform = DAffine3::from_rotation_translation(
+        this_transform.rotation.as_dquat(),
+        this_transform.translation.as_dvec3(),
+    );
+
+    // Get the origin's double position in this grid
+    let origin_translation = this_grid.grid_position_double(
+        &this_grid.local_floating_origin.cell(),
+        &Transform::from_translation(this_grid.local_floating_origin.translation()),
+    );
+    let this_local_origin_transform = DAffine3::from_rotation_translation(
+        this_grid.local_floating_origin.rotation(),
+        origin_translation,
+    );
+
+    // Multiply to move the origin into the parent's grid
+    let origin_affine = this_transform * this_local_origin_transform;
+
+    let (_, origin_rot, origin_trans) = origin_affine.to_scale_rotation_translation();
+    let (origin_cell_relative_to_this_cell, origin_translation_remainder) =
+        parent_grid.translation_to_grid(origin_trans);
+
+    // Up until now we have been computing as if this cell is located at the origin, to maximize
+    // precision. Now that we are done with floats, we can add the cell offset.
+    let parent_origin_cell = origin_cell_relative_to_this_cell + this_cell;
+
+    grids.update(parent_grid_entity, |parent_grid, _, _| {
+        parent_grid.local_floating_origin.set(
+            parent_origin_cell,
+            origin_translation_remainder,
+            origin_rot,
+        );
+    });
+}
+
+fn propagate_origin_to_child<P: GridPrecision>(
+    this_grid_entity: Entity,
+    grids: &mut GridsMut<P>,
+    child_grid_entity: Entity,
+) {
+    let (this_grid, _this_cell, _this_transform) = grids.get(this_grid_entity);
+    let (child_grid, child_cell, child_transform) = grids.get(child_grid_entity);
+
+    // compute double precision translation of origin treating child as the origin grid cell. Add
+    // this to the origin's float translation in double,
+    let origin_cell_relative_to_child = this_grid.local_floating_origin.cell() - child_cell;
+    let origin_translation = this_grid.grid_position_double(
+        &origin_cell_relative_to_child,
+        &Transform::from_translation(this_grid.local_floating_origin.translation()),
+    );
+
+    // then combine with rotation to get a double transform from the child's cell origin to the
+    // origin.
+    let origin_rotation = this_grid.local_floating_origin.rotation();
+    let origin_transform_child_cell_local =
+        DAffine3::from_rotation_translation(origin_rotation, origin_translation);
+
+    // Take the inverse of the child's transform as double (this is the "view" transform of the
+    // child grid)
+    let child_view_child_cell_local = DAffine3::from_rotation_translation(
+        child_transform.rotation.as_dquat(),
+        child_transform.translation.as_dvec3(),
+    )
+    .inverse();
+
+    // then multiply this by the double transform we got of the origin. This is now a transform64 of
+    // the origin, wrt to the child.
+    let origin_child_affine = child_view_child_cell_local * origin_transform_child_cell_local;
+
+    //  We can decompose into translation (high precision) and rotation.
+    let (_, origin_child_rotation, origin_child_translation) =
+        origin_child_affine.to_scale_rotation_translation();
+    let (child_origin_cell, child_origin_translation_float) =
+        child_grid.translation_to_grid(origin_child_translation);
+
+    grids.update(child_grid_entity, |child_grid, _, _| {
+        child_grid.local_floating_origin.set(
+            child_origin_cell,
+            child_origin_translation_float,
+            origin_child_rotation,
+        );
+    })
+}
+
+/// A system param for more easily navigating a hierarchy of [`Grid`]s.
+#[derive(SystemParam)]
+pub struct Grids<'w, 's, P: GridPrecision> {
+    parent: Query<'w, 's, Read<Parent>>,
+    grid_query: Query<'w, 's, (Entity, Read<Grid<P>>, Option<Read<Parent>>)>,
+}
+
+impl<P: GridPrecision> Grids<'_, '_, P> {
+    /// Get a [`Grid`] from its `Entity`.
+    pub fn get(&self, grid_entity: Entity) -> &Grid<P> {
+        self.grid_query
+            .get(grid_entity)
+            .map(|(_entity, grid, _parent)| grid)
+            .unwrap_or_else(|e| {
+                panic!("Grid entity {grid_entity:?} missing Grid component.\n\tError: {e}");
+            })
+    }
+
+    /// Get the [`Grid`] that `this` `Entity` is a child of, if it exists.
+    pub fn parent_grid(&self, this: Entity) -> Option<&Grid<P>> {
+        self.parent_grid_entity(this)
+            .map(|grid_entity| self.get(grid_entity))
+    }
+
+    /// Get the ID of the grid that `this` `Entity` is a child of, if it exists.
+    #[inline]
+    pub fn parent_grid_entity(&self, this: Entity) -> Option<Entity> {
+        match self.parent.get(this).map(|parent| **parent) {
+            Err(_) => None,
+            Ok(parent) => match self.grid_query.contains(parent) {
+                true => Some(parent),
+                false => None,
+            },
+        }
+    }
+
+    /// Get all grid entities that are children of this grid. Applies a filter to the returned
+    /// children.
+    fn child_grids_filtered<'a>(
+        &'a mut self,
+        this: Entity,
+        mut filter: impl FnMut(Entity) -> bool + 'a,
+    ) -> impl Iterator<Item = Entity> + 'a {
+        // This is intentionally formulated to query grids, and filter those, as opposed to
+        // iterating through the children of the current grid. The latter is extremely inefficient
+        // with wide hierarchies (many entities in a grid, which is a common case), and it is
+        // generally better to be querying fewer entities by using a more restrictive query - e.g.
+        // only querying grids.
+        self.grid_query
+            .iter()
+            .filter_map(move |(entity, _, parent)| {
+                parent
+                    .map(|p| p.get())
+                    .filter(|parent| *parent == this)
+                    .map(|_| entity)
+            })
+            .filter(move |entity| filter(*entity))
+    }
+
+    /// Get all grid entities that are children of this grid.
+    pub fn child_grids(&mut self, this: Entity) -> impl Iterator<Item = Entity> + '_ {
+        self.child_grids_filtered(this, |_| true)
+    }
+
+    /// Get all grid entities that are siblings of this grid. Returns `None` if there are no
+    /// siblings.
+    pub fn sibling_grids(
+        &mut self,
+        this_entity: Entity,
+    ) -> Option<impl Iterator<Item = Entity> + '_> {
+        self.parent_grid_entity(this_entity)
+            .map(|parent| self.child_grids_filtered(parent, move |e| e != this_entity))
+    }
+}
+
+/// A system param for more easily navigating a hierarchy of grids mutably.
+#[derive(SystemParam)]
+pub struct GridsMut<'w, 's, P: GridPrecision> {
+    parent: Query<'w, 's, Read<Parent>>,
+    position: Query<'w, 's, (Read<GridCell<P>>, Read<Transform>), With<Grid<P>>>,
+    grid_query: Query<'w, 's, (Entity, Write<Grid<P>>, Option<Read<Parent>>)>,
+}
+
+impl<P: GridPrecision> GridsMut<'_, '_, P> {
+    /// Get mutable access to the [`Grid`], and run the provided function or closure, optionally
+    /// returning data.
+    ///
+    /// ## Panics
+    ///
+    /// This will panic if the entity passed in is invalid.
+    pub fn update<T>(
+        &mut self,
+        grid_entity: Entity,
+        mut func: impl FnMut(&mut Grid<P>, &GridCell<P>, &Transform) -> T,
+    ) -> T {
+        let (cell, transform) = self.position(grid_entity);
+        self.grid_query
+            .get_mut(grid_entity)
+            .map(|(_entity, mut grid, _parent)| func(grid.as_mut(), &cell, &transform))
+            .expect("The supplied grid entity is no longer valid.")
+    }
+
+    /// Get the grid and the position of the grid from its `Entity`.
+    pub fn get(&self, grid_entity: Entity) -> (&Grid<P>, GridCell<P>, Transform) {
+        let (cell, transform) = self.position(grid_entity);
+        self.grid_query
+            .get(grid_entity)
+            .map(|(_entity, grid, _parent)| (grid, cell, transform))
+            .unwrap_or_else(|e| {
+                panic!("Grid entity {grid_entity:?} missing Grid component.\n\tError: {e}");
+            })
+    }
+
+    /// Get the position of this grid, including its grid cell and transform, or return defaults if
+    /// they are missing.
+    ///
+    /// Needed because the root grid should not have a grid cell or transform.
+    pub fn position(&self, grid_entity: Entity) -> (GridCell<P>, Transform) {
+        let (cell, transform) = (GridCell::default(), Transform::default());
+        let (cell, transform) = self.position.get(grid_entity).unwrap_or_else(|_| {
+        assert!(self.parent.get(grid_entity).is_err(), "Grid entity {grid_entity:?} is missing a GridCell and Transform. This is valid only if this is a root grid, but this is not.");
+            (&cell, &transform)
+        });
+        (*cell, *transform)
+    }
+
+    /// Get the [`Grid`] that `this` `Entity` is a child of, if it exists.
+    pub fn parent_grid(&self, this: Entity) -> Option<(&Grid<P>, GridCell<P>, Transform)> {
+        self.parent_grid_entity(this)
+            .map(|grid_entity| self.get(grid_entity))
+    }
+
+    /// Get the ID of the grid that `this` `Entity` is a child of, if it exists.
+    #[inline]
+    pub fn parent_grid_entity(&self, this: Entity) -> Option<Entity> {
+        match self.parent.get(this).map(|parent| **parent) {
+            Err(_) => None,
+            Ok(parent) => match self.grid_query.contains(parent) {
+                true => Some(parent),
+                false => None,
+            },
+        }
+    }
+
+    /// Get all grid entities that are children of this grid. Applies a filter to the returned
+    /// children.
+    fn child_grids_filtered<'a>(
+        &'a mut self,
+        this: Entity,
+        mut filter: impl FnMut(Entity) -> bool + 'a,
+    ) -> impl Iterator<Item = Entity> + 'a {
+        // This is intentionally formulated to query grids, and filter those, as opposed to
+        // iterating through the children of the current grid. The latter is extremely inefficient
+        // with wide hierarchies (many entities in a grid, which is a common case), and it is
+        // generally better to be querying fewer entities by using a more restrictive query - e.g.
+        // only querying grids.
+        self.grid_query
+            .iter()
+            .filter_map(move |(entity, _, parent)| {
+                parent
+                    .map(|p| p.get())
+                    .filter(|parent| *parent == this)
+                    .map(|_| entity)
+            })
+            .filter(move |entity| filter(*entity))
+    }
+
+    /// Get all grid entities that are children of this grid.
+    pub fn child_grids(&mut self, this: Entity) -> impl Iterator<Item = Entity> + '_ {
+        self.child_grids_filtered(this, |_| true)
+    }
+
+    /// Get all grid entities that are siblings of this grid.
+    pub fn sibling_grids(
+        &mut self,
+        this_entity: Entity,
+    ) -> Option<impl Iterator<Item = Entity> + '_> {
+        self.parent_grid_entity(this_entity)
+            .map(|parent| self.child_grids_filtered(parent, move |e| e != this_entity))
+    }
+}
+
+impl<P: GridPrecision> LocalFloatingOrigin<P> {
+    /// Update the [`LocalFloatingOrigin`] of every [`Grid`] in the world. This does not update any
+    /// entity transforms, instead this is a preceding step that updates every reference grid, so it
+    /// knows where the floating origin is located with respect to that reference grid. This is all
+    /// done in high precision if possible, however any loss in precision will only affect the
+    /// rendering precision. The high precision coordinates ([`GridCell`] and [`Transform`]) are the
+    /// source of truth and never mutated.
+    pub fn compute_all(
+        mut stats: ResMut<crate::timing::PropagationStats>,
+        mut grids: GridsMut<P>,
+        mut grid_stack: Local<Vec<Entity>>,
+        mut scratch_buffer: Local<Vec<Entity>>,
+        cells: Query<(Entity, Ref<GridCell<P>>)>,
+        roots: Query<(Entity, &BigSpace)>,
+        parents: Query<&Parent>,
+    ) {
+        let start = bevy_utils::Instant::now();
+
+        /// The maximum grid tree depth, defensively prevents infinite looping in case there is a
+        /// degenerate hierarchy. It might take a while, but at least it's not forever?
+        const MAX_REFERENCE_FRAME_DEPTH: usize = 1_000;
+
+        // TODO: because each tree under a root is disjoint, these updates can be done in parallel
+        // without aliasing. This will require unsafe, just like bevy's own transform propagation.
+        'outer: for (origin_entity, origin_cell) in roots
+            .iter() // TODO: If any of these checks fail, log to some diagnostic
+            .filter_map(|(root_entity, root)| root.validate_floating_origin(root_entity, &parents))
+            .filter_map(|origin| cells.get(origin).ok())
+        {
+            let Some(mut this_grid) = grids.parent_grid_entity(origin_entity) else {
+                tracing::error!("The floating origin is not in a valid grid. The floating origin entity must be a child of an entity with the `Grid` component.");
+                continue;
+            };
+
+            // Prepare by resetting the `origin_transform` of the floating origin's grid. Because
+            // the floating origin is within this grid, there is no grid misalignment and thus no
+            // need for any floating offsets.
+            grids.update(this_grid, |grid, _, _| {
+                grid.local_floating_origin
+                    .set(*origin_cell, Vec3::ZERO, DQuat::IDENTITY);
+            });
+
+            // Seed the grid stack with the floating origin's grid. From this point out, we will
+            // only look at siblings and parents, which will allow us to visit the entire tree.
+            grid_stack.clear();
+            grid_stack.push(this_grid);
+
+            // Recurse up and across the tree, updating siblings and their children.
+            for _ in 0..MAX_REFERENCE_FRAME_DEPTH {
+                // We start by propagating up to the parent of this grid, then propagating down to
+                // the siblings of this grid (children of the parent that are not this grid).
+                if let Some(parent_grid) = grids.parent_grid_entity(this_grid) {
+                    propagate_origin_to_parent(this_grid, &mut grids, parent_grid);
+                    if let Some(siblings) = grids.sibling_grids(this_grid) {
+                        scratch_buffer.extend(siblings);
+                    }
+                    for sibling_grid in scratch_buffer.drain(..) {
+                        // The siblings of this grid are also the children of the parent grid.
+                        propagate_origin_to_child(parent_grid, &mut grids, sibling_grid);
+                        grid_stack.push(sibling_grid); // We'll recurse through children next
+                    }
+                }
+
+                // All of the grids pushed on the stack have been processed. We can now pop those
+                // off the stack and recursively process their children all the way out to the
+                // leaves of the tree.
+                while let Some(this_grid) = grid_stack.pop() {
+                    scratch_buffer.extend(grids.child_grids(this_grid));
+                    // TODO: This loop could be run in parallel, because we are mutating each unique
+                    // child, these do no alias.
+                    for child_grid in scratch_buffer.drain(..) {
+                        propagate_origin_to_child(this_grid, &mut grids, child_grid);
+                        grid_stack.push(child_grid) // Push processed child onto the stack
+                    }
+                }
+
+                // Finally, now that this grid and its siblings have been recursively processed, we
+                // process the parent and set it as the current grid. Note that every time we step
+                // to a parent, "this grid" and all descendants have already been processed, so we
+                // only need to process the siblings.
+                match grids.parent_grid_entity(this_grid) {
+                    Some(parent_grid) => this_grid = parent_grid,
+                    None => continue 'outer, // We have reached the root of the tree, and can exit.
+                }
+            }
+
+            tracing::error!("Reached the maximum grid depth ({MAX_REFERENCE_FRAME_DEPTH}), and exited early to prevent an infinite loop. This might be caused by a degenerate hierarchy.")
+        }
+
+        stats.local_origin_propagation += start.elapsed();
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use bevy::{ecs::system::SystemState, math::DVec3, prelude::*};
+
+    use super::*;
+
+    /// Test that the grid getters do what they say they do.
+    #[test]
+    fn grid_hierarchy_getters() {
+        let mut app = App::new();
+        app.add_plugins(BigSpacePlugin::<i32>::default());
+
+        let grid_bundle = (
+            Transform::default(),
+            GridCell::<i32>::default(),
+            Grid::<i32>::default(),
+        );
+
+        let child_1 = app.world_mut().spawn(grid_bundle.clone()).id();
+        let child_2 = app.world_mut().spawn(grid_bundle.clone()).id();
+        let parent = app.world_mut().spawn(grid_bundle.clone()).id();
+        let root = app.world_mut().spawn(grid_bundle.clone()).id();
+
+        app.world_mut().entity_mut(root).add_child(parent);
+        app.world_mut()
+            .entity_mut(parent)
+            .add_children(&[child_1, child_2]);
+
+        let mut state = SystemState::<GridsMut<i32>>::new(app.world_mut());
+        let mut grids = state.get_mut(app.world_mut());
+
+        // Children
+        let result = grids.child_grids(root).collect::<Vec<_>>();
+        assert_eq!(result, vec![parent]);
+        let result = grids.child_grids(parent).collect::<Vec<_>>();
+        assert!(result.contains(&child_1));
+        assert!(result.contains(&child_2));
+        let result = grids.child_grids(child_1).collect::<Vec<_>>();
+        assert_eq!(result, Vec::new());
+
+        // Parent
+        let result = grids.parent_grid_entity(root);
+        assert_eq!(result, None);
+        let result = grids.parent_grid_entity(parent);
+        assert_eq!(result, Some(root));
+        let result = grids.parent_grid_entity(child_1);
+        assert_eq!(result, Some(parent));
+
+        // Siblings
+        assert!(grids.sibling_grids(root).is_none());
+        let result = grids.sibling_grids(parent).unwrap().collect::<Vec<_>>();
+        assert_eq!(result, vec![]);
+        let result = grids.sibling_grids(child_1).unwrap().collect::<Vec<_>>();
+        assert_eq!(result, vec![child_2]);
+    }
+
+    #[test]
+    fn child_propagation() {
+        let mut app = App::new();
+        app.add_plugins(BigSpacePlugin::<i32>::default());
+
+        let root_grid = Grid {
+            local_floating_origin: LocalFloatingOrigin::new(
+                GridCell::<i32>::new(1_000_000, -1, -1),
+                Vec3::ZERO,
+                DQuat::from_rotation_z(-std::f64::consts::FRAC_PI_2),
+            ),
+            ..default()
+        };
+        let root = app
+            .world_mut()
+            .spawn((Transform::default(), GridCell::<i32>::default(), root_grid))
+            .id();
+
+        let child = app
+            .world_mut()
+            .spawn((
+                Transform::from_rotation(Quat::from_rotation_z(std::f32::consts::FRAC_PI_2))
+                    .with_translation(Vec3::new(1.0, 1.0, 0.0)),
+                GridCell::<i32>::new(1_000_000, 0, 0),
+                Grid::<i32>::default(),
+            ))
+            .id();
+
+        app.world_mut().entity_mut(root).add_child(child);
+
+        let mut state = SystemState::<GridsMut<i32>>::new(app.world_mut());
+        let mut grids = state.get_mut(app.world_mut());
+
+        // The function we are testing
+        propagate_origin_to_child(root, &mut grids, child);
+
+        let (child_grid, ..) = grids.get(child);
+
+        let computed_grid = child_grid.local_floating_origin.cell();
+        let correct_grid = GridCell::new(-1, 0, -1);
+        assert_eq!(computed_grid, correct_grid);
+
+        let computed_rot = child_grid.local_floating_origin.rotation();
+        let correct_rot = DQuat::from_rotation_z(std::f64::consts::PI);
+        let rot_error = computed_rot.angle_between(correct_rot);
+        assert!(rot_error < 1e-10);
+
+        // Even though we are 2 billion units from the origin, our precision is still pretty good.
+        // The loss of precision is coming from the affine multiplication that moves the origin into
+        // the child's grid. The good news is that precision loss only scales with the distance of
+        // the origin to the child (in the child's grid). In this test we are saying that the
+        // floating origin is - with respect to the root - pretty near the child. Even though the
+        // child and floating origin are very far from the origin, we only lose precision based on
+        // how for the origin is from the child.
+        let computed_trans = child_grid.local_floating_origin.translation();
+        let correct_trans = Vec3::new(-1.0, 1.0, 0.0);
+        let trans_error = computed_trans.distance(correct_trans);
+        assert!(trans_error < 1e-4);
+    }
+
+    #[test]
+    fn parent_propagation() {
+        let mut app = App::new();
+        app.add_plugins(BigSpacePlugin::<i64>::default());
+
+        let grid_bundle = (
+            Transform::default(),
+            GridCell::<i64>::default(),
+            Grid::<i64>::default(),
+        );
+        let root = app.world_mut().spawn(grid_bundle.clone()).id();
+
+        let child = app
+            .world_mut()
+            .spawn((
+                Transform::from_rotation(Quat::from_rotation_z(std::f32::consts::FRAC_PI_2))
+                    .with_translation(Vec3::new(1.0, 1.0, 0.0)),
+                GridCell::<i64>::new(150_000_003_000, 0, 0), // roughly radius of earth orbit
+                Grid {
+                    local_floating_origin: LocalFloatingOrigin::new(
+                        GridCell::<i64>::new(0, 3_000, 0),
+                        Vec3::new(5.0, 5.0, 0.0),
+                        DQuat::from_rotation_z(-std::f64::consts::FRAC_PI_2),
+                    ),
+                    ..Default::default()
+                },
+            ))
+            .id();
+
+        app.world_mut().entity_mut(root).add_child(child);
+
+        let mut state = SystemState::<GridsMut<i64>>::new(app.world_mut());
+        let mut grids = state.get_mut(app.world_mut());
+
+        // The function we are testing
+        propagate_origin_to_parent(child, &mut grids, root);
+
+        let (root_grid, ..) = grids.get(root);
+
+        let computed_grid = root_grid.local_floating_origin.cell();
+        let correct_grid = GridCell::new(150_000_000_000, 0, 0);
+        assert_eq!(computed_grid, correct_grid);
+
+        let computed_rot = root_grid.local_floating_origin.rotation();
+        let correct_rot = DQuat::IDENTITY;
+        let rot_error = computed_rot.angle_between(correct_rot);
+        assert!(rot_error < 1e-7);
+
+        // This is the error of the position of the floating origin if the origin was a person
+        // standing on earth, and their position was resampled with respect to the sun. This is 0.3
+        // meters, but recall that this will be the error when positioning the other planets in the
+        // solar system when rendering.
+        //
+        // This error scales with the distance of the floating origin from the origin of its grid,
+        // in this case the radius of the earth, not the radius of the orbit.
+        let computed_trans = root_grid.local_floating_origin.translation();
+        let correct_trans = Vec3::new(-4.0, 6.0, 0.0);
+        let trans_error = computed_trans.distance(correct_trans);
+        assert!(trans_error < 0.3);
+    }
+
+    #[test]
+    fn origin_transform() {
+        let mut app = App::new();
+        app.add_plugins(BigSpacePlugin::<i32>::default());
+
+        let root = app
+            .world_mut()
+            .spawn((
+                Transform::default(),
+                GridCell::<i32>::default(),
+                Grid {
+                    local_floating_origin: LocalFloatingOrigin::new(
+                        GridCell::<i32>::new(0, 0, 0),
+                        Vec3::new(1.0, 1.0, 0.0),
+                        DQuat::from_rotation_z(0.0),
+                    ),
+                    ..default()
+                },
+            ))
+            .id();
+
+        let child = app
+            .world_mut()
+            .spawn((
+                Transform::default()
+                    .with_rotation(Quat::from_rotation_z(-std::f32::consts::FRAC_PI_2))
+                    .with_translation(Vec3::new(3.0, 3.0, 0.0)),
+                GridCell::<i32>::new(0, 0, 0),
+                Grid::<i32>::default(),
+            ))
+            .id();
+
+        app.world_mut().entity_mut(root).add_child(child);
+
+        let mut state = SystemState::<GridsMut<i32>>::new(app.world_mut());
+        let mut grids = state.get_mut(app.world_mut());
+
+        propagate_origin_to_child(root, &mut grids, child);
+
+        let (child_grid, ..) = grids.get(child);
+        let child_local_point = DVec3::new(5.0, 5.0, 0.0);
+
+        let computed_transform = child_grid.local_floating_origin.grid_transform();
+        let computed_pos = computed_transform.transform_point3(child_local_point);
+
+        let correct_transform = DAffine3::from_rotation_translation(
+            DQuat::from_rotation_z(-std::f64::consts::FRAC_PI_2),
+            DVec3::new(2.0, 2.0, 0.0),
+        );
+        let correct_pos = correct_transform.transform_point3(child_local_point);
+
+        assert!((computed_pos - correct_pos).length() < 1e-6);
+        assert!((computed_pos - DVec3::new(7.0, -3.0, 0.0)).length() < 1e-6);
+    }
+}
diff --git a/src/reference_frame/mod.rs b/src/grid/mod.rs
similarity index 61%
rename from src/reference_frame/mod.rs
rename to src/grid/mod.rs
index a6154bd..b69479d 100644
--- a/src/reference_frame/mod.rs
+++ b/src/grid/mod.rs
@@ -1,45 +1,37 @@
-//! Adds the concept of hierarchical, nesting [`ReferenceFrame`]s, to group entities that move
-//! through space together, like entities on a planet, rotating about the planet's axis, and,
-//! orbiting a star.
+//! Adds the concept of hierarchical, nesting [`Grid`]s, to group entities that move through space
+//! together, like entities on a planet, rotating about the planet's axis, and, orbiting a star.
 
+use crate::prelude::*;
 use bevy_ecs::prelude::*;
 use bevy_math::{prelude::*, Affine3A, DAffine3, DVec3};
 use bevy_reflect::prelude::*;
 use bevy_transform::prelude::*;
 
-use crate::{precision::GridPrecision, GridCell};
-
-use self::local_origin::LocalFloatingOrigin;
+use local_origin::LocalFloatingOrigin;
 
+pub mod cell;
 pub mod local_origin;
 pub mod propagation;
 
-/// A component that defines a reference frame for children of this entity with [`GridCell`]s. All
-/// entities with a [`GridCell`] must be children of an entity with a [`ReferenceFrame`]. The
-/// reference frame *defines* the grid that the `GridCell` indexes into.
-///
-/// ## Motivation
+/// A component that defines a spatial grid that child entities are located on. Child entities are
+/// located on this grid with the [`GridCell`] component.
 ///
-/// Reference frames are hierarchical, allowing more precision for objects with similar relative
-/// velocities. All entities in the same reference frame will move together, like standard transform
-/// propagation, but with much more precision. Entities in the same reference frame as the
-/// [`crate::FloatingOrigin`] will be rendered with the most precision. Transforms are propagated
-/// starting from the floating origin, ensuring that references frames in a similar point in the
-/// hierarchy have accumulated the least error. Reference frames are transformed relative to each
-/// other using 64 bit float transforms.
+/// All entities with a [`GridCell`] must be children of an entity with a [`Grid`].
 ///
-/// ## Example
+/// Grids are hierarchical, allowing more precision for objects with similar relative velocities.
+/// All entities in the same grid will move together, like standard transform propagation, but with
+/// much more precision.
 ///
-/// You can use reference frames to ensure all entities on a planet, and the planet itself, are in
-/// the same rotating reference frame, instead of moving rapidly through space around a star, or
-/// worse, around the center of the galaxy.
+/// Entities in the same grid as the [`FloatingOrigin`] will be rendered with the most precision.
+/// Transforms are propagated starting from the floating origin, ensuring that grids in a similar
+/// point in the hierarchy have accumulated the least error. Grids are transformed relative to each
+/// other using 64 bit float transforms.
 #[derive(Debug, Clone, Reflect, Component)]
 #[reflect(Component)]
 // We do not require the Transform, GlobalTransform, or GridCell, because these are not required in
 // all cases: e.g. BigSpace should not have a Transform or GridCell.
-pub struct ReferenceFrame<P: GridPrecision + Reflect> {
-    /// The high-precision position of the floating origin's current grid cell local to this
-    /// reference frame.
+pub struct Grid<P: GridPrecision> {
+    /// The high-precision position of the floating origin's current grid cell local to this grid.
     local_floating_origin: LocalFloatingOrigin<P>,
     /// Defines the uniform scale of the grid by the length of the edge of a grid cell.
     cell_edge_length: f32,
@@ -47,15 +39,14 @@ pub struct ReferenceFrame<P: GridPrecision + Reflect> {
     maximum_distance_from_origin: f32,
 }
 
-impl<P: GridPrecision> Default for ReferenceFrame<P> {
+impl<P: GridPrecision> Default for Grid<P> {
     fn default() -> Self {
         Self::new(2_000f32, 100f32)
     }
 }
 
-impl<P: GridPrecision> ReferenceFrame<P> {
-    /// Construct a new [`ReferenceFrame`]. The properties of a reference frame cannot be changed
-    /// after construction.
+impl<P: GridPrecision> Grid<P> {
+    /// Construct a new [`Grid`]. The properties of a grid cannot be changed after construction.
     pub fn new(cell_edge_length: f32, switching_threshold: f32) -> Self {
         Self {
             local_floating_origin: LocalFloatingOrigin::default(),
@@ -64,23 +55,27 @@ impl<P: GridPrecision> ReferenceFrame<P> {
         }
     }
 
-    /// Get the position of the floating origin relative to the current reference frame.
+    /// Get the position of the floating origin relative to the current grid.
+    #[inline]
     pub fn local_floating_origin(&self) -> &LocalFloatingOrigin<P> {
         &self.local_floating_origin
     }
 
-    /// Get the size of each cell this reference frame's grid.
+    /// Get the size of each cell this grid's grid.
+    #[inline]
     pub fn cell_edge_length(&self) -> f32 {
         self.cell_edge_length
     }
 
-    /// Get the reference frame's [`Self::maximum_distance_from_origin`].
+    /// Get the grid's [`Self::maximum_distance_from_origin`].
+    #[inline]
     pub fn maximum_distance_from_origin(&self) -> f32 {
         self.maximum_distance_from_origin
     }
 
     /// Compute the double precision position of an entity's [`Transform`] with respect to the given
-    /// [`GridCell`] within this reference frame.
+    /// [`GridCell`] within this grid.
+    #[inline]
     pub fn grid_position_double(&self, pos: &GridCell<P>, transform: &Transform) -> DVec3 {
         DVec3 {
             x: pos.x.as_f64() * self.cell_edge_length as f64 + transform.translation.x as f64,
@@ -91,6 +86,7 @@ impl<P: GridPrecision> ReferenceFrame<P> {
 
     /// Compute the single precision position of an entity's [`Transform`] with respect to the given
     /// [`GridCell`].
+    #[inline]
     pub fn grid_position(&self, pos: &GridCell<P>, transform: &Transform) -> Vec3 {
         Vec3 {
             x: pos.x.as_f64() as f32 * self.cell_edge_length + transform.translation.x,
@@ -100,15 +96,16 @@ impl<P: GridPrecision> ReferenceFrame<P> {
     }
 
     /// Returns the floating point position of a [`GridCell`].
-    pub fn grid_to_float(&self, pos: &GridCell<P>) -> DVec3 {
+    pub fn cell_to_float(&self, pos: &GridCell<P>) -> DVec3 {
         DVec3 {
-            x: pos.x.as_f64() * self.cell_edge_length as f64,
-            y: pos.y.as_f64() * self.cell_edge_length as f64,
-            z: pos.z.as_f64() * self.cell_edge_length as f64,
-        }
+            x: pos.x.as_f64(),
+            y: pos.y.as_f64(),
+            z: pos.z.as_f64(),
+        } * self.cell_edge_length as f64
     }
 
     /// Convert a large translation into a small translation relative to a grid cell.
+    #[inline]
     pub fn translation_to_grid(&self, input: impl Into<DVec3>) -> (GridCell<P>, Vec3) {
         let l = self.cell_edge_length as f64;
         let input = input.into();
@@ -127,32 +124,33 @@ impl<P: GridPrecision> ReferenceFrame<P> {
 
         (
             GridCell {
-                x: P::from_f32(x_r as f32),
-                y: P::from_f32(y_r as f32),
-                z: P::from_f32(z_r as f32),
+                x: P::from_f64(x_r),
+                y: P::from_f64(y_r),
+                z: P::from_f64(z_r),
             },
             Vec3::new(t_x as f32, t_y as f32, t_z as f32),
         )
     }
 
     /// Convert a large translation into a small translation relative to a grid cell.
+    #[inline]
     pub fn imprecise_translation_to_grid(&self, input: Vec3) -> (GridCell<P>, Vec3) {
         self.translation_to_grid(input.as_dvec3())
     }
 
-    /// Compute the [`GlobalTransform`] of an entity in this reference frame.
+    /// Compute the [`GlobalTransform`] of an entity in this grid.
+    #[inline]
     pub fn global_transform(
         &self,
         local_cell: &GridCell<P>,
         local_transform: &Transform,
     ) -> GlobalTransform {
-        // The reference frame transform from the floating origin's reference frame, to the local
-        // reference frame.
-        let transform_origin = self.local_floating_origin().reference_frame_transform();
+        // The grid transform from the floating origin's grid, to the local grid.
+        let transform_origin = self.local_floating_origin().grid_transform();
         // The grid cell offset of this entity relative to the floating origin's cell in this local
-        // reference frame.
+        // grid.
         let cell_origin_relative = *local_cell - self.local_floating_origin().cell();
-        let grid_offset = self.grid_to_float(&cell_origin_relative);
+        let grid_offset = self.cell_to_float(&cell_origin_relative);
         let local_transform = DAffine3::from_scale_rotation_translation(
             local_transform.scale.as_dvec3(),
             local_transform.rotation.as_dquat(),
diff --git a/src/grid/propagation.rs b/src/grid/propagation.rs
new file mode 100644
index 0000000..88b7d4f
--- /dev/null
+++ b/src/grid/propagation.rs
@@ -0,0 +1,346 @@
+//! Logic for propagating transforms through the hierarchy of grids.
+
+use crate::prelude::*;
+use bevy_ecs::prelude::*;
+use bevy_hierarchy::prelude::*;
+use bevy_reflect::Reflect;
+use bevy_transform::prelude::*;
+
+/// Marks entities in the big space hierarchy that are themselves roots of a low-precision subtree.
+/// While finding these entities is slow, we only have to do it during hierarchy or archetype
+/// changes. Once the entity is marked (updating its archetype), querying it is now very fast.
+///
+/// - This entity's parent must be a high precision entity (with a [`GridCell`]).
+/// - This entity must not have a [`GridCell`].
+/// - This entity may or may not have children.
+#[derive(Component, Default, Reflect)]
+pub struct LowPrecisionRoot;
+
+impl<P: GridPrecision> Grid<P> {
+    /// Update the `GlobalTransform` of entities with a [`GridCell`], using the [`Grid`] the entity
+    /// belongs to.
+    pub fn propagate_high_precision(
+        mut stats: ResMut<crate::timing::PropagationStats>,
+        grids: Query<&Grid<P>>,
+        mut entities: ParamSet<(
+            Query<(
+                Ref<GridCell<P>>,
+                Ref<Transform>,
+                Ref<Parent>,
+                &mut GlobalTransform,
+            )>,
+            Query<(&Grid<P>, &mut GlobalTransform), With<BigSpace>>,
+        )>,
+    ) {
+        let start = bevy_utils::Instant::now();
+
+        // Performance note: I've also tried to iterate over each grid's children at once, to avoid
+        // the grid and parent lookup, but that made things worse because it prevented dumb
+        // parallelism. The only thing I can see to make this faster is archetype change detection.
+        // Change filters are not archetype filters, so they scale with the total number of entities
+        // that match the query, regardless of change.
+        entities
+            .p0()
+            .par_iter_mut()
+            .for_each(|(cell, transform, parent, mut global_transform)| {
+                if let Ok(grid) = grids.get(parent.get()) {
+                    // Optimization: we don't need to recompute the transforms if the entity hasn't
+                    // moved and the floating origin's local origin in that grid hasn't changed.
+                    //
+                    // This also ensures we don't trigger change detection on GlobalTransforms when
+                    // they haven't changed.
+                    //
+                    // This check can have a big impact on reducing computations for entities in the
+                    // same grid as the floating origin, i.e. the main camera. It also means that as
+                    // the floating origin moves between cells, that could suddenly cause a spike in
+                    // the amount of computation needed that grid. In the future, we might be able
+                    // to spread that work across grids, entities far away can maybe be delayed for
+                    // a grid or two without being noticeable.
+                    if !grid.local_floating_origin().is_local_origin_unchanged()
+                        || transform.is_changed()
+                        || cell.is_changed()
+                        || parent.is_changed()
+                    {
+                        *global_transform = grid.global_transform(&cell, &transform);
+                    }
+                }
+            });
+
+        // Root grids
+        //
+        // These are handled separately because the root grid doesn't have a Transform or GridCell -
+        // it wouldn't make sense because it is the root, and these components are relative to their
+        // parent. Due to floating origins, it *is* possible for the root grid to have a
+        // GlobalTransform - this is what makes it possible to place a low precision (Transform
+        // only) entity in a root transform - it is relative to the origin of the root grid.
+        entities
+            .p1()
+            .iter_mut()
+            .for_each(|(grid, mut global_transform)| {
+                if grid.local_floating_origin().is_local_origin_unchanged() {
+                    return; // By definition, this means the grid has not moved
+                }
+                // The global transform of the root grid is the same as the transform of an entity
+                // at the origin - it is determined entirely by the local origin position:
+                *global_transform =
+                    grid.global_transform(&GridCell::default(), &Transform::IDENTITY);
+            });
+
+        stats.high_precision_propagation += start.elapsed();
+    }
+
+    /// Marks entities with [`LowPrecisionRoot`]. Handles adding and removing the component.
+    pub fn tag_low_precision_roots(
+        mut stats: ResMut<crate::timing::PropagationStats>,
+        mut commands: Commands,
+        valid_parent: Query<(), (With<GridCell<P>>, With<GlobalTransform>, With<Children>)>,
+        unmarked: Query<
+            (Entity, &Parent),
+            (
+                With<Transform>,
+                With<GlobalTransform>,
+                Without<GridCellAny>,
+                Without<LowPrecisionRoot>,
+                Or<(Changed<Parent>, Added<Transform>)>,
+            ),
+        >,
+        invalidated: Query<
+            Entity,
+            (
+                With<LowPrecisionRoot>,
+                Or<(
+                    Without<Transform>,
+                    Without<GlobalTransform>,
+                    With<GridCell<P>>,
+                    Without<Parent>,
+                )>,
+            ),
+        >,
+        has_possibly_invalid_parent: Query<(Entity, &Parent), With<LowPrecisionRoot>>,
+    ) {
+        let start = bevy_utils::Instant::now();
+        for (entity, parent) in unmarked.iter() {
+            if valid_parent.contains(parent.get()) {
+                commands.entity(entity).insert(LowPrecisionRoot);
+            }
+        }
+
+        for entity in invalidated.iter() {
+            commands.entity(entity).remove::<LowPrecisionRoot>();
+        }
+
+        for (entity, parent) in has_possibly_invalid_parent.iter() {
+            if !valid_parent.contains(parent.get()) {
+                commands.entity(entity).remove::<LowPrecisionRoot>();
+            }
+        }
+        stats.low_precision_root_tagging += start.elapsed();
+    }
+
+    /// Update the [`GlobalTransform`] of entities with a [`Transform`], without a [`GridCell`], and
+    /// that are children of an entity with a [`GlobalTransform`]. This will recursively propagate
+    /// entities that only have low-precision [`Transform`]s, just like bevy's built in systems.
+    pub fn propagate_low_precision(
+        mut stats: ResMut<crate::timing::PropagationStats>,
+        root_parents: Query<
+            Ref<GlobalTransform>,
+            (
+                // A root big space does not have a grid cell, and not all high precision entities
+                // have a grid
+                Or<(With<Grid<P>>, With<GridCell<P>>)>,
+            ),
+        >,
+        roots: Query<(Entity, &Parent), With<LowPrecisionRoot>>,
+        transform_query: Query<
+            (Ref<Transform>, &mut GlobalTransform, Option<&Children>),
+            (
+                With<Parent>,
+                Without<GridCellAny>,
+                Without<GridCell<P>>, // Used to prove access to GlobalTransform is disjoint
+                Without<Grid<P>>,
+            ),
+        >,
+        parent_query: Query<
+            (Entity, Ref<Parent>),
+            (
+                With<Transform>,
+                With<GlobalTransform>,
+                Without<GridCellAny>,
+                Without<Grid<P>>,
+            ),
+        >,
+    ) {
+        let start = bevy_utils::Instant::now();
+        let update_transforms = |low_precision_root, parent_transform: Ref<GlobalTransform>| {
+            // High precision global transforms are change-detected, and are only updated if that
+            // entity has moved relative to the floating origin's grid cell.
+            let changed = parent_transform.is_changed();
+
+            // SAFETY:
+            // - Unlike the bevy version of this, we do not iterate over all children of the root,
+            //   and manually verify each child has a parent component that points back to the same
+            //   entity. Instead, we query the roots directly, so we know they are unique.
+            // - We may operate as if all descendants are consistent, since `propagate_recursive`
+            //   will panic before continuing to propagate if it encounters an entity with
+            //   inconsistent parentage.
+            // - Since each root entity is unique and the hierarchy is consistent and forest-like,
+            //   other root entities' `propagate_recursive` calls will not conflict with this one.
+            // - Since this is the only place where `transform_query` gets used, there will be no
+            //   conflicting fetches elsewhere.
+            unsafe {
+                Self::propagate_recursive(
+                    &parent_transform,
+                    &transform_query,
+                    &parent_query,
+                    low_precision_root,
+                    changed,
+                );
+            }
+        };
+
+        roots.par_iter().for_each(|(low_precision_root, parent)| {
+            if let Ok(parent_transform) = root_parents.get(parent.get()) {
+                update_transforms(low_precision_root, parent_transform);
+            }
+        });
+
+        stats.low_precision_propagation += start.elapsed();
+    }
+
+    /// COPIED FROM BEVY
+    ///
+    /// Recursively propagates the transforms for `entity` and all of its descendants.
+    ///
+    /// # Panics
+    ///
+    /// If `entity`'s descendants have a malformed hierarchy, this function will panic occur before
+    /// propagating the transforms of any malformed entities and their descendants.
+    ///
+    /// # Safety
+    ///
+    /// - While this function is running, `transform_query` must not have any fetches for `entity`,
+    ///   nor any of its descendants.
+    /// - The caller must ensure that the hierarchy leading to `entity` is well-formed and must
+    ///   remain as a tree or a forest. Each entity must have at most one parent.
+    unsafe fn propagate_recursive(
+        parent: &GlobalTransform,
+        transform_query: &Query<
+            (Ref<Transform>, &mut GlobalTransform, Option<&Children>),
+            (
+                With<Parent>,
+                Without<GridCellAny>, // ***ADDED*** Only recurse low-precision entities
+                Without<GridCell<P>>, // ***ADDED*** Only recurse low-precision entities
+                Without<Grid<P>>,     // ***ADDED*** Only recurse low-precision entities
+            ),
+        >,
+        parent_query: &Query<
+            (Entity, Ref<Parent>),
+            (
+                With<Transform>,
+                With<GlobalTransform>,
+                Without<GridCellAny>,
+                Without<Grid<P>>,
+            ),
+        >,
+        entity: Entity,
+        mut changed: bool,
+    ) {
+        let (global_matrix, children) = {
+            let Ok((transform, mut global_transform, children)) =
+            // SAFETY: This call cannot create aliased mutable references.
+            //   - The top level iteration parallelizes on the roots of the hierarchy.
+            //   - The caller ensures that each child has one and only one unique parent throughout
+            //     the entire hierarchy.
+            //
+            // For example, consider the following malformed hierarchy:
+            //
+            //     A
+            //   /   \
+            //  B     C \   / D
+            //
+            // D has two parents, B and C. If the propagation passes through C, but the Parent
+            // component on D points to B, the above check will panic as the origin parent does
+            // match the recorded parent.
+            //
+            // Also consider the following case, where A and B are roots:
+            //
+            //  A       B \     / C   D \ / E
+            //
+            // Even if these A and B start two separate tasks running in parallel, one of them will
+            // panic before attempting to mutably access E.
+            (unsafe { transform_query.get_unchecked(entity) }) else {
+                return;
+            };
+
+            changed |= transform.is_changed() || global_transform.is_added();
+            if changed {
+                *global_transform = parent.mul_transform(*transform);
+            }
+            (global_transform, children)
+        };
+
+        let Some(children) = children else { return };
+        for (child, actual_parent) in parent_query.iter_many(children) {
+            assert_eq!(
+            actual_parent.get(), entity,
+            "Malformed hierarchy. This probably means that your hierarchy has been improperly maintained, or contains a cycle"
+        );
+            // SAFETY: The caller guarantees that `transform_query` will not be fetched for any
+            // descendants of `entity`, so it is safe to call `propagate_recursive` for each child.
+            //
+            // The above assertion ensures that each child has one and only one unique parent
+            // throughout the entire hierarchy.
+            unsafe {
+                Self::propagate_recursive(
+                    global_matrix.as_ref(),
+                    transform_query,
+                    parent_query,
+                    child,
+                    changed || actual_parent.is_changed(),
+                );
+            }
+        }
+    }
+}
+
+#[cfg(test)]
+mod tests {
+    use crate::prelude::*;
+    use bevy::prelude::*;
+
+    #[test]
+    fn low_precision_in_big_space() {
+        #[derive(Component)]
+        struct Test;
+
+        let mut app = App::new();
+        app.add_plugins(BigSpacePlugin::<i32>::default())
+            .add_systems(Startup, |mut commands: Commands| {
+                commands.spawn_big_space_default::<i32>(|root| {
+                    root.spawn_spatial(FloatingOrigin);
+                    root.spawn_spatial((
+                        Transform::from_xyz(3.0, 3.0, 3.0),
+                        GridCell::new(1, 1, 1), // Default cell size is 2000
+                    ))
+                    .with_children(|spatial| {
+                        spatial.spawn((
+                            Transform::from_xyz(1.0, 2.0, 3.0),
+                            Visibility::default(),
+                            Test,
+                        ));
+                    });
+                });
+            });
+
+        app.update();
+
+        let mut q = app
+            .world_mut()
+            .query_filtered::<&GlobalTransform, With<Test>>();
+        let actual_transform = *q.single(app.world());
+        assert_eq!(
+            actual_transform,
+            GlobalTransform::from_xyz(2004.0, 2005.0, 2006.0)
+        )
+    }
+}
diff --git a/src/grid_cell.rs b/src/grid_cell.rs
deleted file mode 100644
index d90389e..0000000
--- a/src/grid_cell.rs
+++ /dev/null
@@ -1,147 +0,0 @@
-//! Contains the grid cell implementation
-
-use bevy_ecs::prelude::*;
-use bevy_reflect::prelude::*;
-
-use crate::*;
-
-use self::{precision::GridPrecision, reference_frame::ReferenceFrame};
-
-/// The cell index an entity within a [`crate::ReferenceFrame`]'s grid. The [`Transform`] of an
-/// entity with this component is a transformation from the center of this cell.
-///
-/// This component adds precision to the translation of an entity's [`Transform`]. In a
-/// high-precision [`BigSpace`] world, the position of an entity is described by a [`Transform`]
-/// *and* a [`GridCell`]. This component is the index of a cell inside a large grid defined by the
-/// [`ReferenceFrame`], and the transform is the position of the entity relative to the center of
-/// that cell.
-#[derive(Component, Default, Debug, PartialEq, Eq, PartialOrd, Ord, Clone, Copy, Hash, Reflect)]
-#[reflect(Component, Default, PartialEq)]
-#[require(Transform, GlobalTransform)]
-pub struct GridCell<P: GridPrecision> {
-    /// The x-index of the cell.
-    pub x: P,
-    /// The y-index of the cell.
-    pub y: P,
-    /// The z-index of the cell.
-    pub z: P,
-}
-
-impl<P: GridPrecision> GridCell<P> {
-    /// Construct a new [`GridCell`].
-    pub fn new(x: P, y: P, z: P) -> Self {
-        Self { x, y, z }
-    }
-
-    /// The origin [`GridCell`].
-    pub const ZERO: Self = GridCell {
-        x: P::ZERO,
-        y: P::ZERO,
-        z: P::ZERO,
-    };
-
-    /// A unit value [`GridCell`]. Useful for offsets.
-    pub const ONE: Self = GridCell {
-        x: P::ONE,
-        y: P::ONE,
-        z: P::ONE,
-    };
-
-    /// If an entity's transform translation becomes larger than the limit specified in its
-    /// [`ReferenceFrame`], it will be relocated to the nearest grid cell to reduce the size of the
-    /// transform.
-    pub fn recenter_large_transforms(
-        reference_frames: Query<&ReferenceFrame<P>>,
-        mut changed_transform: Query<(&mut Self, &mut Transform, &Parent), Changed<Transform>>,
-    ) {
-        changed_transform
-            .par_iter_mut()
-            .for_each(|(mut grid_pos, mut transform, parent)| {
-                let Ok(reference_frame) = reference_frames.get(parent.get()) else {
-                    return;
-                };
-                if transform.as_ref().translation.abs().max_element()
-                    > reference_frame.maximum_distance_from_origin()
-                {
-                    let (grid_cell_delta, translation) = reference_frame
-                        .imprecise_translation_to_grid(transform.as_ref().translation);
-                    *grid_pos += grid_cell_delta;
-                    transform.translation = translation;
-                }
-            });
-    }
-}
-
-impl<P: GridPrecision> std::ops::Add for GridCell<P> {
-    type Output = GridCell<P>;
-
-    fn add(self, rhs: Self) -> Self::Output {
-        GridCell {
-            x: self.x.wrapping_add(rhs.x),
-            y: self.y.wrapping_add(rhs.y),
-            z: self.z.wrapping_add(rhs.z),
-        }
-    }
-}
-
-impl<P: GridPrecision> std::ops::Sub for GridCell<P> {
-    type Output = GridCell<P>;
-
-    fn sub(self, rhs: Self) -> Self::Output {
-        GridCell {
-            x: self.x.wrapping_sub(rhs.x),
-            y: self.y.wrapping_sub(rhs.y),
-            z: self.z.wrapping_sub(rhs.z),
-        }
-    }
-}
-
-impl<P: GridPrecision> std::ops::Add for &GridCell<P> {
-    type Output = GridCell<P>;
-
-    fn add(self, rhs: Self) -> Self::Output {
-        (*self).add(*rhs)
-    }
-}
-
-impl<P: GridPrecision> std::ops::Sub for &GridCell<P> {
-    type Output = GridCell<P>;
-
-    fn sub(self, rhs: Self) -> Self::Output {
-        (*self).sub(*rhs)
-    }
-}
-
-impl<P: GridPrecision> std::ops::AddAssign for GridCell<P> {
-    fn add_assign(&mut self, rhs: Self) {
-        use std::ops::Add;
-        *self = self.add(rhs);
-    }
-}
-
-impl<P: GridPrecision> std::ops::SubAssign for GridCell<P> {
-    fn sub_assign(&mut self, rhs: Self) {
-        use std::ops::Sub;
-        *self = self.sub(rhs);
-    }
-}
-
-impl<P: GridPrecision> std::ops::Mul<P> for GridCell<P> {
-    type Output = GridCell<P>;
-
-    fn mul(self, rhs: P) -> Self::Output {
-        GridCell {
-            x: self.x.mul(rhs),
-            y: self.y.mul(rhs),
-            z: self.z.mul(rhs),
-        }
-    }
-}
-
-impl<P: GridPrecision> std::ops::Mul<P> for &GridCell<P> {
-    type Output = GridCell<P>;
-
-    fn mul(self, rhs: P) -> Self::Output {
-        (*self).mul(rhs)
-    }
-}
diff --git a/src/hash/component.rs b/src/hash/component.rs
new file mode 100644
index 0000000..0cc91ae
--- /dev/null
+++ b/src/hash/component.rs
@@ -0,0 +1,217 @@
+//! Components for spatial hashing.
+
+use std::hash::{Hash, Hasher};
+
+use crate::prelude::*;
+use bevy_ecs::prelude::*;
+use bevy_hierarchy::Parent;
+use bevy_math::IVec3;
+use bevy_reflect::Reflect;
+use bevy_utils::{AHasher, Instant, Parallel};
+
+use super::{ChangedGridHashes, GridHashMapFilter};
+
+/// A fast but lossy version of [`GridHash`]. Use this component when you don't care about false
+/// positives (hash collisions). See the docs on [`GridHash::fast_eq`] for more details on fast but
+/// lossy equality checks.
+///
+/// ### Hashing
+///
+/// Use this in `HashMap`s and `HashSet`s with `PassHash` to avoid re-hashing the stored precomputed
+/// hash. Remember, hash collisions cannot be resolved for this type!
+#[derive(Component, Clone, Copy, Debug, Reflect, PartialEq, Eq)]
+pub struct FastGridHash(u64);
+
+impl Hash for FastGridHash {
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        state.write_u64(self.0);
+    }
+}
+
+impl<P: GridPrecision> PartialEq<GridHash<P>> for FastGridHash {
+    fn eq(&self, other: &GridHash<P>) -> bool {
+        self.0 == other.pre_hash
+    }
+}
+
+/// A unique spatial hash shared by all entities in the same [`GridCell`] within the same [`Grid`].
+///
+/// Once computed, a spatial hash can be used to rapidly check if any two entities are in the same
+/// cell, by comparing the hashes. You can also get a list of all entities within a cell using the
+/// [`GridHashMap`] resource.
+///
+/// Due to grids and multiple big spaces in a single world, this must use both the [`GridCell`] and
+/// the [`Parent`] of the entity to uniquely identify its position. These two values are then hashed
+/// and stored in this spatial hash component.
+#[derive(Component, Clone, Copy, Debug, Reflect)]
+pub struct GridHash<P: GridPrecision> {
+    // Needed for equality checks
+    cell: GridCell<P>,
+    // Needed for equality checks
+    grid: Entity,
+    // The hashed value of the `cell` and `grid` fields. Hash collisions are possible, especially
+    // for grids with very large `GridPrecision`s, because a single u64 can only represent the
+    // fraction of possible states compared to an `Entity` (2x u32) and `GridCell` (3x i128)
+    // combined.
+    pre_hash: u64,
+}
+
+impl<P: GridPrecision> PartialEq for GridHash<P> {
+    #[inline]
+    fn eq(&self, other: &Self) -> bool {
+        // Comparing the hash is redundant.
+        //
+        // TODO benchmark adding a hash comparison at the front, may help early out for most
+        // comparisons? It might not be a win, because many of the comparisons could be coming from
+        // hashmaps, in which case we already know the hashes are the same.
+        self.cell == other.cell && self.grid == other.grid
+    }
+}
+
+impl<P: GridPrecision> Eq for GridHash<P> {}
+
+impl<P: GridPrecision> Hash for GridHash<P> {
+    #[inline]
+    fn hash<H: Hasher>(&self, state: &mut H) {
+        state.write_u64(self.pre_hash);
+    }
+}
+
+impl<P: GridPrecision> GridHash<P> {
+    /// Generate a new hash from parts.
+    ///
+    /// Intentionally left private, so we can ensure the only place these are constructed/mutated is
+    /// this module. This allows us to optimize change detection using [`ChangedGridHashes`].
+    #[inline]
+    pub(super) fn new(parent: &Parent, cell: &GridCell<P>) -> Self {
+        Self::from_parent(parent.get(), cell)
+    }
+
+    #[inline]
+    pub(super) fn from_parent(parent: Entity, cell: &GridCell<P>) -> Self {
+        let hasher = &mut AHasher::default();
+        hasher.write_u64(parent.to_bits());
+        cell.hash(hasher);
+
+        GridHash {
+            cell: *cell,
+            grid: parent,
+            pre_hash: hasher.finish(),
+        }
+    }
+
+    /// Do not use this to manually construct this component. You've been warned.
+    #[doc(hidden)]
+    pub fn __new_manual(parent: Entity, cell: &GridCell<P>) -> Self {
+        Self::from_parent(parent, cell)
+    }
+
+    /// Fast comparison that can return false positives, but never false negatives.
+    ///
+    /// Consider using [`FastGridHash`] if you only need fast equality comparisons, as it is much
+    /// more cache friendly than this [`GridHash`] component.
+    ///
+    /// Unlike the [`PartialEq`] implementation, this equality check will only compare the hash
+    /// value instead of the cell and parent. This can result in collisions. You should only use
+    /// this when you want to prove that two cells do not overlap.
+    ///
+    /// - If this returns `false`, it is guaranteed that the entities are in different cells.
+    /// - if this returns `true`, it is probable (but not guaranteed) that the entities are in the
+    ///   same cell
+    ///
+    /// If this returns true, you may either want to try the slightly slower `eq` method, or, ignore
+    /// the chance of a false positive. This is common in collision detection - a false positive is
+    /// rare, and only results in doing some extra narrow-phase collision tests, but no logic
+    /// errors.
+    ///
+    /// In other words, this should only be used for acceleration, when you want to quickly cull
+    /// non-overlapping cells, and you will be double checking for false positives later.
+    #[inline]
+    pub fn fast_eq(&self, other: &Self) -> bool {
+        self.pre_hash == other.pre_hash
+    }
+
+    /// Returns an iterator over all neighboring grid cells and their hashes, within the
+    /// `cell_radius`. This iterator will not visit `cell`.
+    pub fn adjacent(&self, cell_radius: u8) -> impl Iterator<Item = GridHash<P>> + '_ {
+        let radius = cell_radius as i32;
+        let search_width = 1 + 2 * radius;
+        let search_volume = search_width.pow(3);
+        let center = -radius;
+        let stride = IVec3::new(1, search_width, search_width.pow(2));
+        (0..search_volume)
+            .map(move |i| center + i / stride % search_width)
+            .filter(|offset| *offset != IVec3::ZERO) // Skip center cell
+            .map(move |offset| {
+                let neighbor_cell = self.cell + offset;
+                GridHash::from_parent(self.grid, &neighbor_cell)
+            })
+    }
+
+    /// Update or insert the [`GridHash`] of all changed entities that match the optional
+    /// [`GridHashMapFilter`].
+    pub(super) fn update<F: GridHashMapFilter>(
+        mut commands: Commands,
+        mut changed_hashes: ResMut<ChangedGridHashes<P, F>>,
+        mut spatial_entities: ParamSet<(
+            Query<
+                (
+                    Entity,
+                    &Parent,
+                    &GridCell<P>,
+                    &mut GridHash<P>,
+                    &mut FastGridHash,
+                ),
+                (F, Or<(Changed<Parent>, Changed<GridCell<P>>)>),
+            >,
+            Query<(Entity, &Parent, &GridCell<P>), (F, Without<GridHash<P>>)>,
+        )>,
+        mut stats: Option<ResMut<crate::timing::GridHashStats>>,
+        mut thread_changed_hashes: Local<Parallel<Vec<Entity>>>,
+        mut thread_commands: Local<Parallel<Vec<(Entity, GridHash<P>, FastGridHash)>>>,
+    ) {
+        let start = Instant::now();
+
+        // Create new
+        spatial_entities
+            .p1()
+            .par_iter()
+            .for_each(|(entity, parent, cell)| {
+                let spatial_hash = GridHash::new(parent, cell);
+                let fast_hash = FastGridHash(spatial_hash.pre_hash);
+                thread_commands.scope(|tl| tl.push((entity, spatial_hash, fast_hash)));
+                thread_changed_hashes.scope(|tl| tl.push(entity));
+            });
+        for (entity, spatial_hash, fast_hash) in thread_commands.drain() {
+            commands.entity(entity).insert((spatial_hash, fast_hash));
+        }
+
+        // Update existing
+        spatial_entities.p0().par_iter_mut().for_each(
+            |(entity, parent, cell, mut hash, mut fast_hash)| {
+                let new_hash = GridHash::new(parent, cell);
+                let new_fast_hash = new_hash.pre_hash;
+                if hash.replace_if_neq(new_hash).is_some() {
+                    thread_changed_hashes.scope(|tl| tl.push(entity));
+                }
+                fast_hash.0 = new_fast_hash;
+            },
+        );
+
+        changed_hashes.list.extend(thread_changed_hashes.drain());
+
+        if let Some(ref mut stats) = stats {
+            stats.hash_update_duration += start.elapsed();
+        }
+    }
+
+    /// The [`GridCell`] associated with this spatial hash.
+    pub fn cell(&self) -> GridCell<P> {
+        self.cell
+    }
+
+    /// The [`Parent`] [`Grid`] of this spatial hash.
+    pub fn grid(&self) -> Entity {
+        self.grid
+    }
+}
diff --git a/src/hash/map.rs b/src/hash/map.rs
new file mode 100644
index 0000000..1471e76
--- /dev/null
+++ b/src/hash/map.rs
@@ -0,0 +1,475 @@
+//! The [`GridHashMap`] that contains mappings between entities and their spatial hash.
+
+use std::{collections::VecDeque, marker::PhantomData, time::Instant};
+
+use crate::prelude::*;
+use bevy_ecs::{entity::EntityHash, prelude::*};
+use bevy_utils::{
+    hashbrown::{HashMap, HashSet},
+    PassHash,
+};
+
+use super::GridHashMapFilter;
+
+/// An entry in a [`GridHashMap`], accessed with a [`GridHash`].
+#[derive(Clone, Debug)]
+pub struct GridHashEntry<P: GridPrecision> {
+    /// All the entities located in this grid cell.
+    pub entities: HashSet<Entity, EntityHash>,
+    /// Precomputed hashes to direct neighbors.
+    // TODO: computation cheap, heap slow. Can this be replaced with a u32 bitmask of occupied cells
+    // (only need 26 bits), with the hashes computed based on the neighbor's relative position?
+    pub occupied_neighbors: Vec<GridHash<P>>,
+}
+
+impl<P: GridPrecision> GridHashEntry<P> {
+    /// Find an occupied neighbor's index in the list.
+    fn neighbor_index(&self, hash: &GridHash<P>) -> Option<usize> {
+        self.occupied_neighbors
+            .iter()
+            .enumerate()
+            .rev() // recently added cells are more likely to be removed
+            .find_map(|(i, h)| (h == hash).then_some(i))
+    }
+
+    /// Iterate over this cell and its non-empty adjacent neighbors.
+    ///
+    /// See [`GridHashMap::nearby`].
+    pub fn nearby<'a, F: GridHashMapFilter>(
+        &'a self,
+        map: &'a GridHashMap<P, F>,
+    ) -> impl Iterator<Item = &'a GridHashEntry<P>> + 'a {
+        map.nearby(self)
+    }
+}
+
+/// Trait extension that adds `.entities()` to any iterator of [`GridHashEntry`]s.
+pub trait SpatialEntryToEntities<'a> {
+    /// Flatten an iterator of [`GridHashEntry`]s into an iterator of [`Entity`]s.
+    fn entities(self) -> impl Iterator<Item = Entity> + 'a;
+}
+
+impl<'a, T, I> SpatialEntryToEntities<'a> for T
+where
+    T: Iterator<Item = I> + 'a,
+    I: SpatialEntryToEntities<'a>,
+{
+    fn entities(self) -> impl Iterator<Item = Entity> + 'a {
+        self.flat_map(|entry| entry.entities())
+    }
+}
+
+impl<'a, P: GridPrecision> SpatialEntryToEntities<'a> for &'a GridHashEntry<P> {
+    #[inline]
+    fn entities(self) -> impl Iterator<Item = Entity> + 'a {
+        self.entities.iter().copied()
+    }
+}
+
+impl<'a, P: GridPrecision> SpatialEntryToEntities<'a> for Neighbor<'a, P> {
+    #[inline]
+    fn entities(self) -> impl Iterator<Item = Entity> + 'a {
+        self.1.entities.iter().copied()
+    }
+}
+
+/// A global spatial hash map for quickly finding entities in a grid cell.
+#[derive(Resource, Clone)]
+pub struct GridHashMap<P, F = ()>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    /// The primary hash map for looking up entities by their [`GridHash`].
+    map: InnerGridHashMap<P>,
+    /// A reverse lookup to find the latest spatial hash associated with an entity that this map is
+    /// aware of. This is needed to remove or move an entity when its cell changes, because once it
+    /// changes in the ECS, we need to know its *previous* value when it was inserted in this map.
+    reverse_map: HashMap<Entity, GridHash<P>, PassHash>,
+    spooky: PhantomData<F>,
+}
+
+impl<P, F> std::fmt::Debug for GridHashMap<P, F>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
+        f.debug_struct("GridHashMap")
+            .field("map", &self.map)
+            .field("reverse_map", &self.reverse_map)
+            .finish()
+    }
+}
+
+impl<P, F> Default for GridHashMap<P, F>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    fn default() -> Self {
+        Self {
+            map: Default::default(),
+            reverse_map: Default::default(),
+            spooky: PhantomData,
+        }
+    }
+}
+
+impl<P: GridPrecision, F: GridHashMapFilter> GridHashMap<P, F> {
+    /// Get information about all entities located at this [`GridHash`], as well as its
+    /// neighbors.
+    #[inline]
+    pub fn get(&self, hash: &GridHash<P>) -> Option<&GridHashEntry<P>> {
+        self.map.inner.get(hash)
+    }
+
+    /// Returns `true` if this [`GridHash`] is occupied.
+    #[inline]
+    pub fn contains(&self, hash: &GridHash<P>) -> bool {
+        self.map.inner.contains_key(hash)
+    }
+
+    /// An iterator visiting all spatial hash cells and their contents in arbitrary order.
+    #[inline]
+    pub fn all_entries(&self) -> impl Iterator<Item = (&GridHash<P>, &GridHashEntry<P>)> {
+        self.map.inner.iter()
+    }
+
+    /// Iterate over this cell and its non-empty adjacent neighbors.
+    ///
+    /// `GridHashEntry`s cache information about their neighbors as the spatial map is updated,
+    /// making it faster to look up neighboring entries when compared to computing all neighbor
+    /// hashes and checking if they exist.
+    ///
+    /// This function intentionally accepts [`GridHashEntry`] instead of [`GridHash`],
+    /// because it is not a general radius test; it only works for occupied cells with a
+    /// [`GridHashEntry`]. This API makes the above optimization possible, while preventing
+    /// misuse and foot guns.
+    #[inline]
+    pub fn nearby<'a>(
+        &'a self,
+        entry: &'a GridHashEntry<P>,
+    ) -> impl Iterator<Item = &'a GridHashEntry<P>> + 'a {
+        // Use `std::iter::once` to avoid returning a function-local variable.
+        Iterator::chain(
+            std::iter::once(entry),
+            entry.occupied_neighbors.iter().map(|neighbor_hash| {
+                self.get(neighbor_hash)
+                    .expect("occupied_neighbors should be occupied")
+            }),
+        )
+    }
+
+    /// Iterate over all [`GridHashEntry`]s within a cube with `center` and `radius`.
+    ///
+    /// ### Warning
+    ///
+    /// This can become expensive very quickly! The number of cells that need to be checked is
+    /// exponential, a radius of 1 will access 26 cells, a radius of 2, will access 124 cells, and
+    /// radius 5 will access 1,330 cells.
+    ///
+    /// Additionally, unlike `nearby`, this function cannot rely on cached information about
+    /// neighbors. If you are using this function when `hash` is an occupied cell and `radius` is
+    /// `1`, you should probably be using [`GridHashMap::nearby`] instead.
+    #[inline]
+    pub fn within_cube<'a>(
+        &'a self,
+        center: &'a GridHash<P>,
+        radius: u8,
+    ) -> impl Iterator<Item = &'a GridHashEntry<P>> + 'a {
+        // Use `std::iter::once` to avoid returning a function-local variable.
+        Iterator::chain(std::iter::once(*center), center.adjacent(radius))
+            .filter_map(|hash| self.get(&hash))
+    }
+
+    /// Iterate over all connected neighboring cells with a breadth-first "flood fill" traversal
+    /// starting at `seed`. Limits the extents of the breadth-first flood fill traversal with a
+    /// `max_depth`.
+    ///
+    /// ## Depth Limit
+    ///
+    /// This will exit the breadth first traversal as soon as the depth is exceeded. While this
+    /// measurement is the same as the radius, it will not necessarily visit all cells within the
+    /// radius - it will only visit cells within this radius *and* search depth.
+    ///
+    /// Consider the case of a long thin U-shaped set of connected cells. While iterating from one
+    /// end of the "U" to the other with this flood fill, if any of the cells near the base of the
+    /// "U" exceed the max_depth (radius), iteration will stop. Even if the "U" loops back within
+    /// the radius, those cells will never be visited.
+    ///
+    /// Also note that the `max_depth` (radius) is a chebyshev distance, not a euclidean distance.
+    #[doc(alias = "bfs")]
+    pub fn flood<'a>(
+        &'a self,
+        seed: &GridHash<P>,
+        max_depth: Option<P>,
+    ) -> impl Iterator<Item = Neighbor<'a, P>> {
+        let starting_cell_cell = seed.cell();
+        ContiguousNeighborsIter {
+            initial_hash: Some(*seed),
+            spatial_map: self,
+            stack: Default::default(),
+            visited_cells: Default::default(),
+        }
+        .take_while(move |Neighbor(hash, _)| {
+            let Some(max_depth) = max_depth else {
+                return true;
+            };
+            let dist = hash.cell() - starting_cell_cell;
+            dist.x <= max_depth && dist.y <= max_depth && dist.z <= max_depth
+        })
+    }
+
+    /// The set of cells that were inserted in the last update to the spatial hash map.
+    ///
+    /// These are cells that were previously empty, but now contain at least one entity.
+    ///
+    /// Useful for incrementally updating data structures that extend the functionality of
+    /// [`GridHashMap`]. Updated in [`GridHashMapSystem::UpdateMap`].
+    pub fn just_inserted(&self) -> &HashSet<GridHash<P>, PassHash> {
+        &self.map.just_inserted
+    }
+
+    /// The set of cells that were removed in the last update to the spatial hash map.
+    ///
+    /// These are cells that were previously occupied, but now contain no entities.
+    ///
+    /// Useful for incrementally updating data structures that extend the functionality of
+    /// [`GridHashMap`]. Updated in [`GridHashMapSystem::UpdateMap`].
+    pub fn just_removed(&self) -> &HashSet<GridHash<P>, PassHash> {
+        &self.map.just_removed
+    }
+}
+
+/// Private Systems
+impl<P: GridPrecision, F: GridHashMapFilter> GridHashMap<P, F> {
+    /// Update the [`GridHashMap`] with entities that have changed [`GridHash`]es, and meet the
+    /// optional [`GridHashMapFilter`].
+    pub(super) fn update(
+        mut spatial_map: ResMut<Self>,
+        mut changed_hashes: ResMut<super::ChangedGridHashes<P, F>>,
+        all_hashes: Query<(Entity, &GridHash<P>), F>,
+        mut removed: RemovedComponents<GridHash<P>>,
+        mut stats: Option<ResMut<crate::timing::GridHashStats>>,
+    ) {
+        let start = Instant::now();
+
+        spatial_map.map.just_inserted.clear();
+        spatial_map.map.just_removed.clear();
+
+        for entity in removed.read() {
+            spatial_map.remove(entity)
+        }
+
+        if let Some(ref mut stats) = stats {
+            stats.moved_entities = changed_hashes.list.len();
+        }
+
+        // See the docs on ChangedGridHash understand why we don't use query change detection.
+        for (entity, spatial_hash) in changed_hashes
+            .list
+            .drain(..)
+            .filter_map(|entity| all_hashes.get(entity).ok())
+        {
+            spatial_map.insert(entity, *spatial_hash);
+        }
+
+        if let Some(ref mut stats) = stats {
+            stats.map_update_duration += start.elapsed();
+        }
+    }
+}
+
+/// Private Methods
+impl<P: GridPrecision, F: GridHashMapFilter> GridHashMap<P, F> {
+    /// Insert an entity into the [`GridHashMap`], updating any existing entries.
+    #[inline]
+    fn insert(&mut self, entity: Entity, hash: GridHash<P>) {
+        // If this entity is already in the maps, we need to remove and update it.
+        if let Some(old_hash) = self.reverse_map.get_mut(&entity) {
+            if hash.eq(old_hash) {
+                return; // If the spatial hash is unchanged, early exit.
+            }
+            self.map.remove(entity, *old_hash);
+            *old_hash = hash;
+        } else {
+            self.reverse_map.insert(entity, hash);
+        }
+
+        self.map.insert(entity, hash);
+    }
+
+    /// Remove an entity from the [`GridHashMap`].
+    #[inline]
+    fn remove(&mut self, entity: Entity) {
+        if let Some(old_hash) = self.reverse_map.remove(&entity) {
+            self.map.remove(entity, old_hash)
+        }
+    }
+}
+
+/// The primary spatial hash extracted into its own type to help uphold invariants around insertions
+/// and removals.
+//
+// TODO: Performance
+//
+// Improve the data locality of neighbors. Completely random access in a hot loop is probably
+// unlikely, we should instead optimize for the case of wanting to look up neighbors of the current
+// cell. We know neighbor lookups are a common need, and are a bottleneck currently.
+//
+//  - To do this, we could store neighboring entities together in the same entry, so they fill the
+//    cache line during a lookup. Getting a neighbor in the current entry should then be super fast,
+//    as it is already loaded on the cache.
+//  - Not sure what the group size would be, probably depends on a bunch of factors, though will be
+//    limited by common cache line sizes in practice, the decision is probably between whether to
+//    group 2x2x2 or 3x3x3 blocks of cells into the same entry.
+//  - Considering the entity hash set is stored on the heap, it might also make sense to group all
+//    of these into a single collection. Iterating over all neighbors would then only need to access
+//    this single hash set, and scan through it linear, instead of grabbing 8 (2x2x2) or 27 (3x3x3)
+//    independent sets each at a different memory location.
+//      - Not sure how you would efficiently partition this for each cell however. It could be a
+//        hashmap whose value is the cell? Iterating over the entities in a single cell would then
+//        require filtering out other cells. This might not be a big deal because iteration go brrr.
+//        Unique insertion would be an issue though, e.g. the hash set for each cell ensures the
+//        entity is unique.
+//
+//  - Another wild idea is to not change the hashmap structure at all, but store all entries in
+//    Z-order in *another* collection (BTreeMap?) to improve locality for sequential lookups of
+//    spatial neighbors. Would ordering cause hitches with insertions?
+#[derive(Debug, Clone, Default)]
+struct InnerGridHashMap<P: GridPrecision> {
+    inner: HashMap<GridHash<P>, GridHashEntry<P>, PassHash>,
+    /// Creating and freeing hash sets is expensive. To reduce time spent allocating and running
+    /// destructors, we save any hash sets that would otherwise be thrown away. The next time we
+    /// need to construct a new hash set of entities, we can grab one here.
+    ///
+    /// <https://en.wikipedia.org/wiki/Object_pool_pattern>.
+    hash_set_pool: Vec<HashSet<Entity, EntityHash>>,
+    neighbor_pool: Vec<Vec<GridHash<P>>>,
+    /// Cells that were added because they were empty but now contain entities.
+    just_inserted: HashSet<GridHash<P>, PassHash>,
+    /// Cells that were removed because all entities vacated the cell.
+    just_removed: HashSet<GridHash<P>, PassHash>,
+}
+
+impl<P: GridPrecision> InnerGridHashMap<P> {
+    #[inline]
+    fn insert(&mut self, entity: Entity, hash: GridHash<P>) {
+        if let Some(entry) = self.inner.get_mut(&hash) {
+            entry.entities.insert(entity);
+        } else {
+            let mut entities = self.hash_set_pool.pop().unwrap_or_default();
+            entities.insert(entity);
+
+            let mut occupied_neighbors = self.neighbor_pool.pop().unwrap_or_default();
+            occupied_neighbors.extend(hash.adjacent(1).filter(|neighbor| {
+                self.inner
+                    .get_mut(neighbor)
+                    .map(|entry| {
+                        entry.occupied_neighbors.push(hash);
+                        true
+                    })
+                    .unwrap_or_default()
+            }));
+
+            self.inner.insert(
+                hash,
+                GridHashEntry {
+                    entities,
+                    occupied_neighbors,
+                },
+            );
+
+            if !self.just_removed.remove(&hash) {
+                // If a cell is removed then added within the same update, it can't be considered
+                // "just added" because it *already existed* at the start of the update.
+                self.just_inserted.insert(hash);
+            }
+        }
+    }
+
+    #[inline]
+    fn remove(&mut self, entity: Entity, old_hash: GridHash<P>) {
+        if let Some(entry) = self.inner.get_mut(&old_hash) {
+            entry.entities.remove(&entity);
+            if !entry.entities.is_empty() {
+                return; // Early exit if the cell still has other entities in it
+            }
+        }
+
+        // The entry is empty, so we need to do some cleanup
+        if let Some(mut removed_entry) = self.inner.remove(&old_hash) {
+            // Remove this entry from its neighbors' occupied neighbor list
+            removed_entry
+                .occupied_neighbors
+                .drain(..)
+                .for_each(|neighbor_hash| {
+                    let neighbor = self
+                        .inner
+                        .get_mut(&neighbor_hash)
+                        .expect("occupied neighbors is guaranteed to be up to date");
+                    let index = neighbor.neighbor_index(&old_hash).unwrap();
+                    neighbor.occupied_neighbors.remove(index);
+                });
+
+            // Add the allocated structs to their object pools, to reuse the allocations.
+            self.hash_set_pool.push(removed_entry.entities);
+            self.neighbor_pool.push(removed_entry.occupied_neighbors);
+
+            if !self.just_inserted.remove(&old_hash) {
+                // If a cell is added then removed within the same update, it can't be considered
+                // "just removed" because it *already didn't exist* at the start of the update.
+                self.just_removed.insert(old_hash);
+            }
+        }
+    }
+}
+
+/// An iterator over the neighbors of a cell, breadth-first.
+pub struct ContiguousNeighborsIter<'a, P, F>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    initial_hash: Option<GridHash<P>>,
+    spatial_map: &'a GridHashMap<P, F>,
+    stack: VecDeque<Neighbor<'a, P>>,
+    visited_cells: HashSet<GridHash<P>>,
+}
+
+/// Newtype used for adding useful extensions like `.entities()`.
+pub struct Neighbor<'a, P: GridPrecision>(pub GridHash<P>, pub &'a GridHashEntry<P>);
+
+impl<'a, P, F> Iterator for ContiguousNeighborsIter<'a, P, F>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    type Item = Neighbor<'a, P>;
+
+    fn next(&mut self) -> Option<Self::Item> {
+        if let Some(hash) = self.initial_hash.take() {
+            self.stack
+                .push_front(Neighbor(hash, self.spatial_map.get(&hash)?));
+            self.visited_cells.insert(hash);
+        }
+        let Neighbor(hash, entry) = self.stack.pop_back()?;
+        for (neighbor_hash, neighbor_entry) in entry
+            .occupied_neighbors
+            .iter()
+            .filter(|neighbor_hash| self.visited_cells.insert(**neighbor_hash))
+            .map(|neighbor_hash| {
+                let entry = self
+                    .spatial_map
+                    .get(neighbor_hash)
+                    .expect("Neighbor hashes in GridHashEntry are guaranteed to exist.");
+                (neighbor_hash, entry)
+            })
+        {
+            self.stack
+                .push_front(Neighbor(*neighbor_hash, neighbor_entry));
+        }
+        Some(Neighbor(hash, entry))
+    }
+}
diff --git a/src/hash/mod.rs b/src/hash/mod.rs
new file mode 100644
index 0000000..67e2256
--- /dev/null
+++ b/src/hash/mod.rs
@@ -0,0 +1,432 @@
+//! Spatial hashing acceleration structure. See [`GridHashPlugin`].
+
+use std::marker::PhantomData;
+
+use crate::prelude::*;
+use bevy_app::prelude::*;
+use bevy_ecs::{prelude::*, query::QueryFilter};
+
+pub mod component;
+pub mod map;
+pub mod partition;
+
+/// Add spatial hashing acceleration to `big_space`, accessible through the [`GridHashMap`] resource,
+/// and [`GridHash`] components.
+///
+/// You can optionally add a [`GridHashMapFilter`] to this plugin, to only run the spatial hashing on
+/// entities that match the query filter. This is useful if you only want to, say, compute hashes
+/// and insert in the [`GridHashMap`] for `Player` entities.
+///
+/// If you are adding multiple copies of this plugin with different filters, there are optimizations
+/// in place to avoid duplicating work. However, you should still take care to avoid excessively
+/// overlapping filters.
+pub struct GridHashPlugin<P, F = ()>(PhantomData<(P, F)>)
+where
+    P: GridPrecision,
+    F: GridHashMapFilter;
+
+impl<P, F> Plugin for GridHashPlugin<P, F>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    fn build(&self, app: &mut App) {
+        app.init_resource::<GridHashMap<P, F>>()
+            .init_resource::<ChangedGridHashes<P, F>>()
+            .register_type::<GridHash<P>>()
+            .add_systems(
+                PostUpdate,
+                (
+                    GridHash::<P>::update::<F>
+                        .in_set(GridHashMapSystem::UpdateHash)
+                        .after(FloatingOriginSystem::RecenterLargeTransforms),
+                    GridHashMap::<P, F>::update
+                        .in_set(GridHashMapSystem::UpdateMap)
+                        .after(GridHashMapSystem::UpdateHash),
+                ),
+            );
+    }
+}
+
+impl<P: GridPrecision, F: GridHashMapFilter> Default for GridHashPlugin<P, F> {
+    fn default() -> Self {
+        Self(PhantomData)
+    }
+}
+
+/// System sets for [`GridHashPlugin`].
+#[derive(SystemSet, Hash, Debug, PartialEq, Eq, Clone)]
+pub enum GridHashMapSystem {
+    /// [`GridHash`] updated.
+    UpdateHash,
+    /// [`GridHashMap`] updated.
+    UpdateMap,
+    /// [`GridPartitionMap`] updated.
+    UpdatePartition,
+}
+
+/// Used as a [`QueryFilter`] to include or exclude certain types of entities from spatial
+/// hashing.The trait is automatically implemented for all compatible types, like [`With`] or
+/// [`Without`].
+///
+/// By default, this is `()`, but it can be overidden when adding the [`GridHashPlugin`] and
+/// [`GridHashMap`]. For example, if you use `With<Players>` as your filter, only `Player`s would be
+/// considered when building spatial hash maps. This is useful when you only care about querying
+/// certain entities, and want to avoid the plugin doing bookkeeping work for entities you don't
+/// care about.
+pub trait GridHashMapFilter: QueryFilter + Send + Sync + 'static {}
+impl<T: QueryFilter + Send + Sync + 'static> GridHashMapFilter for T {}
+
+/// Used to manually track spatial hashes that have changed, for optimization purposes.
+///
+/// We use a manual collection instead of a `Changed` query because a query that uses `Changed`
+/// still has to iterate over every single entity. By making a shortlist of changed entities
+/// ourselves, we can make this 1000x faster.
+///
+/// Note that this is optimized for *sparse* updates, this may perform worse if you are updating
+/// every entity. The observation here is that usually entities are not moving between grid cells,
+/// and thus their spatial hash is not changing. On top of that, many entities are completely
+/// static.
+///
+/// It may be possible to remove this if bevy gets archetype change detection, or observers that can
+/// react to a component being mutated. For now, this performs well enough.
+#[derive(Resource)]
+struct ChangedGridHashes<P: GridPrecision, F: GridHashMapFilter> {
+    list: Vec<Entity>,
+    spooky: PhantomData<(P, F)>,
+}
+
+impl<P: GridPrecision, F: GridHashMapFilter> Default for ChangedGridHashes<P, F> {
+    fn default() -> Self {
+        Self {
+            list: Vec::new(),
+            spooky: PhantomData,
+        }
+    }
+}
+
+// TODO:
+//
+// - When an entity is re-parented, is is removed/updated in the spatial map?
+// - Entities are hashed with their parent - what happens if an entity is moved to the root? Is the
+//   hash ever recomputed? Is it removed? Is the spatial map updated?
+#[cfg(test)]
+mod tests {
+    use std::sync::OnceLock;
+
+    use crate::{hash::map::SpatialEntryToEntities, prelude::*};
+    use bevy_utils::hashbrown::HashSet;
+
+    #[test]
+    fn entity_despawn() {
+        use bevy::prelude::*;
+
+        static ENTITY: OnceLock<Entity> = OnceLock::new();
+
+        let setup = |mut commands: Commands| {
+            commands.spawn_big_space_default::<i32>(|root| {
+                let entity = root.spawn_spatial(GridCell::<i32>::ZERO).id();
+                ENTITY.set(entity).ok();
+            });
+        };
+
+        let mut app = App::new();
+        app.add_plugins(GridHashPlugin::<i32>::default())
+            .add_systems(Update, setup)
+            .update();
+
+        let hash = *app
+            .world()
+            .entity(*ENTITY.get().unwrap())
+            .get::<GridHash<i32>>()
+            .unwrap();
+
+        assert!(app
+            .world()
+            .resource::<GridHashMap<i32>>()
+            .get(&hash)
+            .is_some());
+
+        app.world_mut().despawn(*ENTITY.get().unwrap());
+
+        app.update();
+
+        assert!(app
+            .world()
+            .resource::<GridHashMap<i32>>()
+            .get(&hash)
+            .is_none());
+    }
+
+    #[test]
+    fn get_hash() {
+        use bevy::prelude::*;
+
+        #[derive(Resource, Clone)]
+        struct ParentSet {
+            a: Entity,
+            b: Entity,
+            c: Entity,
+        }
+
+        #[derive(Resource, Clone)]
+        struct ChildSet {
+            x: Entity,
+            y: Entity,
+            z: Entity,
+        }
+
+        let setup = |mut commands: Commands| {
+            commands.spawn_big_space_default::<i32>(|root| {
+                let a = root.spawn_spatial(GridCell::new(0, 1, 2)).id();
+                let b = root.spawn_spatial(GridCell::new(0, 1, 2)).id();
+                let c = root.spawn_spatial(GridCell::new(5, 5, 5)).id();
+
+                root.commands().insert_resource(ParentSet { a, b, c });
+
+                root.with_grid_default(|grid| {
+                    let x = grid.spawn_spatial(GridCell::new(0, 1, 2)).id();
+                    let y = grid.spawn_spatial(GridCell::new(0, 1, 2)).id();
+                    let z = grid.spawn_spatial(GridCell::new(5, 5, 5)).id();
+                    grid.commands().insert_resource(ChildSet { x, y, z });
+                });
+            });
+        };
+
+        let mut app = App::new();
+        app.add_plugins(GridHashPlugin::<i32>::default())
+            .add_systems(Update, setup);
+
+        app.update();
+
+        let mut spatial_hashes = app.world_mut().query::<&GridHash<i32>>();
+
+        let parent = app.world().resource::<ParentSet>().clone();
+        let child = app.world().resource::<ChildSet>().clone();
+
+        assert_eq!(
+            spatial_hashes.get(app.world(), parent.a).unwrap(),
+            spatial_hashes.get(app.world(), parent.b).unwrap(),
+            "Same parent, same cell"
+        );
+
+        assert_ne!(
+            spatial_hashes.get(app.world(), parent.a).unwrap(),
+            spatial_hashes.get(app.world(), parent.c).unwrap(),
+            "Same parent, different cell"
+        );
+
+        assert_eq!(
+            spatial_hashes.get(app.world(), child.x).unwrap(),
+            spatial_hashes.get(app.world(), child.y).unwrap(),
+            "Same parent, same cell"
+        );
+
+        assert_ne!(
+            spatial_hashes.get(app.world(), child.x).unwrap(),
+            spatial_hashes.get(app.world(), child.z).unwrap(),
+            "Same parent, different cell"
+        );
+
+        assert_ne!(
+            spatial_hashes.get(app.world(), parent.a).unwrap(),
+            spatial_hashes.get(app.world(), child.x).unwrap(),
+            "Same cell, different parent"
+        );
+
+        let entities = &app
+            .world()
+            .resource::<GridHashMap<i32>>()
+            .get(spatial_hashes.get(app.world(), parent.a).unwrap())
+            .unwrap()
+            .entities;
+
+        assert!(entities.contains(&parent.a));
+        assert!(entities.contains(&parent.b));
+        assert!(!entities.contains(&parent.c));
+        assert!(!entities.contains(&child.x));
+        assert!(!entities.contains(&child.y));
+        assert!(!entities.contains(&child.z));
+    }
+
+    #[test]
+    fn neighbors() {
+        use bevy::prelude::*;
+
+        #[derive(Resource, Clone)]
+        struct Entities {
+            a: Entity,
+            b: Entity,
+            c: Entity,
+        }
+
+        let setup = |mut commands: Commands| {
+            commands.spawn_big_space_default::<i32>(|root| {
+                let a = root.spawn_spatial(GridCell::new(0, 0, 0)).id();
+                let b = root.spawn_spatial(GridCell::new(1, 1, 1)).id();
+                let c = root.spawn_spatial(GridCell::new(2, 2, 2)).id();
+
+                root.commands().insert_resource(Entities { a, b, c });
+            });
+        };
+
+        let mut app = App::new();
+        app.add_plugins(GridHashPlugin::<i32>::default())
+            .add_systems(Startup, setup);
+
+        app.update();
+
+        let entities = app.world().resource::<Entities>().clone();
+        let parent = app
+            .world_mut()
+            .query::<&Parent>()
+            .get(app.world(), entities.a)
+            .unwrap();
+
+        let map = app.world().resource::<GridHashMap<i32>>();
+        let entry = map.get(&GridHash::new(parent, &GridCell::ZERO)).unwrap();
+        let neighbors: HashSet<Entity> = map.nearby(entry).entities().collect();
+
+        assert!(neighbors.contains(&entities.a));
+        assert!(neighbors.contains(&entities.b));
+        assert!(!neighbors.contains(&entities.c));
+
+        let flooded: HashSet<Entity> = map
+            .flood(&GridHash::new(parent, &GridCell::ZERO), None)
+            .entities()
+            .collect();
+
+        assert!(flooded.contains(&entities.a));
+        assert!(flooded.contains(&entities.b));
+        assert!(flooded.contains(&entities.c));
+    }
+
+    #[test]
+    fn query_filters() {
+        use bevy::prelude::*;
+
+        #[derive(Component)]
+        struct Player;
+
+        static ROOT: OnceLock<Entity> = OnceLock::new();
+
+        let setup = |mut commands: Commands| {
+            commands.spawn_big_space_default::<i32>(|root| {
+                root.spawn_spatial((GridCell::<i32>::ZERO, Player));
+                root.spawn_spatial(GridCell::<i32>::ZERO);
+                root.spawn_spatial(GridCell::<i32>::ZERO);
+                ROOT.set(root.id()).ok();
+            });
+        };
+
+        let mut app = App::new();
+        app.add_plugins((
+            GridHashPlugin::<i32>::default(),
+            GridHashPlugin::<i32, With<Player>>::default(),
+            GridHashPlugin::<i32, Without<Player>>::default(),
+        ))
+        .add_systems(Startup, setup)
+        .update();
+
+        let zero_hash = GridHash::from_parent(*ROOT.get().unwrap(), &GridCell::ZERO);
+
+        let map = app.world().resource::<GridHashMap<i32>>();
+        assert_eq!(
+            map.get(&zero_hash).unwrap().entities.iter().count(),
+            3,
+            "There are a total of 3 spatial entities"
+        );
+
+        let map = app.world().resource::<GridHashMap<i32, With<Player>>>();
+        assert_eq!(
+            map.get(&zero_hash).unwrap().entities.iter().count(),
+            1,
+            "There is only one entity with the Player component"
+        );
+
+        let map = app.world().resource::<GridHashMap<i32, Without<Player>>>();
+        assert_eq!(
+            map.get(&zero_hash).unwrap().entities.iter().count(),
+            2,
+            "There are two entities without the player component"
+        );
+    }
+
+    /// Verify that [`GridHashMap::just_removed`] and [`GridHashMap::just_inserted`] work correctly when
+    /// entities are spawned and move between cells.
+    #[test]
+    fn spatial_map_changed_cell_tracking() {
+        use bevy::prelude::*;
+
+        #[derive(Resource, Clone)]
+        struct Entities {
+            a: Entity,
+            b: Entity,
+            c: Entity,
+        }
+
+        let setup = |mut commands: Commands| {
+            commands.spawn_big_space_default::<i32>(|root| {
+                let a = root.spawn_spatial(GridCell::new(0, 0, 0)).id();
+                let b = root.spawn_spatial(GridCell::new(1, 1, 1)).id();
+                let c = root.spawn_spatial(GridCell::new(2, 2, 2)).id();
+
+                root.commands().insert_resource(Entities { a, b, c });
+            });
+        };
+
+        let mut app = App::new();
+        app.add_plugins((
+            BigSpacePlugin::<i32>::default(),
+            GridHashPlugin::<i32>::default(),
+        ))
+        .add_systems(Startup, setup);
+
+        app.update();
+
+        let entities = app.world().resource::<Entities>().clone();
+        let get_hash = |app: &mut App, entity| {
+            *app.world_mut()
+                .query::<&GridHash<i32>>()
+                .get(app.world(), entity)
+                .unwrap()
+        };
+
+        let a_hash_t0 = get_hash(&mut app, entities.a);
+        let b_hash_t0 = get_hash(&mut app, entities.b);
+        let c_hash_t0 = get_hash(&mut app, entities.c);
+        let map = app.world().resource::<GridHashMap<i32>>();
+        assert!(map.just_inserted().contains(&a_hash_t0));
+        assert!(map.just_inserted().contains(&b_hash_t0));
+        assert!(map.just_inserted().contains(&c_hash_t0));
+
+        // Move entities and run an update
+        app.world_mut()
+            .entity_mut(entities.a)
+            .get_mut::<GridCell<i32>>()
+            .unwrap()
+            .z += 1;
+        app.world_mut()
+            .entity_mut(entities.b)
+            .get_mut::<Transform>()
+            .unwrap()
+            .translation
+            .z += 1e10;
+        app.update();
+
+        let a_hash_t1 = get_hash(&mut app, entities.a);
+        let b_hash_t1 = get_hash(&mut app, entities.b);
+        let c_hash_t1 = get_hash(&mut app, entities.c);
+        let map = app.world().resource::<GridHashMap<i32>>();
+
+        // Last grid
+        assert!(map.just_removed().contains(&a_hash_t0)); // Moved cell
+        assert!(map.just_removed().contains(&b_hash_t0)); // Moved cell via transform
+        assert!(!map.just_removed().contains(&c_hash_t0)); // Did not move
+
+        // Current grid
+        assert!(map.just_inserted().contains(&a_hash_t1)); // Moved cell
+        assert!(map.just_inserted().contains(&b_hash_t1)); // Moved cell via transform
+        assert!(!map.just_inserted().contains(&c_hash_t1)); // Did not move
+    }
+}
diff --git a/src/hash/partition.rs b/src/hash/partition.rs
new file mode 100644
index 0000000..2d0e96f
--- /dev/null
+++ b/src/hash/partition.rs
@@ -0,0 +1,448 @@
+//! Detect and update groups of nearby occupied cells.
+
+use std::{hash::Hash, marker::PhantomData, ops::Deref, time::Instant};
+
+use bevy_app::prelude::*;
+use bevy_ecs::prelude::*;
+use bevy_tasks::{ComputeTaskPool, ParallelSliceMut};
+use bevy_utils::{
+    hashbrown::{HashMap, HashSet},
+    PassHash,
+};
+
+use super::{GridHash, GridHashMap, GridHashMapFilter, GridHashMapSystem, GridPrecision};
+
+/// Adds support for spatial partitioning. Requires [`GridHashPlugin`](super::GridHashPlugin).
+pub struct GridPartitionPlugin<P, F = ()>(PhantomData<(P, F)>)
+where
+    P: GridPrecision,
+    F: GridHashMapFilter;
+
+impl<P, F> Default for GridPartitionPlugin<P, F>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    fn default() -> Self {
+        Self(PhantomData)
+    }
+}
+
+impl<P, F> Plugin for GridPartitionPlugin<P, F>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    fn build(&self, app: &mut App) {
+        app.init_resource::<GridPartitionMap<P, F>>().add_systems(
+            PostUpdate,
+            GridPartitionMap::<P, F>::update
+                .in_set(GridHashMapSystem::UpdatePartition)
+                .after(GridHashMapSystem::UpdateMap),
+        );
+    }
+}
+
+/// Uniquely identifies a [`GridPartition`] in the [`GridPartitionMap`] resource.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub struct GridPartitionId(u64);
+
+impl GridPartitionId {
+    /// The inner partition id.
+    pub fn id(&self) -> u64 {
+        self.0
+    }
+}
+
+impl Hash for GridPartitionId {
+    #[inline]
+    fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
+        state.write_u64(self.0);
+    }
+}
+
+/// Groups connected [`GridCell`](crate::GridCell)s into [`GridPartition`]s.
+///
+/// Partitions divide space into independent groups of cells.
+///
+/// The map depends on and is built from a corresponding [`GridHashMap`] with the same
+/// `P:`[`GridPrecision`] and `F:`[`GridHashMapFilter`].
+#[derive(Resource)]
+pub struct GridPartitionMap<P, F = ()>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    partitions: HashMap<GridPartitionId, GridPartition<P>>,
+    reverse_map: HashMap<GridHash<P>, GridPartitionId, PassHash>,
+    next_partition: u64,
+    spooky: PhantomData<F>,
+}
+
+impl<P, F> Default for GridPartitionMap<P, F>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    fn default() -> Self {
+        Self {
+            partitions: HashMap::default(),
+            reverse_map: HashMap::default(),
+            next_partition: 0,
+            spooky: PhantomData,
+        }
+    }
+}
+
+impl<P, F> Deref for GridPartitionMap<P, F>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    type Target = HashMap<GridPartitionId, GridPartition<P>>;
+
+    fn deref(&self) -> &Self::Target {
+        &self.partitions
+    }
+}
+
+impl<P, F> GridPartitionMap<P, F>
+where
+    P: GridPrecision,
+    F: GridHashMapFilter,
+{
+    /// Returns a reference to the [`GridPartition`], if it exists.
+    #[inline]
+    pub fn resolve(&self, id: &GridPartitionId) -> Option<&GridPartition<P>> {
+        self.partitions.get(id)
+    }
+
+    /// Searches for the [`GridPartition`] that contains this `hash`, returning the partition's
+    /// [`GridPartitionId`] if the hash is found in any partition.
+    #[inline]
+    pub fn get(&self, hash: &GridHash<P>) -> Option<&GridPartitionId> {
+        self.reverse_map.get(hash)
+    }
+
+    /// Iterates over all [`GridPartition`]s.
+    #[inline]
+    pub fn iter(&self) -> impl Iterator<Item = (&GridPartitionId, &GridPartition<P>)> {
+        self.partitions.iter()
+    }
+
+    #[inline]
+    fn insert(&mut self, partition: GridPartitionId, set: HashSet<GridHash<P>, PassHash>) {
+        let Some(hash) = set.iter().next() else {
+            return;
+        };
+        for hash in set.iter() {
+            self.reverse_map.insert(*hash, partition);
+        }
+        self.partitions.insert(
+            partition,
+            GridPartition {
+                grid: hash.grid(),
+                tables: vec![set],
+            },
+        );
+    }
+
+    #[inline]
+    fn push(&mut self, partition: &GridPartitionId, hash: &GridHash<P>) {
+        if let Some(partition) = self.partitions.get_mut(partition) {
+            partition.insert(*hash)
+        } else {
+            return;
+        }
+        self.reverse_map.insert(*hash, *partition);
+    }
+
+    #[inline]
+    fn remove(&mut self, hash: &GridHash<P>) {
+        let Some(old_id) = self.reverse_map.remove(hash) else {
+            return;
+        };
+        if let Some(partition) = self.partitions.get_mut(&old_id) {
+            partition.tables.iter_mut().any(|table| table.remove(hash));
+        }
+    }
+
+    #[inline]
+    fn take_next_id(&mut self) -> GridPartitionId {
+        let id = GridPartitionId(self.next_partition);
+        self.next_partition += 1;
+        id
+    }
+
+    /// Merge the supplied set of partitions into a single partition.
+    fn merge(&mut self, partitions: &[GridPartitionId]) {
+        let Some(largest_partition) = partitions
+            .iter()
+            .filter_map(|id| {
+                self.resolve(id)
+                    .map(|partition| partition.num_cells())
+                    .zip(Some(id))
+            })
+            .reduce(|acc, elem| if elem.0 > acc.0 { elem } else { acc })
+            .map(|(_cells, id)| id)
+        else {
+            return;
+        };
+
+        for id in partitions.iter().filter(|p| *p != largest_partition) {
+            let Some(partition) = self.partitions.remove(id) else {
+                continue;
+            };
+
+            partition.iter().for_each(|hash| {
+                self.reverse_map.insert(*hash, *largest_partition);
+            });
+
+            self.partitions
+                .get_mut(largest_partition)
+                .expect("partition should exist")
+                .extend(partition);
+        }
+    }
+
+    fn update(
+        mut partition_map: ResMut<Self>,
+        mut timing: ResMut<crate::timing::GridHashStats>,
+        hash_grid: Res<GridHashMap<P, F>>,
+        // Scratch space allocations
+        mut added_neighbors: Local<Vec<GridPartitionId>>,
+        mut adjacent_to_removals: Local<HashMap<GridPartitionId, HashSet<GridHash<P>, PassHash>>>,
+        mut split_candidates: Local<Vec<(GridPartitionId, HashSet<GridHash<P>, PassHash>)>>,
+        mut split_results: Local<Vec<Vec<SplitResult<P>>>>,
+    ) {
+        let start = Instant::now();
+        for added_hash in hash_grid.just_inserted().iter() {
+            added_neighbors.clear();
+            added_neighbors.extend(
+                // This intentionally checks the partition map which is out of date, not the spatial
+                // hash map. Consider the case of a single entity moving through space, between
+                // cells. If we used the spatial hash map's `occupied_neighbors` on the added cell
+                // position, it would return no results, the old partition would be removed, and a
+                // new one created. As the entity moves through space, it is constantly reassigned a
+                // new partition.
+                //
+                // By using the partition map, we will be able to see the previously occupied cell
+                // before it is removed, merge with that partition, then remove it later.
+                added_hash
+                    .adjacent(1)
+                    .filter_map(|hash| partition_map.get(&hash)),
+            );
+
+            if let Some(first_partition) = added_neighbors.first() {
+                // When the added cell is surrounded by other cells with at least one partition, add
+                // the new cell to the first partition, then merge all adjacent partitions. Because
+                // the added cell is the center, any neighboring cells are now connected through
+                // this cell, thus their partitions are connected, and should be merged.
+                partition_map.push(first_partition, added_hash);
+                partition_map.merge(&added_neighbors);
+            } else {
+                let new_partition = partition_map.take_next_id();
+                partition_map.insert(new_partition, [*added_hash].into_iter().collect());
+            }
+        }
+
+        // Track the cells neighboring removed cells. These may now be disconnected from the rest of
+        // their partition.
+        for removed_cell in hash_grid.just_removed().iter() {
+            partition_map.remove(removed_cell);
+        }
+
+        // Clean up empty tables and partitions
+        partition_map.partitions.retain(|_id, partition| {
+            partition.tables.retain(|table| !table.is_empty());
+            !partition.tables.is_empty()
+        });
+
+        for removed_cell in hash_grid.just_removed().iter() {
+            // Group occupied neighbor cells by partition, so we can check if they are still
+            // connected to each other after this removal.
+            //
+            // Note that this will only add values that exist in the map, which has already had
+            // cells added and removed, and the partition, which has just been updated with added
+            // cells.
+            //
+            // Unfortunately, it doesn't seem possible to do any early-out optimizations based on
+            // the local neighborhood, because we don't have a full picture of the end state yet.
+            // This is why we need to gather all potentially affected cells, and check for partition
+            // splits once everything else has been added/removed.
+            //
+            // IMPORTANT: this is *intentionally* run in a second iterator after removing cells from
+            // the partitions. This ensures that when we check the partitions for affected cells, we
+            // aren't adding cells that were just removed but not yet processed.
+            removed_cell
+                .adjacent(1)
+                .filter(|hash| hash_grid.contains(hash))
+                .filter_map(|hash| partition_map.get(&hash).zip(Some(hash)))
+                .for_each(|(id, hash)| {
+                    adjacent_to_removals.entry(*id).or_default().insert(hash);
+                });
+        }
+
+        // Finally, we need to test for partitions being split apart by a removal (removing a bridge
+        // in graph theory).
+        *split_candidates = adjacent_to_removals.drain().collect::<Vec<_>>();
+        *split_results = split_candidates.par_splat_map_mut(
+            ComputeTaskPool::get(),
+            None,
+            |_, affected_cells| {
+                let _task_span = tracing::info_span!("parallel partition split").entered();
+                affected_cells
+                    .iter_mut()
+                    .filter_map(|(original_partition, adjacent_hashes)| {
+                        let mut new_partitions = Vec::with_capacity(0);
+                        let mut counter = 0;
+                        while let Some(this_cell) = adjacent_hashes.iter().next().copied() {
+                            for cell in hash_grid.flood(&this_cell, None) {
+                                // Note: first visited cell is this_cell
+                                adjacent_hashes.remove(&cell.0);
+                                if adjacent_hashes.is_empty() {
+                                    break;
+                                }
+                            }
+                            // At this point, we have either visited all affected cells, or the
+                            // flood fill ran out of cells to visit.
+                            if adjacent_hashes.is_empty() && counter == 0 {
+                                // If it only took a single iteration to connect all affected cells,
+                                // it means the partition has not been split, and we can continue to
+                                // the next // partition.
+                                return None;
+                            } else {
+                                new_partitions
+                                    .push(hash_grid.flood(&this_cell, None).map(|n| n.0).collect());
+                            }
+                            counter += 1;
+                        }
+
+                        Some(SplitResult {
+                            original_partition: *original_partition,
+                            new_partitions,
+                        })
+                    })
+                    .collect::<Vec<_>>()
+            },
+        );
+
+        for SplitResult {
+            original_partition,
+            ref mut new_partitions,
+        } in split_results.iter_mut().flatten()
+        {
+            // We want the original partition to retain the most cells to ensure that the smaller
+            // sets are the ones that are assigned a new partition ID.
+            new_partitions.sort_unstable_by_key(|v| v.len());
+            if let Some(partition) = new_partitions.pop() {
+                if let Some(tables) = partition_map
+                    .partitions
+                    .get_mut(original_partition)
+                    .map(|p| &mut p.tables)
+                {
+                    // TODO: keep these in an object pool to reuse allocs
+                    tables.drain(1..);
+                    if let Some(table) = tables.get_mut(0) {
+                        *table = partition;
+                    } else {
+                        tables.push(partition);
+                    }
+                }
+            }
+
+            // At this point the reverse map will be out of date. However, `partitions.insert()`
+            // will update all hashes that now have a new partition, with their new ID.
+            for partition_set in new_partitions.drain(..) {
+                let new_id = partition_map.take_next_id();
+                partition_map.insert(new_id, partition_set);
+            }
+        }
+        timing.update_partition += start.elapsed();
+    }
+}
+
+struct SplitResult<P: GridPrecision> {
+    original_partition: GridPartitionId,
+    new_partitions: Vec<HashSet<GridHash<P>, PassHash>>,
+}
+
+/// A group of nearby [`GridCell`](crate::GridCell)s in an island disconnected from all other
+/// [`GridCell`](crate::GridCell)s.
+#[derive(Debug)]
+pub struct GridPartition<P: GridPrecision> {
+    grid: Entity,
+    tables: Vec<HashSet<GridHash<P>, PassHash>>,
+}
+impl<P: GridPrecision> GridPartition<P> {
+    /// Tables smaller than this will be drained into other tables when merging. Tables larger than
+    /// this limit will instead be added to a list of tables. This prevents partitions ending up
+    /// with many tables containing a few entries.
+    ///
+    /// Draining and extending a hash set is much slower than moving the entire hash set into a
+    /// list. The tradeoff is that the more tables added, the more there are that need to be
+    /// iterated over when searching for a cell.
+    const MIN_TABLE_SIZE: usize = 128;
+
+    /// Returns `true` if the `hash` is in this partition.
+    #[inline]
+    pub fn contains(&self, hash: &GridHash<P>) -> bool {
+        self.tables.iter().any(|table| table.contains(hash))
+    }
+
+    /// Iterates over all [`GridHash`]s in this partition.
+    #[inline]
+    pub fn iter(&self) -> impl Iterator<Item = &GridHash<P>> {
+        self.tables.iter().flat_map(|table| table.iter())
+    }
+
+    /// Returns the total number of cells in this partition.
+    #[inline]
+    pub fn num_cells(&self) -> usize {
+        self.tables.iter().map(|t| t.len()).sum()
+    }
+
+    #[inline]
+    fn insert(&mut self, cell: GridHash<P>) {
+        if self.contains(&cell) {
+            return;
+        }
+        if let Some(i) = self.smallest_table() {
+            self.tables[i].insert(cell);
+        } else {
+            let mut table = HashSet::default();
+            table.insert(cell);
+            self.tables.push(table);
+        }
+    }
+
+    #[inline]
+    fn smallest_table(&self) -> Option<usize> {
+        self.tables
+            .iter()
+            .enumerate()
+            .map(|(i, t)| (i, t.len()))
+            .min_by_key(|(_, len)| *len)
+            .map(|(i, _len)| i)
+    }
+
+    #[inline]
+    fn extend(&mut self, mut partition: GridPartition<P>) {
+        for mut table in partition.tables.drain(..) {
+            if table.len() < Self::MIN_TABLE_SIZE {
+                if let Some(i) = self.smallest_table() {
+                    self.tables[i].extend(table.drain());
+                } else {
+                    self.tables.push(table);
+                }
+            } else {
+                self.tables.push(table);
+            }
+        }
+    }
+
+    /// The grid this partition resides in.
+    pub fn grid(&self) -> Entity {
+        self.grid
+    }
+}
diff --git a/src/lib.rs b/src/lib.rs
index 72f4ba5..bdf3ee0 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -1,12 +1,32 @@
-//! This `bevy` plugin makes it possible to build high-precision worlds that exceed the size of the
-//! observable universe, with no added dependencies, while remaining largely compatible with the
-//! rest of the Bevy ecosystem.
+#![doc(
+    html_logo_url = "https://raw.githubusercontent.com/aevyrie/big_space/refs/heads/main/assets/bigspace.svg"
+)]
+
+//! A floating origin plugin that uses integer grids to extend bevy's [`Transform`] component with
+//! up to 128 bits of added precision. The plugin propagates and computes [`GlobalTransform`]s
+//! relative to floating origins, making the most of 32 bit rendering precision by reducing error
+//! near the camera.
+//!
+//! <img src="https://raw.githubusercontent.com/aevyrie/big_space/refs/heads/main/assets/bigspacebanner.svg" style="padding:2% 15%">
+//!
+//! ## Quick Reference
+//!
+//! - [`BigSpace`] : The root of a high precision entity hierarchy.
+//! - [`FloatingOrigin`] : Position of the 32 bit rendering origin.
+//! - [`Grid`] : Defines the size of a grid for its child cells.
+//! - [`GridCell`] : Cell index of an entity within its parent's grid.
+//! - [`GridPrecision`] : Integer precision of a grid.
 //!
-//! The next section explains the problem this solves in more detail, how this plugin works, and a
-//! list of other solutions that were considered. If you'd like, you can instead skip ahead to
-//! [Usage](crate#usage).
+//! #### Spatial Hashing
 //!
-//! ### Problem
+//! - [`GridHash`] : The spatial hash of an entity's grid cell.
+//! - [`GridHashMap`] : A map for entity, grid cell, and neighbor lookups.
+//! - [`GridPartition`] : Group of adjacent grid cells.
+//! - [`GridPartitionMap`] : A map for finding independent partitions of entities.
+//!
+//! Jump to [Usage](crate#usage) to get started.
+//!
+//! ## Motivation
 //!
 //! Objects far from the origin suffer from reduced precision, causing rendered meshes to jitter and
 //! jiggle, and transformation calculations to encounter catastrophic cancellation.
@@ -29,7 +49,7 @@
 //!   - Problem: Recentering triggers change detection even for objects that did not move.
 //! - Camera-relative coordinates: don't move the camera, move the world around the camera.
 //!   - Problem: Objects far from the camera will drift and accumulate error.
-//!   - Problem: No fixed reference frame
+//!   - Problem: No fixed reference frame.
 //!   - Problem: Math is more complex when everything is relative to the camera.
 //!   - Problem: Rotating the camera requires recomputing transforms for everything.
 //!   - Problem: Camera movement triggers change detection even for objects that did not move.
@@ -45,7 +65,7 @@
 //!   - Problem: Requires a component to track the grid cell, in addition to the `Transform`.
 //!   - Problem: Computing the `GlobalTransform` is more expensive than single precision.
 //!
-//! ### Solution
+//! ### Integer Grid
 //!
 //! This plugin uses the last solution listed above. The most significant benefits of this method
 //! over the others are:
@@ -65,34 +85,36 @@
 //!   can exist in the high precision hierarchy. This allows you to load in GLTFs or other
 //!   low-precision entity hierarchies with no added effort or cost.
 //!
-//! While using the [`BigSpacePlugin`], the position of entities is now defined with the
-//! [`ReferenceFrame`], [`GridCell`], and [`Transform`] components. The `ReferenceFrame` is a large
-//! integer grid of cells; entities are located within this grid using the `GridCell` component.
-//! Finally, the `Transform` is used to position the entity relative to the center of its
-//! `GridCell`. If an entity moves into a neighboring cell, its transform will be automatically
-//! recomputed relative to the center of that new cell. This prevents `Transforms` from ever
-//! becoming larger than a single grid cell, and thus prevents floating point precision artifacts.
+//! While using the [`BigSpacePlugin`], the position of entities is now defined with the [`Grid`],
+//! [`GridCell`], and [`Transform`] components. The `Grid` is a large integer grid of cells;
+//! entities are located within this grid as children using the `GridCell` component. Finally, the
+//! `Transform` is used to position the entity relative to the center of its `GridCell`. If an
+//! entity moves into a neighboring cell, its transform will be automatically recomputed relative to
+//! the center of that new cell. This prevents `Transforms` from ever becoming larger than a single
+//! grid cell, and thus prevents floating point precision artifacts.
 //!
 //! The grid adds precision to your transforms. If you are using (32-bit) `Transform`s on an `i32`
 //! grid, you will have 64 bits of precision: 32 bits to address into a large integer grid, and 32
 //! bits of floating point precision within a grid cell. This plugin is generic up to `i128` grids,
-//! giving you up tp 160 bits of precision of translation.
-//!
-//! `ReferenceFrame`s - grids - can be nested. This allows you to define moving reference frames,
-//! which can make certain use cases much simpler. For example, if you have a planet rotating, and
-//! orbiting around its star, it would be very annoying if you had to compute this orbit and
-//! rotation for all objects on the surface in high precision. Instead, you can place the planet and
-//! all objects on its surface in the same reference frame. The motion of the planet will be
-//! inherited by all children in that reference frame, in high precision.
-//!
-//! Entities at the root of bevy's entity hierarchy are not in any reference frame. This allows
-//! plugins from the rest of the ecosystem to operate normally, such as bevy_ui, which relies on the
-//! built in transform propagation system. This also means that if you don't need to place entities
-//! in a high-precision reference frame, you don't have to, as the process is opt-in. The
-//! high-precision hierarchical reference frames are explicit. Each high-precision tree must have a
-//! [`BigSpaceRootBundle`] at the root, and each `BigSpace` is independent. This means that each
-//! `BigSpace` has its own floating origin, which allows you to do things like rendering two players
-//! on opposite ends of the universe simultaneously.
+//! giving you up to 160 bits of precision of translation.
+//!
+//! `Grid`s can be nested, like `Transform`s. This allows you to define moving grids, which can make
+//! certain use cases much simpler. For example, if you have a planet rotating, and orbiting around
+//! its star, it would be very annoying if you had to compute this orbit and rotation for all
+//! objects on the surface in high precision. Instead, you can place the planet and all objects on
+//! its surface in the same grid. The motion of the planet will be inherited by all children in that
+//! grid, in high precision.
+//!
+//! Entities at the root of bevy's entity hierarchy are not in a grid. This allows plugins from the
+//! rest of the ecosystem to operate normally, such as bevy_ui, which relies on the built in
+//! transform propagation system. This also means that if you don't need to place entities in a
+//! high-precision grid, you don't have to, as the process is opt-in. The high-precision
+//! hierarchical grids are explicit. Each high-precision tree must have a [`BigSpace`] at the root,
+//! with each `BigSpace` being independent. This means that each `BigSpace` has its own floating
+//! origin, which allows you to do things like rendering two players on opposite ends of the
+//! universe in split screen.
+//!
+//! ### Floating Origin
 //!
 //! All of the above applies to the entity marked with the [`FloatingOrigin`] component. The
 //! floating origin can be any high-precision entity in a `BigSpace`, it doesn't need to be a
@@ -110,28 +132,25 @@
 //! only affects the `GlobalTransform` and not the `Transform`, this also means that entities will
 //! never permanently lose precision just because they were far from the origin at some point. The
 //! lossy calculation only occurs when computing the `GlobalTransform` of entities, the high
-//! precision `GridCell` and `Transform` are never touched.
+//! precision `GridCell` and `Transform` are not affected.
 //!
 //! # Usage
 //!
 //! To start using this plugin, you will first need to choose how big your world should be! Do you
-//! need an i8, or an i128? See [`GridPrecision`](crate::precision::GridPrecision) for more details
-//! and documentation.
+//! need an i8, or an i128? See [`GridPrecision`] for more details and documentation.
 //!
-//! 1. Disable Bevy's transform plugin: `DefaultPlugins.build().disable::<TransformPlugin>()`
-//! 2. Add the [`BigSpacePlugin`] to your `App`
-//! 3. Spawn a [`BigSpace`] with [`spawn_big_space`](BigSpaceCommands::spawn_big_space), and spawn
-//!    entities in it.
-//! 4. Add the [`FloatingOrigin`] to your active camera in the [`BigSpace`].
+//! 1. Add the [`BigSpacePlugin`] to your `App`
+//! 2. Spawn a [`BigSpace`] with [`spawn_big_space`](BigSpaceCommands::spawn_big_space), and add
+//!    entities to it.
+//! 3. Add the [`FloatingOrigin`] to your active camera in the [`BigSpace`].
 //!
-//! To add more levels to the hierarchy, you can use [`ReferenceFrame`]s, which themselves can
-//! contain high-precision spatial entities. Reference frames are useful when you want all objects
-//! to move together in space, for example, objects on the surface of a planet rotating on its axis
-//! and orbiting a star.
+//! To add more levels to the hierarchy, you can use [`Grid`]s, which themselves can contain
+//! high-precision spatial entities. Grids have the same propagation behavior as `Transform`s, but
+//! with higher precision.
 //!
-//! Take a look at the [`ReferenceFrame`] component for some useful helper methods. The component
-//! defines the scale of the grid, which is very important when computing distances between objects
-//! in different cells. Note that the root [`BigSpace`] also has a [`ReferenceFrame`] component.
+//! Take a look at the [`Grid`] component for some useful helper methods. The component defines the
+//! scale of the grid, which is very important when computing distances between objects in different
+//! cells. Note that the root [`BigSpace`] also has a [`Grid`] component.
 //!
 //! # Moving Entities
 //!
@@ -170,7 +189,7 @@
 //! However, if you have something that must not accumulate error, like the orbit of a planet, you
 //! can instead do the orbital calculation (position as a function of time) to compute the absolute
 //! position of the planet with high precision, then directly compute the [`GridCell`] and
-//! [`Transform`] of that entity using [`ReferenceFrame::translation_to_grid`].
+//! [`Transform`] of that entity using [`Grid::translation_to_grid`].
 //!
 //! # Next Steps
 //!
@@ -179,17 +198,19 @@
 #![allow(clippy::type_complexity)]
 #![warn(missing_docs)]
 
-use bevy_ecs::prelude::*;
-use bevy_hierarchy::prelude::*;
+#[allow(unused_imports)] // For docs
 use bevy_transform::prelude::*;
+#[allow(unused_imports)] // For docs
+use prelude::*;
 
 pub mod bundles;
 pub mod commands;
 pub mod floating_origins;
-pub mod grid_cell;
+pub mod grid;
+pub mod hash;
 pub mod plugin;
 pub mod precision;
-pub mod reference_frame;
+pub mod timing;
 pub mod validation;
 pub mod world_query;
 
@@ -200,9 +221,26 @@ pub mod debug;
 #[cfg(test)]
 mod tests;
 
-pub use bundles::{BigReferenceFrameBundle, BigSpaceRootBundle, BigSpatialBundle};
-pub use commands::{BigSpaceCommands, ReferenceFrameCommands, SpatialEntityCommands};
-pub use floating_origins::{BigSpace, FloatingOrigin};
-pub use grid_cell::GridCell;
-pub use plugin::{BigSpacePlugin, FloatingOriginSet};
-pub use reference_frame::ReferenceFrame;
+/// Common big_space imports.
+pub mod prelude {
+    use crate::*;
+    pub use bundles::{BigGridBundle, BigSpaceRootBundle, BigSpatialBundle};
+    pub use commands::{BigSpaceCommands, GridCommands, SpatialEntityCommands};
+    #[cfg(feature = "debug")]
+    pub use debug::FloatingOriginDebugPlugin;
+    pub use floating_origins::{BigSpace, FloatingOrigin};
+    pub use grid::{
+        cell::{GridCell, GridCellAny},
+        local_origin::{Grids, GridsMut, LocalFloatingOrigin},
+        Grid,
+    };
+    pub use hash::{
+        component::{FastGridHash, GridHash},
+        map::{GridHashMap, SpatialEntryToEntities},
+        partition::{GridPartition, GridPartitionId, GridPartitionMap, GridPartitionPlugin},
+        GridHashMapSystem, GridHashPlugin,
+    };
+    pub use plugin::{BigSpacePlugin, FloatingOriginSystem};
+    pub use precision::GridPrecision;
+    pub use world_query::{GridTransform, GridTransformOwned, GridTransformReadOnly};
+}
diff --git a/src/plugin.rs b/src/plugin.rs
index 1eaf5b1..de4ebb4 100644
--- a/src/plugin.rs
+++ b/src/plugin.rs
@@ -1,17 +1,12 @@
 //! The bevy plugin for big_space.
 
+use crate::prelude::*;
 use bevy_app::prelude::*;
 use bevy_ecs::prelude::*;
-use bevy_reflect::{prelude::*, GetTypeRegistration};
-use bevy_transform::{prelude::*, TransformSystem};
+use bevy_reflect::prelude::*;
+use bevy_transform::prelude::*;
 use std::marker::PhantomData;
 
-use crate::{
-    precision::GridPrecision,
-    reference_frame::{local_origin::LocalFloatingOrigin, ReferenceFrame},
-    validation, BigSpace, FloatingOrigin, GridCell,
-};
-
 /// Add this plugin to your [`App`] for floating origin functionality.
 pub struct BigSpacePlugin<P: GridPrecision> {
     phantom: PhantomData<P>,
@@ -30,64 +25,73 @@ impl<P: GridPrecision> BigSpacePlugin<P> {
 
 impl<P: GridPrecision> Default for BigSpacePlugin<P> {
     fn default() -> Self {
-        #[cfg(debug_assertions)]
-        let validate_hierarchies = true;
-
-        #[cfg(not(debug_assertions))]
-        let validate_hierarchies = false;
-
         Self {
-            phantom: Default::default(),
-            validate_hierarchies,
+            phantom: PhantomData,
+            validate_hierarchies: cfg!(debug_assertions),
         }
     }
 }
 
 #[allow(missing_docs)]
 #[derive(Debug, Hash, PartialEq, Eq, Clone, SystemSet)]
-pub enum FloatingOriginSet {
+pub enum FloatingOriginSystem {
+    Init,
     RecenterLargeTransforms,
     LocalFloatingOrigins,
     PropagateHighPrecision,
     PropagateLowPrecision,
 }
 
-impl<P: GridPrecision + Reflect + FromReflect + TypePath + GetTypeRegistration> Plugin
+impl<P: GridPrecision + Reflect + FromReflect + TypePath + bevy_reflect::GetTypeRegistration> Plugin
     for BigSpacePlugin<P>
 {
     fn build(&self, app: &mut App) {
+        // Silence bevy's built-in error spam about GlobalTransforms in the hierarchy
+        app.insert_resource(bevy_hierarchy::ReportHierarchyIssue::<GlobalTransform>::new(false));
+
+        // Performance timings
+        app.add_plugins(crate::timing::TimingStatsPlugin);
+
         let system_set_config = || {
             (
+                Grid::<P>::tag_low_precision_roots // loose ordering on this set
+                    .after(FloatingOriginSystem::Init)
+                    .before(FloatingOriginSystem::PropagateLowPrecision),
                 (
                     GridCell::<P>::recenter_large_transforms,
                     BigSpace::find_floating_origin,
                 )
-                    .in_set(FloatingOriginSet::RecenterLargeTransforms),
+                    .in_set(FloatingOriginSystem::RecenterLargeTransforms),
                 LocalFloatingOrigin::<P>::compute_all
-                    .in_set(FloatingOriginSet::LocalFloatingOrigins)
-                    .after(FloatingOriginSet::RecenterLargeTransforms),
-                ReferenceFrame::<P>::propagate_high_precision
-                    .in_set(FloatingOriginSet::PropagateHighPrecision)
-                    .after(FloatingOriginSet::LocalFloatingOrigins),
-                ReferenceFrame::<P>::propagate_low_precision
-                    .in_set(FloatingOriginSet::PropagateLowPrecision)
-                    .after(FloatingOriginSet::PropagateHighPrecision),
+                    .in_set(FloatingOriginSystem::LocalFloatingOrigins)
+                    .after(FloatingOriginSystem::RecenterLargeTransforms),
+                Grid::<P>::propagate_high_precision
+                    .in_set(FloatingOriginSystem::PropagateHighPrecision)
+                    .after(FloatingOriginSystem::LocalFloatingOrigins),
+                Grid::<P>::propagate_low_precision
+                    .in_set(FloatingOriginSystem::PropagateLowPrecision)
+                    .after(FloatingOriginSystem::PropagateHighPrecision),
             )
                 .in_set(TransformSystem::TransformPropagate)
         };
 
-        app.register_type::<Transform>()
+        app
+            // Reflect
+            .register_type::<Transform>()
             .register_type::<GlobalTransform>()
             .register_type::<GridCell<P>>()
-            .register_type::<ReferenceFrame<P>>()
+            .register_type::<GridCellAny>()
+            .register_type::<Grid<P>>()
             .register_type::<BigSpace>()
             .register_type::<FloatingOrigin>()
+            // Meat of the plugin, once on startup, as well as every update
             .add_systems(PostStartup, system_set_config())
             .add_systems(PostUpdate, system_set_config())
+            // Validation
             .add_systems(
                 PostUpdate,
-                validation::validate_hierarchy::<validation::SpatialHierarchyRoot<P>>
-                    .before(TransformSystem::TransformPropagate)
+                crate::validation::validate_hierarchy::<crate::validation::SpatialHierarchyRoot<P>>
+                    .after(TransformSystem::TransformPropagate)
                     .run_if({
                         let run = self.validate_hierarchies;
                         move || run
diff --git a/src/precision.rs b/src/precision.rs
index fa95cae..02d7df6 100644
--- a/src/precision.rs
+++ b/src/precision.rs
@@ -1,8 +1,11 @@
 //! Contains the [`GridPrecision`] trait and its implementations.
 
-use std::{hash::Hash, ops::Add};
+use std::{
+    hash::Hash,
+    ops::{Add, Mul},
+};
 
-use bevy_reflect::{Reflect, Typed};
+use bevy_reflect::{FromReflect, GetTypeRegistration, Reflect, TypePath, Typed};
 
 /// Used to make the floating origin plugin generic over many grid sizes.
 ///
@@ -23,7 +26,7 @@ use bevy_reflect::{Reflect, Typed};
 /// precision of 0.5mm in any of these cases.
 ///
 /// This can also be used for small scales. With a cell edge length of `1e-11`, and using `i128`,
-/// there is enough precision to render objects the size of quarks anywhere in the observable
+/// there is enough precision to render objects the size of protons anywhere in the observable
 /// universe.
 ///
 /// # Note
@@ -32,7 +35,7 @@ use bevy_reflect::{Reflect, Typed};
 /// define a type alias!
 ///
 /// ```
-/// # use big_space::GridCell;
+/// # use big_space::prelude::*;
 /// type GalacticGrid = GridCell<i64>;
 /// ```
 ///
@@ -50,8 +53,13 @@ pub trait GridPrecision:
     + Send
     + Sync
     + Reflect
+    + FromReflect
+    + GetTypeRegistration
+    + TypePath
     + Typed
     + Add
+    + Add<Self, Output = Self>
+    + Mul<Self, Output = Self>
     + std::fmt::Debug
     + std::fmt::Display
     + 'static
@@ -62,6 +70,8 @@ pub trait GridPrecision:
     const ONE: Self;
     /// Adds `rhs` to `self`, wrapping when overflow would occur.
     fn wrapping_add(self, rhs: Self) -> Self;
+    /// Adds `rhs` to `self`, wrapping when overflow would occur.
+    fn wrapping_add_i32(self, rhs: i32) -> Self;
     /// Subtracts `rhs` from `self`, wrapping when overflow would occur.
     fn wrapping_sub(self, rhs: Self) -> Self;
     /// Multiplies `self` by `rhs`.
@@ -83,6 +93,10 @@ impl GridPrecision for i8 {
         Self::wrapping_add(self, rhs)
     }
     #[inline]
+    fn wrapping_add_i32(self, rhs: i32) -> Self {
+        Self::wrapping_add(self, rhs as Self)
+    }
+    #[inline]
     fn wrapping_sub(self, rhs: Self) -> Self {
         Self::wrapping_sub(self, rhs)
     }
@@ -113,6 +127,10 @@ impl GridPrecision for i16 {
         Self::wrapping_add(self, rhs)
     }
     #[inline]
+    fn wrapping_add_i32(self, rhs: i32) -> Self {
+        Self::wrapping_add(self, rhs as Self)
+    }
+    #[inline]
     fn wrapping_sub(self, rhs: Self) -> Self {
         Self::wrapping_sub(self, rhs)
     }
@@ -143,6 +161,10 @@ impl GridPrecision for i32 {
         Self::wrapping_add(self, rhs)
     }
     #[inline]
+    fn wrapping_add_i32(self, rhs: i32) -> Self {
+        Self::wrapping_add(self, rhs as Self)
+    }
+    #[inline]
     fn wrapping_sub(self, rhs: Self) -> Self {
         Self::wrapping_sub(self, rhs)
     }
@@ -173,6 +195,10 @@ impl GridPrecision for i64 {
         Self::wrapping_add(self, rhs)
     }
     #[inline]
+    fn wrapping_add_i32(self, rhs: i32) -> Self {
+        Self::wrapping_add(self, rhs as Self)
+    }
+    #[inline]
     fn wrapping_sub(self, rhs: Self) -> Self {
         Self::wrapping_sub(self, rhs)
     }
@@ -203,6 +229,10 @@ impl GridPrecision for i128 {
         Self::wrapping_add(self, rhs)
     }
     #[inline]
+    fn wrapping_add_i32(self, rhs: i32) -> Self {
+        Self::wrapping_add(self, rhs as Self)
+    }
+    #[inline]
     fn wrapping_sub(self, rhs: Self) -> Self {
         Self::wrapping_sub(self, rhs)
     }
diff --git a/src/reference_frame/local_origin.rs b/src/reference_frame/local_origin.rs
deleted file mode 100644
index 3ef9df1..0000000
--- a/src/reference_frame/local_origin.rs
+++ /dev/null
@@ -1,725 +0,0 @@
-//! Describes how the floating origin's position is propagated through the hierarchy of reference
-//! frames, and used to compute the floating origin's position relative to each reference frame. See
-//! [`LocalFloatingOrigin`].
-
-use bevy_ecs::{
-    prelude::*,
-    system::{
-        lifetimeless::{Read, Write},
-        SystemParam,
-    },
-};
-use bevy_hierarchy::prelude::*;
-use bevy_math::{prelude::*, DAffine3, DQuat};
-use bevy_transform::prelude::*;
-use tracing::error;
-
-pub use inner::LocalFloatingOrigin;
-
-use crate::{precision::GridPrecision, BigSpace, GridCell};
-
-use super::ReferenceFrame;
-
-/// A module kept private to enforce use of setters and getters within the parent module.
-mod inner {
-    use bevy_math::{prelude::*, DAffine3, DMat3, DQuat};
-    use bevy_reflect::prelude::*;
-
-    use crate::{precision::GridPrecision, GridCell};
-
-    /// An isometry that describes the location of the floating origin's grid cell's origin, in the
-    /// local reference frame.
-    ///
-    /// Used to compute the [`GlobalTransform`](bevy_transform::components::GlobalTransform) of
-    /// every entity within a reference frame. Because this tells us where the floating origin cell
-    /// is located in the local frame, we can compute the inverse transform once, then use it to
-    /// transform every entity relative to the floating origin.
-    ///
-    /// If the floating origin is in this local reference frame, the `float` fields will be
-    /// identity. The `float` fields will be non-identity when the floating origin is in a different
-    /// reference frame that does not perfectly align with this one. Different reference frames can
-    /// be rotated and offset from each other - consider the reference frame of a planet, spinning
-    /// about its axis and orbiting about a star, it will not align with the reference frame of the
-    /// star system!
-    #[derive(Default, Debug, Clone, PartialEq, Reflect)]
-    pub struct LocalFloatingOrigin<P: GridPrecision> {
-        /// The local cell that the floating origin's grid cell origin falls into.
-        cell: GridCell<P>,
-        /// The translation of floating origin's grid cell relative to the origin of
-        /// [`LocalFloatingOrigin::cell`].
-        translation: Vec3,
-        /// The rotation of the floating origin's grid cell relative to the origin of
-        /// [`LocalFloatingOrigin::cell`].
-        rotation: DQuat,
-        /// Transform from the local reference frame to the floating origin's grid cell. This is
-        /// used to compute the `GlobalTransform` of all entities in this reference frame.
-        ///
-        /// Imagine you have the local reference frame and the floating origin's reference frame
-        /// overlapping in space, misaligned. This transform is the smallest possible that will
-        /// align the two reference frame grids, going from the local frame, to the floating
-        /// origin's frame.
-        ///
-        /// This is like a camera's "view transform", but instead of transforming an object into a
-        /// camera's view space, this will transform an object into the floating origin's reference
-        /// frame.
-        ///   - That object must be positioned in the same [`super::ReferenceFrame`] that this
-        ///     [`LocalFloatingOrigin`] is part of.
-        ///   - That object's position must be relative to the same grid cell as defined by
-        ///     [`Self::cell`].
-        ///
-        /// The above requirements help to ensure this transform has a small magnitude, maximizing
-        /// precision, and minimizing floating point error.
-        reference_frame_transform: DAffine3,
-    }
-
-    impl<P: GridPrecision> LocalFloatingOrigin<P> {
-        /// The reference frame transform from the local reference frame, to the floating origin's
-        /// reference frame. See [Self::reference_frame_transform].
-        pub fn reference_frame_transform(&self) -> DAffine3 {
-            self.reference_frame_transform
-        }
-
-        /// Gets [`Self::cell`].
-        pub fn cell(&self) -> GridCell<P> {
-            self.cell
-        }
-
-        /// Gets [`Self::translation`].
-        pub fn translation(&self) -> Vec3 {
-            self.translation
-        }
-
-        /// Gets [`Self::rotation`].
-        pub fn rotation(&self) -> DQuat {
-            self.rotation
-        }
-
-        /// Update this local floating origin, and compute the new inverse transform.
-        pub fn set(
-            &mut self,
-            translation_grid: GridCell<P>,
-            translation_float: Vec3,
-            rotation_float: DQuat,
-        ) {
-            self.cell = translation_grid;
-            self.translation = translation_float;
-            self.rotation = rotation_float;
-
-            self.reference_frame_transform = DAffine3 {
-                matrix3: DMat3::from_quat(self.rotation),
-                translation: self.translation.as_dvec3(),
-            }
-            .inverse()
-        }
-
-        /// Create a new [`LocalFloatingOrigin`].
-        pub fn new(cell: GridCell<P>, translation: Vec3, rotation: DQuat) -> Self {
-            let reference_frame_transform = DAffine3 {
-                matrix3: DMat3::from_quat(rotation),
-                translation: translation.as_dvec3(),
-            }
-            .inverse();
-
-            Self {
-                cell,
-                translation,
-                rotation,
-                reference_frame_transform,
-            }
-        }
-    }
-}
-
-fn propagate_origin_to_parent<P: GridPrecision>(
-    this_frame_entity: Entity,
-    reference_frames: &mut ReferenceFramesMut<P>,
-    parent_frame_entity: Entity,
-) {
-    let (this_frame, this_cell, this_transform) = reference_frames.get(this_frame_entity);
-    let (parent_frame, _parent_cell, _parent_transform) = reference_frames.get(parent_frame_entity);
-
-    // Get this frame's double precision transform, relative to its cell. We ignore the grid
-    // cell here because we don't want to lose precision - we can do these calcs relative to
-    // this cell, then add the grid cell offset at the end.
-    let this_transform = DAffine3::from_rotation_translation(
-        this_transform.rotation.as_dquat(),
-        this_transform.translation.as_dvec3(),
-    );
-
-    // Get the origin's double position in this reference frame
-    let origin_translation = this_frame.grid_position_double(
-        &this_frame.local_floating_origin.cell(),
-        &Transform::from_translation(this_frame.local_floating_origin.translation()),
-    );
-    let this_local_origin_transform = DAffine3::from_rotation_translation(
-        this_frame.local_floating_origin.rotation(),
-        origin_translation,
-    );
-
-    // Multiply to move the origin into the parent's reference frame
-    let origin_affine = this_transform * this_local_origin_transform;
-
-    let (_, origin_rot, origin_trans) = origin_affine.to_scale_rotation_translation();
-    let (origin_cell_relative_to_this_cell, origin_translation_remainder) =
-        parent_frame.translation_to_grid(origin_trans);
-
-    // Up until now we have been computing as if this cell is located at the origin, to maximize
-    // precision. Now that we are done with floats, we can add the cell offset.
-    let parent_origin_cell = origin_cell_relative_to_this_cell + this_cell;
-
-    reference_frames.update_reference_frame(parent_frame_entity, |parent_frame, _, _| {
-        parent_frame.local_floating_origin.set(
-            parent_origin_cell,
-            origin_translation_remainder,
-            origin_rot,
-        );
-    });
-}
-
-fn propagate_origin_to_child<P: GridPrecision>(
-    this_frame_entity: Entity,
-    reference_frames: &mut ReferenceFramesMut<P>,
-    child_frame_entity: Entity,
-) {
-    let (this_frame, _this_cell, _this_transform) = reference_frames.get(this_frame_entity);
-    let (child_frame, child_cell, child_transform) = reference_frames.get(child_frame_entity);
-
-    // compute double precision translation of origin treating child as the origin grid cell. Add this to the origin's float translation in double,
-    let origin_cell_relative_to_child = this_frame.local_floating_origin.cell() - child_cell;
-    let origin_translation = this_frame.grid_position_double(
-        &origin_cell_relative_to_child,
-        &Transform::from_translation(this_frame.local_floating_origin.translation()),
-    );
-
-    // then combine with rotation to get a double transform from the child's cell origin to the origin.
-    let origin_rotation = this_frame.local_floating_origin.rotation();
-    let origin_transform_child_cell_local =
-        DAffine3::from_rotation_translation(origin_rotation, origin_translation);
-
-    // Take the inverse of the child's transform as double (this is the "view" transform of the child reference frame)
-    let child_view_child_cell_local = DAffine3::from_rotation_translation(
-        child_transform.rotation.as_dquat(),
-        child_transform.translation.as_dvec3(),
-    )
-    .inverse();
-
-    // then multiply this by the double transform we got of the origin. This is now a transform64 of the origin, wrt to the child.
-    let origin_child_affine = child_view_child_cell_local * origin_transform_child_cell_local;
-
-    //  We can decompose into translation (high precision) and rotation.
-    let (_, origin_child_rotation, origin_child_translation) =
-        origin_child_affine.to_scale_rotation_translation();
-    let (child_origin_cell, child_origin_translation_float) =
-        child_frame.translation_to_grid(origin_child_translation);
-
-    reference_frames.update_reference_frame(child_frame_entity, |child_frame, _, _| {
-        child_frame.local_floating_origin.set(
-            child_origin_cell,
-            child_origin_translation_float,
-            origin_child_rotation,
-        );
-    })
-}
-
-/// A system param for more easily navigating a hierarchy of reference frames.
-#[derive(SystemParam)]
-pub struct ReferenceFrames<'w, 's, P: GridPrecision> {
-    parent: Query<'w, 's, Read<Parent>>,
-    children: Query<'w, 's, Read<Children>>,
-    // position: Query<'w, 's, (Read<GridCell<P>>, Read<Transform>), With<ReferenceFrame<P>>>,
-    frame_query: Query<'w, 's, (Entity, Read<ReferenceFrame<P>>, Option<Read<Parent>>)>,
-}
-
-impl<P: GridPrecision> ReferenceFrames<'_, '_, P> {
-    /// Get a [`ReferenceFrame`] from its `Entity`.
-    pub fn get(&self, frame_entity: Entity) -> &ReferenceFrame<P> {
-        self.frame_query
-            .get(frame_entity)
-            .map(|(_entity, frame, _parent)| frame)
-            .unwrap_or_else(|e| {
-                panic!("Reference frame entity missing ReferenceFrame component.\n\tError: {e}");
-            })
-    }
-
-    /// Get the [`ReferenceFrame`] that `this` `Entity` is a child of, if it exists.
-    pub fn parent_frame(&self, this: Entity) -> Option<&ReferenceFrame<P>> {
-        self.parent_frame_entity(this)
-            .map(|frame_entity| self.get(frame_entity))
-    }
-
-    /// Get the ID of the reference frame that `this` `Entity` is a child of, if it exists.
-    #[inline]
-    pub fn parent_frame_entity(&self, this: Entity) -> Option<Entity> {
-        match self.parent.get(this).map(|parent| **parent) {
-            Err(_) => None,
-            Ok(parent) => match self.frame_query.contains(parent) {
-                true => Some(parent),
-                false => None,
-            },
-        }
-    }
-
-    /// Get handles to all reference frames that are children of this reference frame. Applies a
-    /// filter to the returned children.
-    fn child_frames_filtered(
-        &mut self,
-        this: Entity,
-        mut filter: impl FnMut(Entity) -> bool,
-    ) -> Vec<Entity> {
-        self.children
-            .get(this)
-            .iter()
-            .flat_map(|c| c.iter())
-            .filter(|entity| filter(**entity))
-            .filter(|child| self.frame_query.contains(**child))
-            .copied()
-            .collect()
-    }
-
-    /// Get IDs to all reference frames that are children of this reference frame.
-    pub fn child_frames(&mut self, this: Entity) -> Vec<Entity> {
-        self.child_frames_filtered(this, |_| true)
-    }
-
-    /// Get IDs to all reference frames that are siblings of this reference frame.
-    pub fn sibling_frames(&mut self, this_entity: Entity) -> Vec<Entity> {
-        if let Some(parent) = self.parent_frame_entity(this_entity) {
-            self.child_frames_filtered(parent, |e| e != this_entity)
-        } else {
-            Vec::new()
-        }
-    }
-}
-
-/// Used to access a reference frame. Needed because the reference frame could either be a
-/// component, or a resource if at the root of the hierarchy.
-#[derive(SystemParam)]
-pub struct ReferenceFramesMut<'w, 's, P: GridPrecision> {
-    parent: Query<'w, 's, Read<Parent>>,
-    children: Query<'w, 's, Read<Children>>,
-    position: Query<'w, 's, (Read<GridCell<P>>, Read<Transform>)>,
-    frame_query: Query<'w, 's, (Entity, Write<ReferenceFrame<P>>, Option<Read<Parent>>)>,
-}
-
-impl<P: GridPrecision> ReferenceFramesMut<'_, '_, P> {
-    /// Get mutable access to the [`ReferenceFrame`], and run the provided function or closure,
-    /// optionally returning data.
-    ///
-    /// ## Panics
-    ///
-    /// This will panic if the entity passed in is invalid.
-    ///
-    /// ## Why a closure?
-    ///
-    /// This expects a closure because the reference frame could be stored as a component or a
-    /// resource, making it difficult (impossible?) to return a mutable reference to the reference
-    /// frame when the types involved are different. The main issue seems to be that the component
-    /// is returned as a `Mut<T>`; getting a mutable reference to the internal value requires that
-    /// this function return a reference to a value owned by the function.
-    ///
-    /// I tried returning an enum or a boxed trait object, but ran into issues expressing the
-    /// lifetimes. Worth revisiting if this turns out to be annoying, but seems pretty insignificant
-    /// at the time of writing.
-    pub fn update_reference_frame<T>(
-        &mut self,
-        frame_entity: Entity,
-        mut func: impl FnMut(&mut ReferenceFrame<P>, &GridCell<P>, &Transform) -> T,
-    ) -> T {
-        let (cell, transform) = self.position(frame_entity);
-        self.frame_query
-            .get_mut(frame_entity)
-            .map(|(_entity, mut frame, _parent)| func(frame.as_mut(), &cell, &transform))
-            .expect("The supplied reference frame handle to node is no longer valid.")
-    }
-
-    /// Get the reference frame and the position of the reference frame from its `Entity`.
-    pub fn get(&self, frame_entity: Entity) -> (&ReferenceFrame<P>, GridCell<P>, Transform) {
-        let (cell, transform) = self.position(frame_entity);
-        self.frame_query
-            .get(frame_entity)
-            .map(|(_entity, frame, _parent)| (frame, cell, transform))
-            .unwrap_or_else(|e| {
-                panic!("Reference frame entity {frame_entity:?} missing ReferenceFrame component.\n\tError: {e}");
-            })
-    }
-
-    /// Get the position of this reference frame, including its grid cell and transform, or return
-    /// defaults if they are missing.
-    ///
-    /// Needed because the root reference frame should not have a grid cell or transform.
-    pub fn position(&self, frame_entity: Entity) -> (GridCell<P>, Transform) {
-        let (cell, transform) = (GridCell::default(), Transform::default());
-        let (cell, transform) = self.position.get(frame_entity).unwrap_or_else(|_| {
-        assert!(self.parent.get(frame_entity).is_err(), "Reference frame entity {frame_entity:?} is missing a GridCell and Transform. This is valid only if this is a root reference frame, but this is not.");
-            (&cell, &transform)
-        });
-        (*cell, *transform)
-    }
-
-    /// Get the ID of the reference frame that `this` `Entity` is a child of, if it exists.
-    #[inline]
-    pub fn parent_frame(&self, this: Entity) -> Option<Entity> {
-        match self.parent.get(this).map(|parent| **parent) {
-            Err(_) => None,
-            Ok(parent) => match self.frame_query.contains(parent) {
-                true => Some(parent),
-                false => None,
-            },
-        }
-    }
-
-    /// Get handles to all reference frames that are children of this reference frame. Applies a
-    /// filter to the returned children.
-    fn child_frames_filtered(
-        &mut self,
-        this: Entity,
-        mut filter: impl FnMut(Entity) -> bool,
-    ) -> Vec<Entity> {
-        self.children
-            .get(this)
-            .iter()
-            .flat_map(|c| c.iter())
-            .filter(|entity| filter(**entity))
-            .filter(|child| self.frame_query.contains(**child))
-            .copied()
-            .collect()
-    }
-
-    /// Get IDs to all reference frames that are children of this reference frame.
-    pub fn child_frames(&mut self, this: Entity) -> Vec<Entity> {
-        self.child_frames_filtered(this, |_| true)
-    }
-
-    /// Get IDs to all reference frames that are siblings of this reference frame.
-    pub fn sibling_frames(&mut self, this_entity: Entity) -> Vec<Entity> {
-        if let Some(parent) = self.parent_frame(this_entity) {
-            self.child_frames_filtered(parent, |e| e != this_entity)
-        } else {
-            Vec::new()
-        }
-    }
-}
-
-impl<P: GridPrecision> LocalFloatingOrigin<P> {
-    /// Update the [`LocalFloatingOrigin`] of every [`ReferenceFrame`] in the world. This does not
-    /// update any entity transforms, instead this is a preceding step that updates every reference
-    /// frame, so it knows where the floating origin is located with respect to that reference
-    /// frame. This is all done in high precision if possible, however any loss in precision will
-    /// only affect the rendering precision. The high precision coordinates ([`GridCell`] and
-    /// [`Transform`]) are the source of truth and never mutated.
-    pub fn compute_all(
-        mut reference_frames: ReferenceFramesMut<P>,
-        mut frame_stack: Local<Vec<Entity>>,
-        cells: Query<(Entity, &GridCell<P>)>,
-        roots: Query<(Entity, &BigSpace)>,
-        parents: Query<&Parent>,
-    ) {
-        /// The maximum reference frame tree depth, defensively prevents infinite looping in case
-        /// there is a degenerate hierarchy. It might take a while, but at least it's not forever?
-        const MAX_REFERENCE_FRAME_DEPTH: usize = 255;
-
-        // TODO: because each tree under a root is disjoint, these updates can be done in parallel
-        // without aliasing. This will require unsafe, just like bevy's own transform propagation.
-        'outer: for (origin_entity, origin_cell) in roots
-            .iter() // TODO: If any of these checks fail, log to some diagnostic
-            .filter_map(|(root_entity, root)| root.validate_floating_origin(root_entity, &parents))
-            .filter_map(|origin| cells.get(origin).ok())
-        {
-            let Some(mut this_frame) = reference_frames.parent_frame(origin_entity) else {
-                error!("The floating origin is not in a valid reference frame. The floating origin entity must be a child of an entity with the `ReferenceFrame` component.");
-                continue;
-            };
-
-            // Prepare by resetting the `origin_transform` of the floating origin's reference frame.
-            // Because the floating origin is within this reference frame, there is no grid
-            // misalignment and thus no need for any floating offsets.
-            reference_frames.update_reference_frame(this_frame, |frame, _, _| {
-                frame
-                    .local_floating_origin
-                    .set(*origin_cell, Vec3::ZERO, DQuat::IDENTITY);
-            });
-
-            // Seed the frame stack with the floating origin's reference frame. From this point out,
-            // we will only look at siblings and parents, which will allow us to visit the entire
-            // tree.
-            frame_stack.clear();
-            frame_stack.push(this_frame);
-
-            // Recurse up and across the tree, updating siblings and their children.
-            for _ in 0..MAX_REFERENCE_FRAME_DEPTH {
-                // We start by propagating up to the parent of this frame, then propagating down to
-                // the siblings of this frame (children of the parent that are not this frame).
-                if let Some(parent_frame) = reference_frames.parent_frame(this_frame) {
-                    propagate_origin_to_parent(this_frame, &mut reference_frames, parent_frame);
-                    for sibling_frame in reference_frames.sibling_frames(this_frame) {
-                        // The siblings of this frame are also the children of the parent frame.
-                        propagate_origin_to_child(
-                            parent_frame,
-                            &mut reference_frames,
-                            sibling_frame,
-                        );
-                        frame_stack.push(sibling_frame); // We'll recurse through children next
-                    }
-                }
-
-                // All of the reference frames pushed on the stack have been processed. We can now
-                // pop those off the stack and recursively process their children all the way out to
-                // the leaves of the tree.
-                while let Some(this_frame) = frame_stack.pop() {
-                    for child_frame in reference_frames.child_frames(this_frame) {
-                        propagate_origin_to_child(this_frame, &mut reference_frames, child_frame);
-                        frame_stack.push(child_frame) // Push processed child onto the stack
-                    }
-                }
-
-                // Finally, now that the siblings of this frame have been recursively processed, we
-                // process the parent and set it as the current reference frame. Note that every
-                // time we step to a parent, "this frame" and all descendants have already been
-                // processed, so we only need to process the siblings.
-                match reference_frames.parent_frame(this_frame) {
-                    Some(parent_frame) => this_frame = parent_frame,
-                    None => continue 'outer, // We have reached the root of the tree, and can exit.
-                }
-            }
-
-            error!("Reached the maximum reference frame depth ({MAX_REFERENCE_FRAME_DEPTH}), and exited early to prevent an infinite loop. This might be caused by a degenerate hierarchy.")
-        }
-    }
-}
-
-#[cfg(test)]
-mod tests {
-    use bevy::{ecs::system::SystemState, math::DVec3, prelude::*};
-
-    use super::*;
-    use crate::*;
-
-    /// Test that the reference frame getters do what they say they do.
-    #[test]
-    fn frame_hierarchy_getters() {
-        let mut app = App::new();
-        app.add_plugins(BigSpacePlugin::<i32>::default());
-
-        let frame_bundle = (
-            Transform::default(),
-            GridCell::<i32>::default(),
-            ReferenceFrame::<i32>::default(),
-        );
-
-        let child_1 = app.world_mut().spawn(frame_bundle.clone()).id();
-        let child_2 = app.world_mut().spawn(frame_bundle.clone()).id();
-        let parent = app.world_mut().spawn(frame_bundle.clone()).id();
-        let root = app.world_mut().spawn(frame_bundle.clone()).id();
-
-        app.world_mut().entity_mut(root).add_child(parent);
-        app.world_mut()
-            .entity_mut(parent)
-            .add_children(&[child_1, child_2]);
-
-        let mut state = SystemState::<ReferenceFramesMut<i32>>::new(app.world_mut());
-        let mut ref_frames = state.get_mut(app.world_mut());
-
-        // Children
-        let result = ref_frames.child_frames(root);
-        assert_eq!(result, vec![parent]);
-        let result = ref_frames.child_frames(parent);
-        assert!(result.contains(&child_1));
-        assert!(result.contains(&child_2));
-        let result = ref_frames.child_frames(child_1);
-        assert_eq!(result, Vec::new());
-
-        // Parent
-        let result = ref_frames.parent_frame(root);
-        assert_eq!(result, None);
-        let result = ref_frames.parent_frame(parent);
-        assert_eq!(result, Some(root));
-        let result = ref_frames.parent_frame(child_1);
-        assert_eq!(result, Some(parent));
-
-        // Siblings
-        let result = ref_frames.sibling_frames(root);
-        assert_eq!(result, vec![]);
-        let result = ref_frames.sibling_frames(parent);
-        assert_eq!(result, vec![]);
-        let result = ref_frames.sibling_frames(child_1);
-        assert_eq!(result, vec![child_2]);
-    }
-
-    #[test]
-    fn child_propagation() {
-        let mut app = App::new();
-        app.add_plugins(BigSpacePlugin::<i32>::default());
-
-        let root_frame = ReferenceFrame {
-            local_floating_origin: LocalFloatingOrigin::new(
-                GridCell::<i32>::new(1_000_000, -1, -1),
-                Vec3::ZERO,
-                DQuat::from_rotation_z(-std::f64::consts::FRAC_PI_2),
-            ),
-            ..default()
-        };
-        let root = app
-            .world_mut()
-            .spawn((Transform::default(), GridCell::<i32>::default(), root_frame))
-            .id();
-
-        let child = app
-            .world_mut()
-            .spawn((
-                Transform::from_rotation(Quat::from_rotation_z(std::f32::consts::FRAC_PI_2))
-                    .with_translation(Vec3::new(1.0, 1.0, 0.0)),
-                GridCell::<i32>::new(1_000_000, 0, 0),
-                ReferenceFrame::<i32>::default(),
-            ))
-            .id();
-
-        app.world_mut().entity_mut(root).add_child(child);
-
-        let mut state = SystemState::<ReferenceFramesMut<i32>>::new(app.world_mut());
-        let mut reference_frames = state.get_mut(app.world_mut());
-
-        // The function we are testing
-        propagate_origin_to_child(root, &mut reference_frames, child);
-
-        let (child_frame, ..) = reference_frames.get(child);
-
-        let computed_grid = child_frame.local_floating_origin.cell();
-        let correct_grid = GridCell::new(-1, 0, -1);
-        assert_eq!(computed_grid, correct_grid);
-
-        let computed_rot = child_frame.local_floating_origin.rotation();
-        let correct_rot = DQuat::from_rotation_z(std::f64::consts::PI);
-        let rot_error = computed_rot.angle_between(correct_rot);
-        assert!(rot_error < 1e-10);
-
-        // Even though we are 2 billion units from the origin, our precision is still pretty good.
-        // The loss of precision is coming from the affine multiplication that moves the origin into
-        // the child's reference frame. The good news is that precision loss only scales with the
-        // distance of the origin to the child (in the child's reference frame). In this test we are
-        // saying that the floating origin is - with respect to the root - pretty near the child.
-        // Even though the child and floating origin are very far from the origin, we only lose
-        // precision based on how for the origin is from the child.
-        let computed_trans = child_frame.local_floating_origin.translation();
-        let correct_trans = Vec3::new(-1.0, 1.0, 0.0);
-        let trans_error = computed_trans.distance(correct_trans);
-        assert!(trans_error < 1e-4);
-    }
-
-    #[test]
-    fn parent_propagation() {
-        let mut app = App::new();
-        app.add_plugins(BigSpacePlugin::<i64>::default());
-
-        let frame_bundle = (
-            Transform::default(),
-            GridCell::<i64>::default(),
-            ReferenceFrame::<i64>::default(),
-        );
-        let root = app.world_mut().spawn(frame_bundle.clone()).id();
-
-        let child = app
-            .world_mut()
-            .spawn((
-                Transform::from_rotation(Quat::from_rotation_z(std::f32::consts::FRAC_PI_2))
-                    .with_translation(Vec3::new(1.0, 1.0, 0.0)),
-                GridCell::<i64>::new(150_000_003_000, 0, 0), // roughly radius of earth orbit
-                ReferenceFrame {
-                    local_floating_origin: LocalFloatingOrigin::new(
-                        GridCell::<i64>::new(0, 3_000, 0),
-                        Vec3::new(5.0, 5.0, 0.0),
-                        DQuat::from_rotation_z(-std::f64::consts::FRAC_PI_2),
-                    ),
-                    ..Default::default()
-                },
-            ))
-            .id();
-
-        app.world_mut().entity_mut(root).add_child(child);
-
-        let mut state = SystemState::<ReferenceFramesMut<i64>>::new(app.world_mut());
-        let mut reference_frames = state.get_mut(app.world_mut());
-
-        // The function we are testing
-        propagate_origin_to_parent(child, &mut reference_frames, root);
-
-        let (root_frame, ..) = reference_frames.get(root);
-
-        let computed_grid = root_frame.local_floating_origin.cell();
-        let correct_grid = GridCell::new(150_000_000_000, 0, 0);
-        assert_eq!(computed_grid, correct_grid);
-
-        let computed_rot = root_frame.local_floating_origin.rotation();
-        let correct_rot = DQuat::IDENTITY;
-        let rot_error = computed_rot.angle_between(correct_rot);
-        assert!(rot_error < 1e-7);
-
-        // This is the error of the position of the floating origin if the origin was a person
-        // standing on earth, and their position was resampled with respect to the sun. This is 0.3
-        // meters, but recall that this will be the error when positioning the other planets in the
-        // solar system when rendering.
-        //
-        // This error scales with the distance of the floating origin from the origin of its
-        // reference frame, in this case the radius of the earth, not the radius of the orbit.
-        let computed_trans = root_frame.local_floating_origin.translation();
-        let correct_trans = Vec3::new(-4.0, 6.0, 0.0);
-        let trans_error = computed_trans.distance(correct_trans);
-        assert!(trans_error < 0.3);
-    }
-
-    #[test]
-    fn origin_transform() {
-        let mut app = App::new();
-        app.add_plugins(BigSpacePlugin::<i32>::default());
-
-        let root = app
-            .world_mut()
-            .spawn((
-                Transform::default(),
-                GridCell::<i32>::default(),
-                ReferenceFrame {
-                    local_floating_origin: LocalFloatingOrigin::new(
-                        GridCell::<i32>::new(0, 0, 0),
-                        Vec3::new(1.0, 1.0, 0.0),
-                        DQuat::from_rotation_z(0.0),
-                    ),
-                    ..default()
-                },
-            ))
-            .id();
-
-        let child = app
-            .world_mut()
-            .spawn((
-                Transform::default()
-                    .with_rotation(Quat::from_rotation_z(-std::f32::consts::FRAC_PI_2))
-                    .with_translation(Vec3::new(3.0, 3.0, 0.0)),
-                GridCell::<i32>::new(0, 0, 0),
-                ReferenceFrame::<i32>::default(),
-            ))
-            .id();
-
-        app.world_mut().entity_mut(root).add_child(child);
-
-        let mut state = SystemState::<ReferenceFramesMut<i32>>::new(app.world_mut());
-        let mut reference_frames = state.get_mut(app.world_mut());
-
-        propagate_origin_to_child(root, &mut reference_frames, child);
-
-        let (child_frame, ..) = reference_frames.get(child);
-        let child_local_point = DVec3::new(5.0, 5.0, 0.0);
-
-        let computed_transform = child_frame
-            .local_floating_origin
-            .reference_frame_transform();
-        let computed_pos = computed_transform.transform_point3(child_local_point);
-
-        let correct_transform = DAffine3::from_rotation_translation(
-            DQuat::from_rotation_z(-std::f64::consts::FRAC_PI_2),
-            DVec3::new(2.0, 2.0, 0.0),
-        );
-        let correct_pos = correct_transform.transform_point3(child_local_point);
-
-        assert!((computed_pos - correct_pos).length() < 1e-6);
-        assert!((computed_pos - DVec3::new(7.0, -3.0, 0.0)).length() < 1e-6);
-    }
-}
diff --git a/src/reference_frame/propagation.rs b/src/reference_frame/propagation.rs
deleted file mode 100644
index 1416368..0000000
--- a/src/reference_frame/propagation.rs
+++ /dev/null
@@ -1,184 +0,0 @@
-//! Logic for propagating transforms through the hierarchy of reference frames.
-
-use bevy_ecs::prelude::*;
-use bevy_hierarchy::prelude::*;
-use bevy_transform::prelude::*;
-
-use crate::{precision::GridPrecision, reference_frame::ReferenceFrame, GridCell};
-
-impl<P: GridPrecision> ReferenceFrame<P> {
-    /// Update the `GlobalTransform` of entities with a [`GridCell`], using the [`ReferenceFrame`]
-    /// the entity belongs to.
-    pub fn propagate_high_precision(
-        reference_frames: Query<&ReferenceFrame<P>>,
-        mut entities: Query<(&GridCell<P>, &Transform, &Parent, &mut GlobalTransform)>,
-    ) {
-        // Update the GlobalTransform of GridCell entities that are children of a ReferenceFrame
-        entities
-            .par_iter_mut()
-            .for_each(|(grid, transform, parent, mut global_transform)| {
-                if let Ok(frame) = reference_frames.get(parent.get()) {
-                    *global_transform = frame.global_transform(grid, transform);
-                }
-            });
-    }
-
-    /// Update the [`GlobalTransform`] of entities with a [`Transform`] that are children of a
-    /// [`ReferenceFrame`] and do not have a [`GridCell`] component, or that are children of
-    /// [`GridCell`]s. This will recursively propagate entities that only have low-precision
-    /// [`Transform`]s, just like bevy's built in systems.
-    pub fn propagate_low_precision(
-        frames: Query<&Children, With<ReferenceFrame<P>>>,
-        frame_child_query: Query<(Entity, &Children, &GlobalTransform), With<GridCell<P>>>,
-        transform_query: Query<
-            (Ref<Transform>, &mut GlobalTransform, Option<&Children>),
-            (
-                With<Parent>,
-                Without<GridCell<P>>,
-                Without<ReferenceFrame<P>>,
-            ),
-        >,
-        parent_query: Query<(Entity, Ref<Parent>)>,
-    ) {
-        let update_transforms = |(entity, children, global_transform)| {
-            for (child, actual_parent) in parent_query.iter_many(children) {
-                assert_eq!(
-                actual_parent.get(), entity,
-                "Malformed hierarchy. This probably means that your hierarchy has been improperly maintained, or contains a cycle"
-            );
-
-                // Unlike bevy's transform propagation, change detection is much more complex, because
-                // it is relative to the floating origin, *and* whether entities are moving.
-                // - If the floating origin changes grid cells, everything needs to update
-                // - If the floating origin's reference frame moves (translation, rotation), every
-                //   entity outside of the reference frame subtree that the floating origin is in must
-                //   update.
-                // - All entities or reference frame subtrees that move within the same frame as the
-                //   floating origin must be updated.
-                //
-                // Instead of adding this complexity and computation, is it much simpler to update
-                // everything every frame.
-                let changed = true;
-
-                // SAFETY:
-                // - `child` must have consistent parentage, or the above assertion would panic. Since
-                // `child` is parented to a root entity, the entire hierarchy leading to it is
-                // consistent.
-                // - We may operate as if all descendants are consistent, since `propagate_recursive`
-                //   will panic before continuing to propagate if it encounters an entity with
-                //   inconsistent parentage.
-                // - Since each root entity is unique and the hierarchy is consistent and forest-like,
-                //   other root entities' `propagate_recursive` calls will not conflict with this one.
-                // - Since this is the only place where `transform_query` gets used, there will be no
-                //   conflicting fetches elsewhere.
-                unsafe {
-                    Self::propagate_recursive(
-                        &global_transform,
-                        &transform_query,
-                        &parent_query,
-                        child,
-                        changed,
-                    );
-                }
-            }
-        };
-
-        frames.par_iter().for_each(|children| {
-            children
-                .iter()
-                .filter_map(|child| frame_child_query.get(*child).ok())
-                .for_each(|(e, c, g)| update_transforms((e, c, *g)))
-        });
-    }
-
-    /// COPIED FROM BEVY
-    ///
-    /// Recursively propagates the transforms for `entity` and all of its descendants.
-    ///
-    /// # Panics
-    ///
-    /// If `entity`'s descendants have a malformed hierarchy, this function will panic occur before
-    /// propagating the transforms of any malformed entities and their descendants.
-    ///
-    /// # Safety
-    ///
-    /// - While this function is running, `transform_query` must not have any fetches for `entity`,
-    ///   nor any of its descendants.
-    /// - The caller must ensure that the hierarchy leading to `entity` is well-formed and must
-    ///   remain as a tree or a forest. Each entity must have at most one parent.
-    unsafe fn propagate_recursive(
-        parent: &GlobalTransform,
-        transform_query: &Query<
-            (Ref<Transform>, &mut GlobalTransform, Option<&Children>),
-            (
-                With<Parent>,
-                Without<GridCell<P>>, // ***ADDED*** Only recurse low-precision entities
-                Without<ReferenceFrame<P>>, // ***ADDED*** Only recurse low-precision entities
-            ),
-        >,
-        parent_query: &Query<(Entity, Ref<Parent>)>,
-        entity: Entity,
-        mut changed: bool,
-    ) {
-        let (global_matrix, children) = {
-            let Ok((transform, mut global_transform, children)) =
-            // SAFETY: This call cannot create aliased mutable references.
-            //   - The top level iteration parallelizes on the roots of the hierarchy.
-            //   - The caller ensures that each child has one and only one unique parent throughout the entire
-            //     hierarchy.
-            //
-            // For example, consider the following malformed hierarchy:
-            //
-            //     A
-            //   /   \
-            //  B     C
-            //   \   /
-            //     D
-            //
-            // D has two parents, B and C. If the propagation passes through C, but the Parent component on D points to B,
-            // the above check will panic as the origin parent does match the recorded parent.
-            //
-            // Also consider the following case, where A and B are roots:
-            //
-            //  A       B
-            //   \     /
-            //    C   D
-            //     \ /
-            //      E
-            //
-            // Even if these A and B start two separate tasks running in parallel, one of them will panic before attempting
-            // to mutably access E.
-            (unsafe { transform_query.get_unchecked(entity) }) else {
-                return;
-            };
-
-            changed |= transform.is_changed() || global_transform.is_added();
-            if changed {
-                *global_transform = parent.mul_transform(*transform);
-            }
-            (*global_transform, children)
-        };
-
-        let Some(children) = children else { return };
-        for (child, actual_parent) in parent_query.iter_many(children) {
-            assert_eq!(
-            actual_parent.get(), entity,
-            "Malformed hierarchy. This probably means that your hierarchy has been improperly maintained, or contains a cycle"
-        );
-            // SAFETY: The caller guarantees that `transform_query` will not be fetched
-            // for any descendants of `entity`, so it is safe to call `propagate_recursive` for each child.
-            //
-            // The above assertion ensures that each child has one and only one unique parent throughout the
-            // entire hierarchy.
-            unsafe {
-                Self::propagate_recursive(
-                    &global_matrix,
-                    transform_query,
-                    parent_query,
-                    child,
-                    changed || actual_parent.is_changed(),
-                );
-            }
-        }
-    }
-}
diff --git a/src/tests.rs b/src/tests.rs
index 8ebe392..a1ab088 100644
--- a/src/tests.rs
+++ b/src/tests.rs
@@ -1,7 +1,6 @@
+use crate::prelude::*;
 use bevy::prelude::*;
 
-use crate::{BigSpacePlugin, BigSpaceRootBundle, FloatingOrigin, GridCell};
-
 #[test]
 fn changing_floating_origin_updates_global_transform() {
     let mut app = App::new();
diff --git a/src/timing.rs b/src/timing.rs
new file mode 100644
index 0000000..45a65e5
--- /dev/null
+++ b/src/timing.rs
@@ -0,0 +1,249 @@
+//! Timing statistics for transform propagation
+
+use std::{collections::VecDeque, iter::Sum, ops::Div, time::Duration};
+
+use crate::prelude::*;
+use bevy_app::prelude::*;
+use bevy_ecs::prelude::*;
+use bevy_reflect::prelude::*;
+use bevy_transform::TransformSystem;
+
+/// Summarizes plugin performance timings
+pub struct TimingStatsPlugin;
+
+impl Plugin for TimingStatsPlugin {
+    fn build(&self, app: &mut bevy_app::App) {
+        app.init_resource::<PropagationStats>()
+            .register_type::<PropagationStats>()
+            .init_resource::<GridHashStats>()
+            .register_type::<GridHashStats>()
+            .init_resource::<SmoothedStat<PropagationStats>>()
+            .register_type::<SmoothedStat<PropagationStats>>()
+            .init_resource::<SmoothedStat<GridHashStats>>()
+            .register_type::<SmoothedStat<GridHashStats>>()
+            .add_systems(
+                PostUpdate,
+                (GridHashStats::reset, PropagationStats::reset).in_set(FloatingOriginSystem::Init),
+            )
+            .add_systems(
+                PostUpdate,
+                (update_totals, update_averages)
+                    .chain()
+                    .after(TransformSystem::TransformPropagate),
+            );
+    }
+}
+
+fn update_totals(mut prop_stats: ResMut<PropagationStats>, mut hash_stats: ResMut<GridHashStats>) {
+    prop_stats.total = prop_stats.grid_recentering
+        + prop_stats.high_precision_propagation
+        + prop_stats.local_origin_propagation
+        + prop_stats.low_precision_propagation
+        + prop_stats.low_precision_root_tagging;
+
+    hash_stats.total = hash_stats.hash_update_duration
+        + hash_stats.map_update_duration
+        + hash_stats.update_partition;
+}
+
+fn update_averages(
+    hash_stats: Res<GridHashStats>,
+    mut avg_hash_stats: ResMut<SmoothedStat<GridHashStats>>,
+    prop_stats: Res<PropagationStats>,
+    mut avg_prop_stats: ResMut<SmoothedStat<PropagationStats>>,
+) {
+    avg_hash_stats.push(hash_stats.clone()).compute_avg();
+    avg_prop_stats.push(prop_stats.clone()).compute_avg();
+}
+
+/// Aggregate runtime statistics for transform propagation.
+#[derive(Resource, Debug, Clone, Default, Reflect)]
+pub struct PropagationStats {
+    pub(crate) grid_recentering: Duration,
+    pub(crate) local_origin_propagation: Duration,
+    pub(crate) high_precision_propagation: Duration,
+    pub(crate) low_precision_root_tagging: Duration,
+    pub(crate) low_precision_propagation: Duration,
+    pub(crate) total: Duration,
+}
+
+impl PropagationStats {
+    pub(crate) fn reset(mut stats: ResMut<Self>) {
+        *stats = Self::default();
+    }
+
+    /// How long it took to run
+    /// [`recenter_large_transforms`](crate::grid::cell::GridCell::recenter_large_transforms)
+    /// propagation this update.
+    pub fn grid_recentering(&self) -> Duration {
+        self.grid_recentering
+    }
+
+    /// How long it took to run [`LocalFloatingOrigin`] propagation this update.
+    pub fn local_origin_propagation(&self) -> Duration {
+        self.local_origin_propagation
+    }
+
+    /// How long it took to run high precision
+    /// [`Transform`](bevy_transform::prelude::Transform)+[`GridCell`] propagation this update.
+    pub fn high_precision_propagation(&self) -> Duration {
+        self.high_precision_propagation
+    }
+
+    /// How long it took to run low precision [`Transform`](bevy_transform::prelude::Transform)
+    /// propagation this update.
+    pub fn low_precision_propagation(&self) -> Duration {
+        self.low_precision_propagation
+    }
+
+    /// How long it took to tag entities with
+    /// [`LowPrecisionRoot`](crate::grid::propagation::LowPrecisionRoot).
+    pub fn low_precision_root_tagging(&self) -> Duration {
+        self.low_precision_root_tagging
+    }
+
+    /// Total propagation time.
+    pub fn total(&self) -> Duration {
+        self.total
+    }
+}
+
+impl<'a> std::iter::Sum<&'a PropagationStats> for PropagationStats {
+    fn sum<I: Iterator<Item = &'a PropagationStats>>(iter: I) -> Self {
+        iter.fold(PropagationStats::default(), |mut acc, e| {
+            acc.grid_recentering += e.grid_recentering;
+            acc.local_origin_propagation += e.local_origin_propagation;
+            acc.high_precision_propagation += e.high_precision_propagation;
+            acc.low_precision_propagation += e.low_precision_propagation;
+            acc.low_precision_root_tagging += e.low_precision_root_tagging;
+            acc.total += e.total;
+            acc
+        })
+    }
+}
+
+impl std::ops::Div<u32> for PropagationStats {
+    type Output = Self;
+
+    fn div(self, rhs: u32) -> Self::Output {
+        Self {
+            grid_recentering: self.grid_recentering.div(rhs),
+            local_origin_propagation: self.local_origin_propagation.div(rhs),
+            high_precision_propagation: self.high_precision_propagation.div(rhs),
+            low_precision_root_tagging: self.low_precision_root_tagging.div(rhs),
+            low_precision_propagation: self.low_precision_propagation.div(rhs),
+            total: self.total.div(rhs),
+        }
+    }
+}
+
+/// Aggregate runtime statistics across all [`crate::hash::GridHashPlugin`]s.
+#[derive(Resource, Debug, Clone, Default, Reflect)]
+pub struct GridHashStats {
+    pub(crate) moved_entities: usize,
+    pub(crate) hash_update_duration: Duration,
+    pub(crate) map_update_duration: Duration,
+    pub(crate) update_partition: Duration,
+    pub(crate) total: Duration,
+}
+
+impl GridHashStats {
+    fn reset(mut stats: ResMut<GridHashStats>) {
+        *stats = Self::default();
+    }
+
+    /// Time to update all entity hashes.
+    pub fn hash_update_duration(&self) -> Duration {
+        self.hash_update_duration
+    }
+
+    /// Time to update all spatial hash maps.
+    pub fn map_update_duration(&self) -> Duration {
+        self.map_update_duration
+    }
+
+    /// Time to update all partition maps.
+    pub fn update_partition(&self) -> Duration {
+        self.update_partition
+    }
+
+    /// Number of entities with a changed spatial hash (moved to a new grid cell).
+    pub fn moved_cell_entities(&self) -> usize {
+        self.moved_entities
+    }
+
+    /// Total runtime cost of spatial hashing.
+    pub fn total(&self) -> Duration {
+        self.total
+    }
+}
+
+impl<'a> std::iter::Sum<&'a GridHashStats> for GridHashStats {
+    fn sum<I: Iterator<Item = &'a GridHashStats>>(iter: I) -> Self {
+        iter.fold(GridHashStats::default(), |mut acc, e| {
+            acc.hash_update_duration += e.hash_update_duration;
+            acc.map_update_duration += e.map_update_duration;
+            acc.update_partition += e.update_partition;
+            acc.moved_entities += e.moved_entities;
+            acc.total += e.total;
+            acc
+        })
+    }
+}
+
+impl Div<u32> for GridHashStats {
+    type Output = Self;
+
+    fn div(self, rhs: u32) -> Self::Output {
+        Self {
+            hash_update_duration: self.hash_update_duration.div(rhs),
+            map_update_duration: self.map_update_duration.div(rhs),
+            update_partition: self.update_partition.div(rhs),
+            moved_entities: self.moved_entities.div(rhs as usize),
+            total: self.total.div(rhs),
+        }
+    }
+}
+
+/// Smoothed timing statistics
+#[derive(Resource, Debug, Reflect)]
+pub struct SmoothedStat<T>
+where
+    for<'a> T: FromWorld + Sum<&'a T> + Div<u32, Output = T>,
+{
+    queue: VecDeque<T>,
+    avg: T,
+}
+
+impl<T> FromWorld for SmoothedStat<T>
+where
+    for<'a> T: FromWorld + Sum<&'a T> + Div<u32, Output = T>,
+{
+    fn from_world(world: &mut World) -> Self {
+        SmoothedStat {
+            queue: VecDeque::new(),
+            avg: T::from_world(world),
+        }
+    }
+}
+
+impl<T> SmoothedStat<T>
+where
+    for<'a> T: FromWorld + Sum<&'a T> + Div<u32, Output = T>,
+{
+    fn push(&mut self, value: T) -> &mut Self {
+        self.queue.truncate(63);
+        self.queue.push_front(value);
+        self
+    }
+
+    fn compute_avg(&mut self) -> &mut Self {
+        self.avg = self.queue.iter().sum::<T>() / self.queue.len() as u32;
+        self
+    }
+
+    /// Get the smoothed average value.
+    pub fn avg(&self) -> &T {
+        &self.avg
+    }
+}
diff --git a/src/validation.rs b/src/validation.rs
index 62823ac..1a92caa 100644
--- a/src/validation.rs
+++ b/src/validation.rs
@@ -2,15 +2,13 @@
 
 use std::marker::PhantomData;
 
+use crate::prelude::*;
 use bevy_ecs::prelude::*;
 use bevy_hierarchy::prelude::*;
 use bevy_transform::prelude::*;
-use bevy_utils::HashMap;
-use tracing::error;
+use bevy_utils::{HashMap, HashSet};
 
-use crate::{
-    precision::GridPrecision, reference_frame::ReferenceFrame, BigSpace, FloatingOrigin, GridCell,
-};
+use crate::{grid::Grid, precision::GridPrecision, BigSpace, FloatingOrigin, GridCell};
 
 struct ValidationStackEntry {
     parent_node: Box<dyn ValidHierarchyNode>,
@@ -23,9 +21,11 @@ struct ValidatorCaches {
     validator_cache: HashMap<&'static str, Vec<Box<dyn ValidHierarchyNode>>>,
     root_query: Option<QueryState<Entity, Without<Parent>>>,
     stack: Vec<ValidationStackEntry>,
+    /// Only report errors for an entity one time.
+    error_entities: HashSet<Entity>,
 }
 
-/// Validate the entity hierarchy and report errors.
+/// An exclusive system that validate the entity hierarchy and report errors.
 pub fn validate_hierarchy<V: 'static + ValidHierarchyNode + Default>(world: &mut World) {
     world.init_resource::<ValidatorCaches>();
     let mut caches = world.remove_resource::<ValidatorCaches>().unwrap();
@@ -76,27 +76,45 @@ pub fn validate_hierarchy<V: 'static + ValidHierarchyNode + Default>(world: &mut
                 }
                 Some(_) => (), // Matched, but no children to push on the stack
                 None => {
+                    if caches.error_entities.contains(entity) {
+                        continue; // Don't repeat error messages for the same entity
+                    }
+
                     let mut possibilities = String::new();
                     stack_entry
                         .parent_node
                         .allowed_child_nodes()
                         .iter()
                         .for_each(|v| {
-                            possibilities.push('\t');
-                            possibilities.push('\t');
+                            possibilities.push_str("  - ");
                             possibilities.push_str(v.name());
                             possibilities.push('\n');
                         });
 
                     let mut inspect = String::new();
                     world.inspect_entity(*entity).for_each(|info| {
-                        inspect.push('\t');
-                        inspect.push('\t');
+                        inspect.push_str("  - ");
                         inspect.push_str(info.name());
                         inspect.push('\n');
                     });
 
-                    error!("big_space hierarchy validation error:\n\tEntity {:#?} is a child of the node {:#?}, but the entity does not match its parent's validation criteria.\n\tBecause it is a child of a {:#?}, the entity must be one of the following kinds of nodes:\n{}\tHowever, the entity has the following components, which does not match any of the above allowed archetypes:\n{}\tCommon errors include:\n\t  - Using mismatched GridPrecisions, like GridCell<i32> and GridCell<i64>\n\t  - Spawning an entity with a GridCell as a child of an entity without a ReferenceFrame.\n\tIf possible, use commands.spawn_big_space(), which prevents these errors, instead of manually assembling a hierarchy.\n\tSee {} for details.", entity, stack_entry.parent_node.name(), stack_entry.parent_node.name(), possibilities, inspect, file!());
+                    tracing::error!("
+-------------------------------------------
+big_space hierarchy validation error report
+-------------------------------------------
+
+Entity {:#} is a child of a {:#?}, but the components on this entity do not match any of the allowed archetypes for children of this parent.
+                    
+Because it is a child of a {:#?}, the entity must be one of the following:
+{}
+However, the entity has the following components, which does not match any of the allowed archetypes listed above:
+{}
+Common errors include:
+  - Using mismatched GridPrecisions, like GridCell<i32> and GridCell<i64>
+  - Spawning an entity with a GridCell as a child of an entity without a Grid.
+
+If possible, use commands.spawn_big_space(), which prevents these errors, instead of manually assembling a hierarchy. See {} for details.", entity, stack_entry.parent_node.name(), stack_entry.parent_node.name(), possibilities, inspect, file!());
+                    caches.error_entities.insert(*entity);
                 }
             }
         }
@@ -123,7 +141,7 @@ pub trait ValidHierarchyNode: sealed::CloneHierarchy + Send + Sync {
     }
 }
 
-pub(super) mod sealed {
+mod sealed {
     use super::ValidHierarchyNode;
 
     pub trait CloneHierarchy {
@@ -151,6 +169,10 @@ pub(super) mod sealed {
 pub struct SpatialHierarchyRoot<P: GridPrecision>(PhantomData<P>);
 
 impl<P: GridPrecision> ValidHierarchyNode for SpatialHierarchyRoot<P> {
+    fn name(&self) -> &'static str {
+        "Root"
+    }
+
     fn match_self(&self, _: &mut QueryBuilder<(Entity, Option<&Children>)>) {}
 
     fn allowed_child_nodes(&self) -> Vec<Box<dyn ValidHierarchyNode>> {
@@ -166,13 +188,17 @@ impl<P: GridPrecision> ValidHierarchyNode for SpatialHierarchyRoot<P> {
 struct AnyNonSpatial<P: GridPrecision>(PhantomData<P>);
 
 impl<P: GridPrecision> ValidHierarchyNode for AnyNonSpatial<P> {
+    fn name(&self) -> &'static str {
+        "Any non-spatial entity"
+    }
+
     fn match_self(&self, query: &mut QueryBuilder<(Entity, Option<&Children>)>) {
         query
-            .without::<GridCell<P>>()
+            .without::<GridCellAny>()
             .without::<Transform>()
             .without::<GlobalTransform>()
             .without::<BigSpace>()
-            .without::<ReferenceFrame<P>>()
+            .without::<Grid<P>>()
             .without::<FloatingOrigin>();
     }
 
@@ -185,13 +211,17 @@ impl<P: GridPrecision> ValidHierarchyNode for AnyNonSpatial<P> {
 struct RootFrame<P: GridPrecision>(PhantomData<P>);
 
 impl<P: GridPrecision> ValidHierarchyNode for RootFrame<P> {
+    fn name(&self) -> &'static str {
+        "Root of a BigSpace"
+    }
+
     fn match_self(&self, query: &mut QueryBuilder<(Entity, Option<&Children>)>) {
         query
             .with::<BigSpace>()
-            .with::<ReferenceFrame<P>>()
-            .without::<GridCell<P>>()
+            .with::<Grid<P>>()
+            .with::<GlobalTransform>()
+            .without::<GridCellAny>()
             .without::<Transform>()
-            .without::<GlobalTransform>()
             .without::<Parent>()
             .without::<FloatingOrigin>();
     }
@@ -210,13 +240,17 @@ impl<P: GridPrecision> ValidHierarchyNode for RootFrame<P> {
 struct RootSpatialLowPrecision<P: GridPrecision>(PhantomData<P>);
 
 impl<P: GridPrecision> ValidHierarchyNode for RootSpatialLowPrecision<P> {
+    fn name(&self) -> &'static str {
+        "Root of a Transform hierarchy at the root of the tree outside of any BigSpace"
+    }
+
     fn match_self(&self, query: &mut QueryBuilder<(Entity, Option<&Children>)>) {
         query
             .with::<Transform>()
             .with::<GlobalTransform>()
-            .without::<GridCell<P>>()
+            .without::<GridCellAny>()
             .without::<BigSpace>()
-            .without::<ReferenceFrame<P>>()
+            .without::<Grid<P>>()
             .without::<Parent>()
             .without::<FloatingOrigin>();
     }
@@ -233,9 +267,13 @@ impl<P: GridPrecision> ValidHierarchyNode for RootSpatialLowPrecision<P> {
 struct ChildFrame<P: GridPrecision>(PhantomData<P>);
 
 impl<P: GridPrecision> ValidHierarchyNode for ChildFrame<P> {
+    fn name(&self) -> &'static str {
+        "Non-root Grid"
+    }
+
     fn match_self(&self, query: &mut QueryBuilder<(Entity, Option<&Children>)>) {
         query
-            .with::<ReferenceFrame<P>>()
+            .with::<Grid<P>>()
             .with::<GridCell<P>>()
             .with::<Transform>()
             .with::<GlobalTransform>()
@@ -246,25 +284,57 @@ impl<P: GridPrecision> ValidHierarchyNode for ChildFrame<P> {
     fn allowed_child_nodes(&self) -> Vec<Box<dyn ValidHierarchyNode>> {
         vec![
             Box::<ChildFrame<P>>::default(),
-            Box::<ChildSpatialLowPrecision<P>>::default(),
+            Box::<ChildRootSpatialLowPrecision<P>>::default(),
             Box::<ChildSpatialHighPrecision<P>>::default(),
             Box::<AnyNonSpatial<P>>::default(),
         ]
     }
 }
 
+#[derive(Default, Clone)]
+struct ChildRootSpatialLowPrecision<P: GridPrecision>(PhantomData<P>);
+
+impl<P: GridPrecision> ValidHierarchyNode for ChildRootSpatialLowPrecision<P> {
+    fn name(&self) -> &'static str {
+        "Root of a low-precision Transform hierarchy, within a BigSpace"
+    }
+
+    fn match_self(&self, query: &mut QueryBuilder<(Entity, Option<&Children>)>) {
+        query
+            .with::<Transform>()
+            .with::<GlobalTransform>()
+            .with::<Parent>()
+            .with::<crate::grid::propagation::LowPrecisionRoot>()
+            .without::<GridCellAny>()
+            .without::<BigSpace>()
+            .without::<Grid<P>>()
+            .without::<FloatingOrigin>();
+    }
+
+    fn allowed_child_nodes(&self) -> Vec<Box<dyn ValidHierarchyNode>> {
+        vec![
+            Box::<ChildSpatialLowPrecision<P>>::default(),
+            Box::<AnyNonSpatial<P>>::default(),
+        ]
+    }
+}
+
 #[derive(Default, Clone)]
 struct ChildSpatialLowPrecision<P: GridPrecision>(PhantomData<P>);
 
 impl<P: GridPrecision> ValidHierarchyNode for ChildSpatialLowPrecision<P> {
+    fn name(&self) -> &'static str {
+        "Non-root low-precision spatial entity"
+    }
+
     fn match_self(&self, query: &mut QueryBuilder<(Entity, Option<&Children>)>) {
         query
             .with::<Transform>()
             .with::<GlobalTransform>()
             .with::<Parent>()
-            .without::<GridCell<P>>()
+            .without::<GridCellAny>()
             .without::<BigSpace>()
-            .without::<ReferenceFrame<P>>()
+            .without::<Grid<P>>()
             .without::<FloatingOrigin>();
     }
 
@@ -280,6 +350,10 @@ impl<P: GridPrecision> ValidHierarchyNode for ChildSpatialLowPrecision<P> {
 struct ChildSpatialHighPrecision<P: GridPrecision>(PhantomData<P>);
 
 impl<P: GridPrecision> ValidHierarchyNode for ChildSpatialHighPrecision<P> {
+    fn name(&self) -> &'static str {
+        "Non-root high precision spatial entity"
+    }
+
     fn match_self(&self, query: &mut QueryBuilder<(Entity, Option<&Children>)>) {
         query
             .with::<GridCell<P>>()
@@ -287,12 +361,12 @@ impl<P: GridPrecision> ValidHierarchyNode for ChildSpatialHighPrecision<P> {
             .with::<GlobalTransform>()
             .with::<Parent>()
             .without::<BigSpace>()
-            .without::<ReferenceFrame<P>>();
+            .without::<Grid<P>>();
     }
 
     fn allowed_child_nodes(&self) -> Vec<Box<dyn ValidHierarchyNode>> {
         vec![
-            Box::<ChildSpatialLowPrecision<P>>::default(),
+            Box::<ChildRootSpatialLowPrecision<P>>::default(),
             Box::<AnyNonSpatial<P>>::default(),
         ]
     }
diff --git a/src/world_query.rs b/src/world_query.rs
index b9a9ec3..fb91aa2 100644
--- a/src/world_query.rs
+++ b/src/world_query.rs
@@ -1,19 +1,17 @@
-//! A helper query argument that ensures you don't forget to handle
-//! the [`GridCell`] when you work with a [`Transform`].
+//! A helper query argument that ensures you don't forget to handle the [`GridCell`] when you work
+//! with a [`Transform`].
 
+use crate::prelude::*;
 use bevy_ecs::query::QueryData;
 use bevy_math::{prelude::*, DVec3};
 use bevy_transform::prelude::*;
 
-use crate::GridCell;
-use crate::{precision::GridPrecision, reference_frame::ReferenceFrame};
-
 #[derive(QueryData)]
 #[query_data(mutable)]
-/// A convenience query argument that groups a [`Transform`] with its [`GridCell`].
-/// If you only want to read from the position, use [`GridTransformReadOnly`] instead,
-/// as this will allow the bevy ECS to run multiple queries using [`GridTransformReadOnly`]
-/// at the same time (just like multiple queries with `&Transform` are fine).
+/// A convenience query argument that groups a [`Transform`] with its [`GridCell`]. If you only want
+/// to read from the position, use [`GridTransformReadOnly`] instead, as this will allow the bevy
+/// ECS to run multiple queries using [`GridTransformReadOnly`] at the same time (just like multiple
+/// queries with `&Transform` are fine).
 pub struct GridTransform<P: GridPrecision> {
     /// Grid local transform
     pub transform: &'static mut Transform,
@@ -23,13 +21,13 @@ pub struct GridTransform<P: GridPrecision> {
 
 impl<P: GridPrecision> GridTransformItem<'_, P> {
     /// Compute the global position with double precision.
-    pub fn position_double(&self, reference_frame: &ReferenceFrame<P>) -> DVec3 {
-        reference_frame.grid_position_double(&self.cell, &self.transform)
+    pub fn position_double(&self, grid: &Grid<P>) -> DVec3 {
+        grid.grid_position_double(&self.cell, &self.transform)
     }
 
     /// Compute the global position.
-    pub fn position(&self, reference_frame: &ReferenceFrame<P>) -> Vec3 {
-        reference_frame.grid_position(&self.cell, &self.transform)
+    pub fn position(&self, grid: &Grid<P>) -> Vec3 {
+        grid.grid_position(&self.cell, &self.transform)
     }
 
     /// Get a copy of the fields to work with.
@@ -43,13 +41,13 @@ impl<P: GridPrecision> GridTransformItem<'_, P> {
 
 impl<P: GridPrecision> GridTransformReadOnlyItem<'_, P> {
     /// Compute the global position with double precision.
-    pub fn position_double(&self, reference_frame: &ReferenceFrame<P>) -> DVec3 {
-        reference_frame.grid_position_double(self.cell, self.transform)
+    pub fn position_double(&self, grid: &Grid<P>) -> DVec3 {
+        grid.grid_position_double(self.cell, self.transform)
     }
 
     /// Compute the global position.
-    pub fn position(&self, reference_frame: &ReferenceFrame<P>) -> Vec3 {
-        reference_frame.grid_position(self.cell, self.transform)
+    pub fn position(&self, grid: &Grid<P>) -> Vec3 {
+        grid.grid_position(self.cell, self.transform)
     }
 
     /// Get a copy of the fields to work with.
@@ -98,12 +96,12 @@ impl<P: GridPrecision> std::ops::Add for GridTransformOwned<P> {
 
 impl<P: GridPrecision> GridTransformOwned<P> {
     /// Compute the global position with double precision.
-    pub fn position_double(&self, reference_frame: &ReferenceFrame<P>) -> DVec3 {
-        reference_frame.grid_position_double(&self.cell, &self.transform)
+    pub fn position_double(&self, grid: &Grid<P>) -> DVec3 {
+        grid.grid_position_double(&self.cell, &self.transform)
     }
 
     /// Compute the global position.
-    pub fn position(&self, reference_frame: &ReferenceFrame<P>) -> Vec3 {
-        reference_frame.grid_position(&self.cell, &self.transform)
+    pub fn position(&self, grid: &Grid<P>) -> Vec3 {
+        grid.grid_position(&self.cell, &self.transform)
     }
 }