From 44511cde752028b6c444568923d6142e2345a15b Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 7 Jul 2026 18:53:24 +0200 Subject: [PATCH 1/9] feat(server): stream chunks around a player entity via ECS --- Cargo.lock | 500 +++++++++++++++++++++++++++++- crates/server/Cargo.toml | 1 + crates/server/src/main.rs | 53 +++- crates/server/src/player.rs | 18 ++ crates/server/src/world_server.rs | 136 ++++++-- 5 files changed, 669 insertions(+), 39 deletions(-) create mode 100644 crates/server/src/player.rs diff --git a/Cargo.lock b/Cargo.lock index f093e97..e0b0a95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -25,7 +25,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5a15f179cd60c4584b8a8c596927aadc462e27f2ca70c04e0071964a73ba7a75" dependencies = [ "cfg-if", - "getrandom", + "getrandom 0.3.4", "once_cell", "version_check", "zerocopy", @@ -109,11 +109,48 @@ dependencies = [ "raw-window-metal", ] +[[package]] +name = "assert_type_match" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f548ad2c4031f2902e3edc1f29c29e835829437de49562d8eb5dc5584d3a1043" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "async-executor" +version = "1.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c96bf972d85afc50bf5ab8fe2d54d1586b4e0b46c97c50a0c9e71e2f7bcd812a" +dependencies = [ + "async-task", + "concurrent-queue", + "fastrand", + "futures-lite", + "pin-project-lite", + "slab", +] + +[[package]] +name = "async-task" +version = "4.7.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8b75356056920673b02621b35afd0f7dda9306d03c79a30f5c56c44cf256e3de" +dependencies = [ + "portable-atomic", +] + [[package]] name = "atomic-waker" version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +dependencies = [ + "portable-atomic", +] [[package]] name = "autocfg" @@ -121,6 +158,168 @@ version = "1.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c08606f8c3cbf4ce6ec8e28fb0014a2c086708fe954eaa885384a6165172e7e8" +[[package]] +name = "bevy_ecs" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4654b9b3535fa7813d2878a50e1b5ad80a361dc1c913b96ded57667f65d70a01" +dependencies = [ + "arrayvec", + "bevy_ecs_macros", + "bevy_platform", + "bevy_ptr", + "bevy_reflect", + "bevy_tasks", + "bevy_utils", + "bitflags 2.11.1", + "bumpalo", + "concurrent-queue", + "derive_more", + "fixedbitset", + "indexmap", + "log", + "nonmax", + "serde", + "slotmap", + "smallvec", + "thiserror 2.0.18", + "variadics_please", +] + +[[package]] +name = "bevy_ecs_macro_logic" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ee0b98a74141ce8dce4654c60020ebbaefab32646f42af6bbae55f282ae65ff7" +dependencies = [ + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bevy_ecs_macros" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4dc958a194d226d618404e0fea99a3c8b4146207a00a3ba07fa712bf71607240" +dependencies = [ + "bevy_ecs_macro_logic", + "bevy_macro_utils", + "proc-macro2", + "quote", + "syn", +] + +[[package]] +name = "bevy_macro_utils" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "746a19912c6dc1bbe79188778573e8a253d5832c696b2fcb95578c17b29ff7ba" +dependencies = [ + "proc-macro2", + "quote", + "syn", + "toml_edit", +] + +[[package]] +name = "bevy_platform" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3120670bb2308980f723477c9d82476fcda31f08be9b256c3f0acdee82a65094" +dependencies = [ + "critical-section", + "foldhash", + "futures-channel", + "futures-lite", + "hashbrown 0.16.1", + "js-sys", + "portable-atomic", + "portable-atomic-util", + "serde", + "spin", + "wasm-bindgen", + "wasm-bindgen-futures", + "windows-sys 0.61.2", +] + +[[package]] +name = "bevy_ptr" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b511e89f7078fd21fdea77061a0ca3e737297c7011bb10c073e0aecb17c9fea6" + +[[package]] +name = "bevy_reflect" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92abd59cbba1524c1847e9a50f74d94e6b7836c80a8edfa9c47e59f7fd81021d" +dependencies = [ + "assert_type_match", + "bevy_platform", + "bevy_ptr", + "bevy_reflect_derive", + "bevy_utils", + "derive_more", + "disqualified", + "downcast-rs 2.0.2", + "erased-serde", + "foldhash", + "glam 0.32.1", + "indexmap", + "serde", + "smallvec", + "smol_str", + "thiserror 2.0.18", + "uuid", + "variadics_please", + "wgpu-types", +] + +[[package]] +name = "bevy_reflect_derive" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "90fa4e6b97fd2d582dd5ed96762a70d04505a477c833cf4f69d016dcd03d2d04" +dependencies = [ + "bevy_macro_utils", + "indexmap", + "proc-macro2", + "quote", + "syn", + "uuid", +] + +[[package]] +name = "bevy_tasks" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "fa34e6b9b804851ec08a41c0346c859d82e2fa98c906d74b965ee61bbbb688e4" +dependencies = [ + "async-executor", + "async-task", + "atomic-waker", + "bevy_platform", + "crossbeam-queue", + "derive_more", + "futures-lite", + "heapless", + "web-task", +] + +[[package]] +name = "bevy_utils" +version = "0.19.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3aceea6c7ffdd568f866d5ca4dfe50c33440c54f7bc230c13a9ba7ab0c91aed1" +dependencies = [ + "bevy_platform", + "disqualified", + "indexmap", + "thread_local", +] + [[package]] name = "bitflags" version = "1.3.2" @@ -132,6 +331,9 @@ name = "bitflags" version = "2.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c4512299f36f043ab09a583e57bceb5a5aab7a73db1805848e8fef3c9e8c78b3" +dependencies = [ + "serde_core", +] [[package]] name = "block" @@ -174,6 +376,12 @@ dependencies = [ "syn", ] +[[package]] +name = "byteorder" +version = "1.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fd0f2584146f6f2ef48085050886acf353beff7305ebd1ae69500e27c67f64b" + [[package]] name = "bytes" version = "1.11.1" @@ -293,6 +501,16 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4ca0197aee26d1ae37445ee532fefce43251d24cc7c166799f4d46817f1d3973" dependencies = [ "crossbeam-utils", + "portable-atomic", +] + +[[package]] +name = "convert_case" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "633458d4ef8c78b72454de2d54fd6ab2e60f9e02be22f3c6104cdc8a4e0fceb9" +dependencies = [ + "unicode-segmentation", ] [[package]] @@ -335,6 +553,12 @@ dependencies = [ "libc", ] +[[package]] +name = "critical-section" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" + [[package]] name = "crossbeam-deque" version = "0.8.7" @@ -354,6 +578,15 @@ dependencies = [ "crossbeam-utils", ] +[[package]] +name = "crossbeam-queue" +version = "0.3.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "803d13fb3b09d88be9f4dbc29062c66b19bf7170867ceb746d2a8689bf6c7a26" +dependencies = [ + "crossbeam-utils", +] + [[package]] name = "crossbeam-utils" version = "0.8.21" @@ -366,12 +599,41 @@ version = "1.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f27ae1dd37df86211c42e150270f82743308803d90a6f6e6651cd730d5e1732f" +[[package]] +name = "derive_more" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d751e9e49156b02b44f9c1815bcb94b984cdcc4396ecc32521c739452808b134" +dependencies = [ + "derive_more-impl", +] + +[[package]] +name = "derive_more-impl" +version = "2.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "799a97264921d8623a957f6c3b9011f3b5492f557bbb7a5a19b7fa6d06ba8dcb" +dependencies = [ + "convert_case", + "proc-macro2", + "quote", + "rustc_version", + "syn", + "unicode-xid", +] + [[package]] name = "dispatch" version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bd0c93bb4b0c6d9b77f4435b0ae98c24d17f1c45b2ff844c6151a07256ca923b" +[[package]] +name = "disqualified" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c9c272297e804878a2a4b707cfcfc6d2328b5bb936944613b4fdf2b9269afdfd" + [[package]] name = "dlib" version = "0.5.3" @@ -387,6 +649,12 @@ version = "1.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "75b325c5dbd37f80359721ad39aca5a29fb04c89279657cffdda8736d0c0b9d2" +[[package]] +name = "downcast-rs" +version = "2.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "117240f60069e65410b3ae1bb213295bd828f707b5bec6596a1afc8793ce0cbc" + [[package]] name = "dpi" version = "0.1.2" @@ -405,6 +673,17 @@ version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" +[[package]] +name = "erased-serde" +version = "0.4.10" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d2add8a07dd6a8d93ff627029c51de145e12686fbc36ecb298ac22e74cf02dec" +dependencies = [ + "serde", + "serde_core", + "typeid", +] + [[package]] name = "errno" version = "0.3.14" @@ -427,6 +706,18 @@ version = "0.1.9" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5baebc0774151f905a1a2cc41989300b1e6fbb29aff0ceffa1064fdd3088d582" +[[package]] +name = "fixedbitset" +version = "0.5.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d674e81391d1e1ab681a28d99df07927c6d4aa5b027d7da16ba32d1d21ecd99" + +[[package]] +name = "foldhash" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "77ce24cb58228fbb8aa041425bb1050850ac19177686ea6e0f41a70416f56fdb" + [[package]] name = "foreign-types" version = "0.5.0" @@ -454,12 +745,40 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b" +[[package]] +name = "futures-channel" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "07bbe89c50d7a535e539b8c17bc0b49bdb77747034daa8087407d655f3f7cc1d" +dependencies = [ + "futures-core", +] + [[package]] name = "futures-core" version = "0.3.32" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7e3450815272ef58cec6d564423f6e755e25379b217b0bc688e295ba24df6b1d" +[[package]] +name = "futures-io" +version = "0.3.32" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cecba35d7ad927e23624b22ad55235f2239cfa44fd10428eecbeba6d6a717718" + +[[package]] +name = "futures-lite" +version = "2.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f78e10609fe0e0b3f4157ffab1876319b5b0db102a2c60dc4626306dc46b44ad" +dependencies = [ + "fastrand", + "futures-core", + "futures-io", + "parking", + "pin-project-lite", +] + [[package]] name = "futures-task" version = "0.3.32" @@ -496,10 +815,21 @@ checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", "libc", - "r-efi", + "r-efi 5.3.0", "wasip2", ] +[[package]] +name = "getrandom" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "300e883d756b2e4ec94e02791f39b04b522276138852cfc41d9fb7e904106099" +dependencies = [ + "cfg-if", + "libc", + "r-efi 6.0.0", +] + [[package]] name = "glam" version = "0.27.0" @@ -515,6 +845,15 @@ version = "0.29.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8babf46d4c1c9d92deac9f7be466f76dfc4482b6452fc5024b5e8daf6ffeb3ee" +[[package]] +name = "glam" +version = "0.32.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f70749695b063ecbf6b62949ccccde2e733ec3ecbbd71d467dca4e5c6c97cca0" +dependencies = [ + "serde_core", +] + [[package]] name = "gpu-allocator" version = "0.28.0" @@ -531,12 +870,43 @@ dependencies = [ "windows", ] +[[package]] +name = "hash32" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "47d60b12902ba28e2730cd37e95b8c9223af2808df9e902d4df49588d1470606" +dependencies = [ + "byteorder", +] + +[[package]] +name = "hashbrown" +version = "0.16.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "841d1cc9bed7f9236f321df977030373f4a4163ae1a7dbfe1a51a2c1a51d9100" +dependencies = [ + "equivalent", + "serde", + "serde_core", +] + [[package]] name = "hashbrown" version = "0.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4f467dd6dccf739c208452f8014c75c18bb8301b050ad1cfb27153803edb0f51" +[[package]] +name = "heapless" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "25ba4bd83f9415b58b4ed8dc5714c76e626a105be4646c02630ad730ad3b5aa4" +dependencies = [ + "hash32", + "portable-atomic", + "stable_deref_trait", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -550,7 +920,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" dependencies = [ "equivalent", - "hashbrown", + "hashbrown 0.17.0", ] [[package]] @@ -623,7 +993,7 @@ version = "0.1.34" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9afb3de4395d6b3e67a780b6de64b51c978ecf11cb9a462c66be7d4ca9039d33" dependencies = [ - "getrandom", + "getrandom 0.3.4", "libc", ] @@ -765,6 +1135,12 @@ dependencies = [ "rand_xorshift", ] +[[package]] +name = "nonmax" +version = "0.5.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "610a5acd306ec67f907abe5567859a3c693fb9886eb1f012ab8f2a47bef3db51" + [[package]] name = "nu-ansi-term" version = "0.50.3" @@ -1072,6 +1448,12 @@ dependencies = [ "ttf-parser", ] +[[package]] +name = "parking" +version = "2.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f38d5652c16fde515bb1ecef450ab0f6a219d619a7274976324d5e377f7dceba" + [[package]] name = "percent-encoding" version = "2.3.2" @@ -1130,6 +1512,21 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "portable-atomic" +version = "1.13.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c33a9471896f1c69cecef8d20cbe2f7accd12527ce60845ff44c153bb2a21b49" + +[[package]] +name = "portable-atomic-util" +version = "0.2.7" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c2a106d1259c23fac8e543272398ae0e3c0b8d33c88ed73d0cc71b0f1d902618" +dependencies = [ + "portable-atomic", +] + [[package]] name = "presser" version = "0.3.1" @@ -1178,6 +1575,12 @@ version = "5.3.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f" +[[package]] +name = "r-efi" +version = "6.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" + [[package]] name = "rand" version = "0.8.6" @@ -1415,6 +1818,7 @@ dependencies = [ name = "server" version = "0.1.0" dependencies = [ + "bevy_ecs", "glam 0.27.0", "rayon", "serde_json", @@ -1471,6 +1875,15 @@ version = "0.4.12" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" +[[package]] +name = "slotmap" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bdd58c3c93c3d278ca835519292445cb4b0d4dc59ccfdf7ceadaab3f8aeb4038" +dependencies = [ + "version_check", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -1511,6 +1924,21 @@ dependencies = [ "serde", ] +[[package]] +name = "spin" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d5fe4ccb98d9c292d56fec89a5e07da7fc4cf0dc11e156b41793132775d3e591" +dependencies = [ + "portable-atomic", +] + +[[package]] +name = "stable_deref_trait" +version = "1.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6ce2be8dc25455e1f91df71bfa12ad37d7af1092ae736f3a6cd0e37bc7810596" + [[package]] name = "strict-num" version = "0.1.1" @@ -1699,6 +2127,12 @@ version = "0.25.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d2df906b07856748fa3f6e0ad0cbaa047052d4a7dd609e231c4f72cee8c36f31" +[[package]] +name = "typeid" +version = "1.0.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" + [[package]] name = "unicode-ident" version = "1.0.24" @@ -1711,12 +2145,41 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[package]] +name = "unicode-xid" +version = "0.2.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ebc1c04c71510c7f702b52b7c350734c9ff1295c464a03335b00bb84fc54f853" + +[[package]] +name = "uuid" +version = "1.23.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf80a72845275afea99e7f2b434723d3bc7e38470fcd1c7ed39a599c73319a53" +dependencies = [ + "getrandom 0.4.3", + "js-sys", + "serde_core", + "wasm-bindgen", +] + [[package]] name = "valuable" version = "0.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65" +[[package]] +name = "variadics_please" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "41b6d82be61465f97d42bd1d15bf20f3b0a3a0905018f38f9d6f6962055b0b5c" +dependencies = [ + "proc-macro2", + "quote", + "syn", +] + [[package]] name = "version_check" version = "0.9.5" @@ -1804,7 +2267,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2857dd20b54e916ec7253b3d6b4d5c4d7d4ca2c33c2e11c6c76a99bd8744755d" dependencies = [ "cc", - "downcast-rs", + "downcast-rs 1.2.1", "rustix 1.1.4", "scoped-tls", "smallvec", @@ -1916,6 +2379,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-task" +version = "1.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1cdc136a53ccd64a1211f107ccc34404769fbcc0f165f1afa065f5d88ab93538" +dependencies = [ + "async-task", + "cfg-if", + "js-sys", + "wasm-bindgen", +] + [[package]] name = "web-time" version = "1.1.0" @@ -1926,6 +2401,21 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "wgpu-types" +version = "29.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "84bf84cd9ca8ca45e2b223a3868f1adf9bfc0c66aeac212e76ee7e40fdadf8f5" +dependencies = [ + "bitflags 2.11.1", + "bytemuck", + "js-sys", + "log", + "raw-window-handle", + "serde", + "web-sys", +] + [[package]] name = "winapi-util" version = "0.1.11" diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index 0f8436b..ae4aad7 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -9,6 +9,7 @@ authors.workspace = true workspace = true [dependencies] +bevy_ecs = "0.19" glam = "0.27" rayon = "1.12.0" serde_json = "1.0.149" diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 69742b5..aa7c210 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -5,16 +5,43 @@ //! The server handles the authoritative game simulation, including world //! management, physics, and combat. +/// Entity components describing players and other world-streaming anchors. +pub mod player; /// Authoritative chunk storage and generation logic for the server. pub mod world_server; +use std::collections::HashSet; use std::fs; +use bevy_ecs::prelude::{Query, ResMut, Schedule, With, World}; use glam::Vec3; use shared::generator::{VoxelGenerator, WorldGenConfig}; use shared::world::{ChunkPos, EntityPos}; use tracing::{debug, info}; +use player::{Player, Position, ViewDistance}; +use world_server::{ServerWorld, cylinder_chunks}; + +/// Streaming system: loads and unloads chunks so that the resident set matches the union of the cylinders around every player anchor. +fn stream_chunks( + anchors: Query<(&Position, &ViewDistance), With>, + mut world: ResMut, +) { + // Desired set is the union of every anchor's cylinder; a chunk survives as long as it lies within any one player's view. + let mut desired = HashSet::new(); + for (position, view) in &anchors { + cylinder_chunks(position.0.chunk, view.0, &mut desired); + } + + let stats = world.reconcile(&desired); + info!( + loaded = stats.loaded, + unloaded = stats.unloaded, + resident = stats.resident, + "streaming reconcile" + ); +} + fn main() { tracing_subscriber::fmt() .with_env_filter( @@ -44,10 +71,28 @@ fn main() { let generator = VoxelGenerator::new(worldgen_config, seed); - let mut world = world_server::ServerWorld::new(generator); + let mut world = World::new(); + world.insert_resource(ServerWorld::new(generator)); - // Hardcoded spawn stand-in until a real player entity exists; the chunk anchor drives streaming. - let player = EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::ZERO); + // Spawn a single dummy player anchor at the world origin. + world.spawn(( + Player, + Position(EntityPos::new(ChunkPos::new(0, 0, 0), Vec3::ZERO)), + ViewDistance(4), + )); - world.load_around(player, 4); + // A schedule is one tick's worth of systems; running it advances the world. + let mut schedule = Schedule::default(); + schedule.add_systems(stream_chunks); + + // Drive several ticks, marching the dummy one chunk along +X between each. The manual movement stands in for network-driven player input and exists only to exercise load/unload as the anchor moves. + for step in 0..5 { + info!(step, "tick"); + schedule.run(&mut world); + + let mut movers = world.query_filtered::<&mut Position, With>(); + for mut position in movers.iter_mut(&mut world) { + position.0.chunk.x += 1; + } + } } diff --git a/crates/server/src/player.rs b/crates/server/src/player.rs new file mode 100644 index 0000000..fae25a4 --- /dev/null +++ b/crates/server/src/player.rs @@ -0,0 +1,18 @@ +// SPDX-License-Identifier: AGPL-3.0-only + +//! Entity components describing players and other world-streaming anchors. + +use bevy_ecs::prelude::Component; +use shared::world::EntityPos; + +/// Marker component identifying an entity as a player. +#[derive(Component)] +pub struct Player; + +/// Authoritative world-space position of an entity. +#[derive(Component)] +pub struct Position(pub EntityPos); + +/// Chunk-streaming radius, in chunks, of the cylinder loaded around an entity. +#[derive(Component)] +pub struct ViewDistance(pub i32); diff --git a/crates/server/src/world_server.rs b/crates/server/src/world_server.rs index f2fb406..6b6e1f5 100644 --- a/crates/server/src/world_server.rs +++ b/crates/server/src/world_server.rs @@ -2,16 +2,31 @@ //! Authoritative chunk storage and generation logic for the server. +use bevy_ecs::prelude::Resource; use rayon::prelude::*; use shared::{ generator::VoxelGenerator, - world::{Chunk, ChunkPos, EntityPos}, + world::{Chunk, ChunkPos}, }; -use std::collections::HashMap; +use std::collections::{HashMap, HashSet}; + +/// Outcome of a single streaming reconcile pass, for logging and tests. +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct StreamStats { + /// Number of chunks generated and inserted this pass. + pub loaded: usize, + /// Number of chunks evicted this pass. + pub unloaded: usize, + /// Number of chunks resident after the pass. + pub resident: usize, +} /// The server's authoritative representation of the world. +#[derive(Resource)] pub struct ServerWorld { + /// Deterministic voxel source used to generate missing chunks on demand. generator: VoxelGenerator, + /// Currently resident chunks, keyed by chunk-space position. chunks: HashMap, } @@ -32,42 +47,103 @@ impl ServerWorld { .or_insert_with(|| self.generator.generate_chunk(pos)) } - /// Generates and caches all chunks in a cylindrical region around the given position. - pub fn load_around(&mut self, center: EntityPos, radius: i32) { - // The chunk anchor is the exact streaming key; no lossy world-to-chunk conversion is needed. - let center = center.chunk; + /// Number of chunks currently resident in memory. + #[must_use] + pub fn loaded_count(&self) -> usize { + self.chunks.len() + } - // Phase 1: collect the positions inside the cylinder that are not yet resident - let mut pending = Vec::new(); - for x in center.x - radius..=center.x + radius { - for z in center.z - radius..=center.z + radius { - for y in center.y - radius / 2..=center.y + radius / 2 { - let dx = x - center.x; - let dz = z - center.z; - - if (dx * dx + dz * dz) <= radius * radius { - let pos = ChunkPos::new(x, y, z); - if !self.chunks.contains_key(&pos) { - pending.push(pos); - } - } - } - } + /// Reconciles resident chunks against a desired set: generates the chunks present in `desired` but not resident, and evicts resident chunks absent from `desired`. + pub fn reconcile(&mut self, desired: &HashSet) -> StreamStats { + // Evict resident chunks that no anchor wants any more. + let stale: Vec = self + .chunks + .keys() + .filter(|pos| !desired.contains(pos)) + .copied() + .collect(); + for pos in &stale { + self.chunks.remove(pos); } - // Phase 2: generate the missing chunks in parallel - let generator = &self.generator; - let generated: Vec<(ChunkPos, Chunk)> = pending - .into_par_iter() - .map(|pos| (pos, generator.generate_chunk(pos))) + // Collect the desired positions that are not yet resident. + let pending: Vec = desired + .iter() + .filter(|pos| !self.chunks.contains_key(pos)) + .copied() .collect(); - // Phase 3: move the finished chunks into the map - let count = generated.len(); + // Generate the missing chunks in parallel + let generator = &self.generator; + let generated: Vec<(ChunkPos, Chunk)> = pending + .par_iter() + .map(|&pos| (pos, generator.generate_chunk(pos))) + .collect(); + + let loaded = generated.len(); for (pos, chunk) in generated { self.chunks.insert(pos, chunk); } - tracing::info!(chunks = count, center = ?center, radius, "generated chunk cylinder"); + StreamStats { + loaded, + unloaded: stale.len(), + resident: self.chunks.len(), + } + } +} + +/// Inserts every chunk position inside the streaming cylinder around `center` into `out`. +pub fn cylinder_chunks( + center: ChunkPos, + radius: i32, + out: &mut HashSet, +) { + for x in center.x - radius..=center.x + radius { + for z in center.z - radius..=center.z + radius { + let dx = x - center.x; + let dz = z - center.z; + + // Keep only the columns whose XZ distance falls within the disc. + if dx * dx + dz * dz <= radius * radius { + for y in center.y - radius / 2..=center.y + radius / 2 { + out.insert(ChunkPos::new(x, y, z)); + } + } + } + } +} + +#[cfg(test)] +mod tests { + use super::{ChunkPos, cylinder_chunks}; + use std::collections::HashSet; + + #[test] + fn cylinder_is_symmetric_and_bounded() { + let mut set = HashSet::new(); + cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut set); + + // Center column is always included. + assert!(set.contains(&ChunkPos::new(0, 0, 0))); + // A corner outside the disc (dx=2, dz=2 -> 8 > 4) is excluded. + assert!(!set.contains(&ChunkPos::new(2, 0, 2))); + // An axis cell at exactly the radius is included (dx=2, dz=0 -> 4 == 4). + assert!(set.contains(&ChunkPos::new(2, 0, 0))); + // Vertical extent is radius/2 = 1, so y=2 is out of range. + assert!(!set.contains(&ChunkPos::new(0, 2, 0))); + assert!(set.contains(&ChunkPos::new(0, 1, 0))); + } + + #[test] + fn cylinder_translates_with_center() { + let mut origin = HashSet::new(); + cylinder_chunks(ChunkPos::new(0, 0, 0), 3, &mut origin); + + let mut shifted = HashSet::new(); + cylinder_chunks(ChunkPos::new(10, 0, -5), 3, &mut shifted); + + // Shape is translation-invariant: same count regardless of center. + assert_eq!(origin.len(), shifted.len()); } } From 4399e88412980a8070625722002a89373d97d0c0 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 7 Jul 2026 19:01:16 +0200 Subject: [PATCH 2/9] chore(assets): recompile cube shader spir-v --- assets/shaders/cube.frag.spv | Bin 128 -> 572 bytes assets/shaders/cube.vert.spv | Bin 129 -> 1320 bytes 2 files changed, 0 insertions(+), 0 deletions(-) diff --git a/assets/shaders/cube.frag.spv b/assets/shaders/cube.frag.spv index 9a6a8932f2a6c51ce10ed42665848f9d82dd3559..692e6c1eab87c30dbfd16d8f1994b55f4ec762cb 100644 GIT binary patch literal 572 zcmYk2%}T>i6oijW)7IAhS6M^Qqhj&KDEO zg~_>R?wmVw<226NW_#ANzD?|JjjbWZ#I=g*`Op4rn@6+Ds|yqZa|$7vp|!243f`7U z#=*XDAdIAgmw|SUZZ-STP}92R`GL1sc>Y5WuGhgf|48E?O|m$Ml1&`u$$N~hQ`03` znC_ywz?Guz>zZ45uXcG5uF};;esE-Y*(_fx($f?fPXrb{nyQ~DDOpd8)K=_|v~P)< z3dhp(CVKp*`gT-G3;@u5Pr(JG-_VxZGzpQ;s4%9o@bvmN@7mtlkJq%1aq4RAy^q^NbpF48 zaHivF_1wr_-W;7L-CKZVl_)@|L}r#!h^u47CA0>)QkLQYvq*F)xy&JxudOx+LR diff --git a/assets/shaders/cube.vert.spv b/assets/shaders/cube.vert.spv index befb022fc8f74533327fa6d3cdbcde7d3634ea0c..0b87b2284686937b56b490d5bbf617ba54c7404f 100644 GIT binary patch literal 1320 zcmZ9K-EPxB5QVo%otBoil+qudq$%O&0;%Ex5E24}3Njaw8dQl}D{@R($#pE-semh9 zfmhvpTZV$70RHyh@$sn@n?2{FPt&WD5Z!AV(-Po6!0 z3S-l>9TCo!x#srE`oA36j9^1{TlPTqP}bJ3CI4Fomd%<82T^!-77ji{ah^wI^*v3Z z)MiOEwnY+G_9}t5y1=tpoKDA!gg}hNo`JNktH(te4U^(?QdG$|g+gnbJ-JC~D?7=A zt~fq3INWB{x&5KeoT+nYR_-*l`FmSdqbyElIc|X5G)-r@KyxxX3q9NN!)f{Tbdqtp zDiwF68k}``l{?;!_PKabJSPUqaECUFa<3VTJN;6OF2qeI1$_c{$Ko}JF`;yD7wG?} zJbRY+9;@G&*Prt^1CRMUk3AUoO>Okowdb=0!r=Sa^ii1`|6zT_tWcGht?QyXX0FAU z?}iv{JL*QiWA#Q0`#tSC@;%wU>d}uFjl23^mG8+|Pnwu7m3oJY*W}@Yb+uzYUxOUh z6YsjbpZ`@8^TuHG*>t?RE`5l%WUK?l+yQmb)zvBP2aMV`<>?PbZDPI!F!HyWJ$z=| zmq#nU@^*K>SzJzm)w0`^;;l literal 129 zcmWN`K@!3s3;@78uiyg~l1NPZ8yY~EQQHyPgRi%zJ?xpky=@;`$JqPg+_R0_tMuuz z9_ysr+x0!GJHc!%* Date: Tue, 7 Jul 2026 20:06:09 +0200 Subject: [PATCH 3/9] docs(workspace): add adr for split-coordinate entity positions --- docs/README.md | 1 + .../0008-split-coordinate-entity-positions.md | 26 +++++++++++++++++++ 2 files changed, 27 insertions(+) create mode 100644 docs/adr/0008-split-coordinate-entity-positions.md diff --git a/docs/README.md b/docs/README.md index a1d608c..03f9ace 100644 --- a/docs/README.md +++ b/docs/README.md @@ -37,6 +37,7 @@ Each subsystem note should name the design topic it implements (by title, e.g. " - [`adr/0005-namespaced-content-ids.md`](adr/0005-namespaced-content-ids.md): namespaced content IDs. - [`adr/0006-base-game-on-modding-api.md`](adr/0006-base-game-on-modding-api.md): base game built on the modding API. - [`adr/0007-declarative-content-via-modding-api.md`](adr/0007-declarative-content-via-modding-api.md): declarative content loads through the modding API. + - [`adr/0008-split-coordinate-entity-positions.md`](adr/0008-split-coordinate-entity-positions.md): split-coordinate entity positions. - [`adr/template.md`](adr/template.md): template for new decisions. Subsystem notes: diff --git a/docs/adr/0008-split-coordinate-entity-positions.md b/docs/adr/0008-split-coordinate-entity-positions.md new file mode 100644 index 0000000..4d21a44 --- /dev/null +++ b/docs/adr/0008-split-coordinate-entity-positions.md @@ -0,0 +1,26 @@ +# 0008. Split-coordinate entity positions + +- **Status:** Accepted +- **Date:** 2026-07-07 + +## Context + +In a procedurally generated voxel world, an entity's position cannot be robustly represented by a single global single-precision floating-point vector (`f32` or `Vec3`). At large distances from the origin, the spacing between representable floating-point numbers increases, leading to spatial jitter, physics instability, and rendering artifacts. + +While double-precision floats (`f64`) postpone this issue, they double the data size and are not uniformly or natively supported on GPUs, which expect `f32` vertices and transforms. The engine requires a spatial representation that maintains sub-millimeter precision universally across a theoretically unbounded world, without coupling the simulation state directly to GPU limitations or paying the cost of `f64` everywhere. + +## Decision + +The engine uses a split-coordinate representation for entity positions, encapsulated by the `EntityPos` type. An entity's absolute position is defined by two discrete components: + +1. A `chunk` anchor (`ChunkPos`): The integer coordinates of the chunk containing the entity. +2. A `local` offset (`Vec3`): A single-precision floating-point vector describing the entity's exact position relative to the chunk's minimum corner. + +When an entity moves, the movement is applied to the `local` offset. A normalization step (`EntityPos::renormalize`) then carries any overflow beyond the chunk boundaries into the integer `chunk` anchor, ensuring the `local` offset always remains strictly within the bounding box of a single chunk (`[0.0, CHUNK_SIZE)`). + +## Consequences + +- **Uniform Precision:** Entities maintain exact `f32` precision regardless of how far they travel from the world origin, as the active floating-point magnitude is strictly bounded by the size of a single chunk. +- **Rendering Stability:** The renderer can compute relative matrices by defining the camera's current chunk as the origin. This allows the GPU to process all vertex data and transforms in standard `f32` without any spatial jitter. +- **Math Complexity:** Code manipulating spatial positions (like physics integration and distance checks) becomes more complex. It is no longer possible to simply subtract two global vectors; logic must handle both the chunk offset and the local offset simultaneously. +- **Serialization:** `EntityPos` serializes as a compound struct, ensuring save files do not lose coordinate precision for distant entities. From 9001cc6f98d444b13bf1d9829d32348c31f938de Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 7 Jul 2026 22:21:32 +0200 Subject: [PATCH 4/9] feat(server): generate chunks on worker pool --- Cargo.lock | 44 ++------------ crates/server/Cargo.toml | 2 +- crates/server/src/main.rs | 1 + crates/server/src/world_server.rs | 98 ++++++++++++++++++++++++------- 4 files changed, 82 insertions(+), 63 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index e0b0a95..020db95 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -560,20 +560,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "790eea4361631c5e7d22598ecd5723ff611904e3344ce8720784c93e3d83d40b" [[package]] -name = "crossbeam-deque" -version = "0.8.7" +name = "crossbeam-channel" +version = "0.5.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5181e0de7b61eb03a81e347d6dd8797bae9da5146707b51077e2d71a54ec0ceb" -dependencies = [ - "crossbeam-epoch", - "crossbeam-utils", -] - -[[package]] -name = "crossbeam-epoch" -version = "0.9.20" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d6914041f254d6e9176c01941b21115dcfb7089e55135a35411081bd106ef3f" +checksum = "d85363c37faeca707aef026efa9f3b34d077bce547e48f770770625c6013679e" dependencies = [ "crossbeam-utils", ] @@ -661,12 +651,6 @@ version = "0.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d8b14ccef22fc6f5a8f4d7d768562a182c04ce9a3b3157b91390b52ddfdf1a76" -[[package]] -name = "either" -version = "1.16.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "91622ff5e7162018101f2fea40d6ebf4a78bbe5a49736a2020649edf9693679e" - [[package]] name = "equivalent" version = "1.0.2" @@ -1623,26 +1607,6 @@ dependencies = [ "raw-window-handle", ] -[[package]] -name = "rayon" -version = "1.12.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fb39b166781f92d482534ef4b4b1b2568f42613b53e5b6c160e24cfbfa30926d" -dependencies = [ - "either", - "rayon-core", -] - -[[package]] -name = "rayon-core" -version = "1.13.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22e18b0f0062d30d4230b2e85ff77fdfe4326feb054b9783a3460d8435c8ab91" -dependencies = [ - "crossbeam-deque", - "crossbeam-utils", -] - [[package]] name = "redox_syscall" version = "0.4.1" @@ -1819,8 +1783,8 @@ name = "server" version = "0.1.0" dependencies = [ "bevy_ecs", + "crossbeam-channel", "glam 0.27.0", - "rayon", "serde_json", "shared", "tracing", diff --git a/crates/server/Cargo.toml b/crates/server/Cargo.toml index ae4aad7..87da38d 100644 --- a/crates/server/Cargo.toml +++ b/crates/server/Cargo.toml @@ -10,8 +10,8 @@ workspace = true [dependencies] bevy_ecs = "0.19" +crossbeam-channel = "0.5.16" glam = "0.27" -rayon = "1.12.0" serde_json = "1.0.149" shared = { version = "0.1.0", path = "../shared" } tracing = "0.1.44" diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index aa7c210..6e8d1e4 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -38,6 +38,7 @@ fn stream_chunks( loaded = stats.loaded, unloaded = stats.unloaded, resident = stats.resident, + in_flight = stats.in_flight, "streaming reconcile" ); } diff --git a/crates/server/src/world_server.rs b/crates/server/src/world_server.rs index 6b6e1f5..bed997b 100644 --- a/crates/server/src/world_server.rs +++ b/crates/server/src/world_server.rs @@ -3,12 +3,16 @@ //! Authoritative chunk storage and generation logic for the server. use bevy_ecs::prelude::Resource; -use rayon::prelude::*; +use crossbeam_channel::{Receiver, Sender}; use shared::{ generator::VoxelGenerator, world::{Chunk, ChunkPos}, }; -use std::collections::{HashMap, HashSet}; +use std::{ + collections::{HashMap, HashSet}, + sync::Arc, + thread::JoinHandle, +}; /// Outcome of a single streaming reconcile pass, for logging and tests. #[derive(Copy, Clone, Debug, PartialEq, Eq)] @@ -19,24 +23,71 @@ pub struct StreamStats { pub unloaded: usize, /// Number of chunks resident after the pass. pub resident: usize, + /// Number of chunks in flight. + pub in_flight: usize, } /// The server's authoritative representation of the world. #[derive(Resource)] pub struct ServerWorld { /// Deterministic voxel source used to generate missing chunks on demand. - generator: VoxelGenerator, + generator: Arc, /// Currently resident chunks, keyed by chunk-space position. chunks: HashMap, + /// Sending end of the job channel; the main thread pushes positions that require generation. + job_tx: Sender, + /// Receiving end of the result channel; the main thread drains finished chunks returned by workers. + result_rx: Receiver<(ChunkPos, Chunk)>, + /// Positions dispatched to a worker but not yet returned, preventing the same chunk from being re-dispatched on subsequent passes. + in_flight: HashSet, + /// Handles to the generation worker threads, retained so they can be joined on shutdown. + // Retained ahead of a dedicated shutdown path; not yet read because the server has no graceful-stop sequence. + #[expect(dead_code)] + workers: Vec>, } impl ServerWorld { /// Initializes a new authoritative server world with the provided generator. #[must_use] pub fn new(generator: VoxelGenerator) -> Self { + let generator = Arc::new(generator); + let (job_tx, job_rx) = crossbeam_channel::unbounded::(); + let (result_tx, result_rx) = crossbeam_channel::unbounded::<(ChunkPos, Chunk)>(); + + // One worker per logical core, falling back to a small fixed pool if the platform cannot report its parallelism. + let worker_count = std::thread::available_parallelism().map_or(4, std::num::NonZero::get); + + let workers = (0..worker_count) + .map(|_| { + // Each worker owns its own clones: a shared handle to the read-only generator, its own view of the shared job queue, and its own sender back into the result channel. + let generator = Arc::clone(&generator); + let job_rx = job_rx.clone(); + let result_tx = result_tx.clone(); + + std::thread::spawn(move || { + // Block until a job arrives. + while let Ok(pos) = job_rx.recv() { + let chunk = generator.generate_chunk(pos); + // A send error means the main thread has gone away; nothing left to do but let the worker wind down. + if result_tx.send((pos, chunk)).is_err() { + break; + } + } + }) + }) + .collect(); + + // Drop the template ends left over after cloning. + drop(job_rx); + drop(result_tx); + Self { generator, chunks: HashMap::new(), + job_tx, + result_rx, + in_flight: HashSet::new(), + workers, } } @@ -53,9 +104,21 @@ impl ServerWorld { self.chunks.len() } - /// Reconciles resident chunks against a desired set: generates the chunks present in `desired` but not resident, and evicts resident chunks absent from `desired`. + /// Reconciles resident chunks against a desired set without blocking the caller: finished chunks are drained from the worker pool, resident chunks absent from `desired` are evicted, and still-missing chunks are dispatched to the pool. pub fn reconcile(&mut self, desired: &HashSet) -> StreamStats { - // Evict resident chunks that no anchor wants any more. + // Drain: absorb every chunk the workers have finished since the last pass. + let mut loaded = 0; + while let Ok((pos, chunk)) = self.result_rx.try_recv() { + // The position is no longer dispatched now that its chunk has returned. + self.in_flight.remove(&pos); + // Guard against the eviction race: the anchor may have moved away while this chunk was generating, so a returned chunk is only kept if it is still wanted. + if desired.contains(&pos) { + self.chunks.insert(pos, chunk); + loaded += 1; + } + } + + // Evict: drop resident chunks that no anchor wants any more. let stale: Vec = self .chunks .keys() @@ -66,29 +129,20 @@ impl ServerWorld { self.chunks.remove(pos); } - // Collect the desired positions that are not yet resident. - let pending: Vec = desired - .iter() - .filter(|pos| !self.chunks.contains_key(pos)) - .copied() - .collect(); - - // Generate the missing chunks in parallel - let generator = &self.generator; - let generated: Vec<(ChunkPos, Chunk)> = pending - .par_iter() - .map(|&pos| (pos, generator.generate_chunk(pos))) - .collect(); - - let loaded = generated.len(); - for (pos, chunk) in generated { - self.chunks.insert(pos, chunk); + // Dispatch: request generation for every wanted position that is neither resident nor already in flight. + for &pos in desired { + if !self.chunks.contains_key(&pos) && !self.in_flight.contains(&pos) { + self.in_flight.insert(pos); + // A send error means every worker has shut down; there is nothing useful to do with the position, so the failure is ignored. + let _ = self.job_tx.send(pos); + } } StreamStats { loaded, unloaded: stale.len(), resident: self.chunks.len(), + in_flight: self.in_flight.len(), } } } From 19fdec44d1a6ea96eb3119368f14e8919f596c64 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 7 Jul 2026 22:27:00 +0200 Subject: [PATCH 5/9] test(server): cover async reconcile convergence and eviction race --- crates/server/src/world_server.rs | 71 ++++++++++++++++++++++++++++++- 1 file changed, 70 insertions(+), 1 deletion(-) diff --git a/crates/server/src/world_server.rs b/crates/server/src/world_server.rs index bed997b..bd278b7 100644 --- a/crates/server/src/world_server.rs +++ b/crates/server/src/world_server.rs @@ -170,8 +170,77 @@ pub fn cylinder_chunks( #[cfg(test)] mod tests { - use super::{ChunkPos, cylinder_chunks}; + use super::{ChunkPos, ServerWorld, StreamStats, cylinder_chunks}; + use shared::generator::{VoxelGenerator, WorldGenConfig}; + use shared::world::BlockId; use std::collections::HashSet; + use std::time::{Duration, Instant}; + + /// Builds a server world backed by a real worker pool for streaming tests. + fn test_world() -> ServerWorld { + let config = WorldGenConfig { + base_height: 8, + noise_scale: 0.05, + surface_block: BlockId(1), + subsurface_block: BlockId(2), + stone_block: BlockId(3), + }; + ServerWorld::new(VoxelGenerator::new(config, 42)) + } + + /// Repeatedly reconciles against `desired` until the worker pool reports no outstanding work, returning the final pass's stats. Fails the test if the pool does not drain within a fixed timeout. + fn drain_to_idle(world: &mut ServerWorld, desired: &HashSet) -> StreamStats { + let deadline = Instant::now() + Duration::from_secs(5); + loop { + let stats = world.reconcile(desired); + if stats.in_flight == 0 { + return stats; + } + assert!( + Instant::now() < deadline, + "worker pool did not drain in time" + ); + std::thread::sleep(Duration::from_millis(1)); + } + } + + #[test] + fn reconcile_converges_over_multiple_passes() { + let mut world = test_world(); + let mut desired = HashSet::new(); + cylinder_chunks(ChunkPos::new(0, 0, 0), 2, &mut desired); + + // The first pass only dispatches work; because generation is off-thread, nothing is resident yet and every position is in flight. + let first = world.reconcile(&desired); + assert_eq!(first.loaded, 0); + assert_eq!(first.resident, 0); + assert!(first.in_flight > 0); + + // Later passes drain finished chunks until the pool is idle, at which point every desired position must be resident. + let final_stats = drain_to_idle(&mut world, &desired); + assert_eq!(final_stats.in_flight, 0); + assert_eq!(final_stats.resident, desired.len()); + } + + #[test] + fn evicted_chunk_is_not_repopulated_on_arrival() { + let mut world = test_world(); + let target = ChunkPos::new(0, 0, 0); + let mut desired = HashSet::new(); + desired.insert(target); + + // Dispatch the chunk, then immediately stop wanting it. + world.reconcile(&desired); + + // Every subsequent pass reconciles against an empty desired set, so whenever the worker returns the chunk the drain guard discards it rather than resurrecting an unwanted chunk. + let empty = HashSet::new(); + let final_stats = drain_to_idle(&mut world, &empty); + assert_eq!(final_stats.in_flight, 0); + assert_eq!( + final_stats.resident, 0, + "a chunk that is no longer wanted must not become resident when it arrives" + ); + } #[test] fn cylinder_is_symmetric_and_bounded() { From ca39d36a81532e8e084b226c0794fe172cd04352 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 7 Jul 2026 22:29:50 +0200 Subject: [PATCH 6/9] feat(server): gate startup on initial region streaming --- crates/server/src/main.rs | 19 +++++++++++++++++++ crates/server/src/world_server.rs | 12 ++++++++++++ 2 files changed, 31 insertions(+) diff --git a/crates/server/src/main.rs b/crates/server/src/main.rs index 6e8d1e4..710db45 100644 --- a/crates/server/src/main.rs +++ b/crates/server/src/main.rs @@ -86,6 +86,25 @@ fn main() { let mut schedule = Schedule::default(); schedule.add_systems(stream_chunks); + // Loading phase: dispatch the initial region and wait for the worker pool to finish before granting control. + info!("Streaming initial region"); + loop { + schedule.run(&mut world); + + let server_world = world.resource::(); + let resident = server_world.loaded_count(); + let in_flight = server_world.in_flight_count(); + // Loading progress is simply the resident fraction of all known chunks. + let total = resident + in_flight; + debug!(resident, in_flight, total, "loading progress"); + + // The region is ready once at least one chunk has been generated and none remain in flight. + if server_world.streaming_idle() && resident > 0 { + break; + } + } + info!("Initial region ready; granting player control"); + // Drive several ticks, marching the dummy one chunk along +X between each. The manual movement stands in for network-driven player input and exists only to exercise load/unload as the anchor moves. for step in 0..5 { info!(step, "tick"); diff --git a/crates/server/src/world_server.rs b/crates/server/src/world_server.rs index bd278b7..b6eac45 100644 --- a/crates/server/src/world_server.rs +++ b/crates/server/src/world_server.rs @@ -104,6 +104,18 @@ impl ServerWorld { self.chunks.len() } + /// Number of chunks dispatched to the worker pool but not yet returned. + #[must_use] + pub fn in_flight_count(&self) -> usize { + self.in_flight.len() + } + + /// Returns `true` when the worker pool has no outstanding generation work, i.e. every dispatched chunk has been returned. A loading gate can poll this to decide when an initial region has finished streaming. + #[must_use] + pub fn streaming_idle(&self) -> bool { + self.in_flight.is_empty() + } + /// Reconciles resident chunks against a desired set without blocking the caller: finished chunks are drained from the worker pool, resident chunks absent from `desired` are evicted, and still-missing chunks are dispatched to the pool. pub fn reconcile(&mut self, desired: &HashSet) -> StreamStats { // Drain: absorb every chunk the workers have finished since the last pass. From 213995eb776d4856f452d4b27cc9f57b1585d11b Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 7 Jul 2026 22:31:38 +0200 Subject: [PATCH 7/9] docs(server): document chunk streaming and async worker pipeline --- docs/chunk_streaming.md | 60 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 60 insertions(+) create mode 100644 docs/chunk_streaming.md diff --git a/docs/chunk_streaming.md b/docs/chunk_streaming.md new file mode 100644 index 0000000..2d20e8a --- /dev/null +++ b/docs/chunk_streaming.md @@ -0,0 +1,60 @@ +# Chunk streaming + +How the server keeps the set of resident chunks in sync with where players are, and how chunk generation is kept off the simulation tick. The generating side lives in [`crates/shared/src/generator.rs`](../crates/shared/src/generator.rs); the streaming and storage side in [`crates/server/src/world_server.rs`](../crates/server/src/world_server.rs), driven from [`crates/server/src/main.rs`](../crates/server/src/main.rs). + +## Overview + +Chunk residency is *reconciled* every tick against a **desired set**: the union of a cylinder of chunks around every player anchor. Chunks inside the desired set are made resident; chunks outside it are evicted. Generation of a missing chunk is expensive, so it is performed on a dedicated worker-thread pool rather than inline on the tick. Reconciliation is therefore non-blocking: each pass *drains* whatever chunks the pool has finished, *evicts* what is no longer wanted, and *dispatches* what is still missing, without ever waiting for a chunk to be generated. + +## Desired set + +`cylinder_chunks(center, radius, out)` inserts every chunk position within the streaming cylinder around `center` into `out`. The shape is a disc in XZ (`dx² + dz² ≤ radius²`) extruded vertically to `±radius/2`, reflecting the fact that horizontal view distance exceeds vertical. + +The ECS streaming system `stream_chunks` (in `main.rs`) queries every entity carrying `Player`, `Position`, and `ViewDistance`, and unions each anchor's cylinder into one `HashSet`. Because the sets are unioned, overlapping cylinders deduplicate automatically and a chunk is evicted only when *no* player wants it. See [Multiplayer](#multiplayer) below. + +## The worker pool + +`ServerWorld::new` builds the pool once. Its structure: + +- **Generator sharing:** the `VoxelGenerator` is wrapped in an `Arc` and a handle is cloned into every worker. `generate_chunk(&self)` is read-only, so no `Mutex` is required; the workers share one immutable generator. +- **Job channel (main → workers):** a `crossbeam-channel` carrying `ChunkPos`. `crossbeam-channel` is used rather than `std::sync::mpsc` because the pool needs **multiple consumers**: every worker clones the `Receiver` and pulls from the one shared queue, and each job is delivered to exactly one worker. `std::sync::mpsc` permits only a single consumer. +- **Result channel (workers → main):** a `crossbeam-channel` carrying `(ChunkPos, Chunk)`. Each worker clones the `Sender`; the main thread holds the single `Receiver`. +- **Worker loop:** each worker blocks on `job_rx.recv()`, generates the chunk, and sends `(pos, chunk)` back. A blocking `recv` on a worker thread is acceptable because it is not the simulation thread. + +### Channel disconnection and shutdown + +A `crossbeam-channel` reports disconnection (its `recv` returns `Err`) only once *all* senders — or, symmetrically, all receivers — have been dropped. After the spawn loop, the template `job_rx` and `result_tx` that were cloned from are dropped immediately. Retaining either would keep its channel open forever: workers would never observe job-channel shutdown, and the main thread would never observe the result channel closing. Worker `JoinHandle`s are retained on `ServerWorld` for a future graceful-stop path that drops `job_tx` and joins the threads; the process currently relies on OS teardown at exit. + +## Reconcile: drain → evict → dispatch + +`ServerWorld::reconcile(&mut self, desired)` runs three non-blocking phases per pass: + +1. **Drain.** `result_rx.try_recv()` is pulled in a loop until empty (`try_recv` never blocks). Each returned position is removed from `in_flight`. A returned chunk is inserted into the resident map **only if it is still in `desired`** — see [the eviction race](#the-eviction-race). +2. **Evict.** Resident chunks absent from `desired` are removed. In-flight chunks that are no longer wanted need no handling here; the drain guard discards them when they arrive. +3. **Dispatch.** For every position in `desired` that is neither resident nor already in `in_flight`, the position is inserted into `in_flight` and sent on `job_tx`. The `in_flight` set is what prevents the same position being re-queued on every pass while a worker is still generating it. + +`in_flight` therefore tracks positions dispatched but not yet returned, and is the single source of truth for "work outstanding." + +### The eviction race + +Between a chunk being dispatched and the worker returning it, the anchor may move so that the chunk is no longer wanted. Without a guard, the drain phase would insert the now-unwanted chunk, resurrecting a chunk that the evict phase had already discarded (or would never be asked to discard, since it was never resident). The guard in phase 1 — insert only if `desired.contains(&pos)` — makes a late arrival harmless: an unwanted chunk is dropped on arrival rather than made resident. + +## Startup loading gate + +Startup reuses the *same* worker pool and schedule; there is no separate synchronous loading path. Before granting player control, `main` runs the streaming schedule in a loop and polls `ServerWorld::streaming_idle()` (true when `in_flight` is empty). Once the initial region has at least one resident chunk and no work in flight, the region is ready. Waiting here is acceptable because no gameplay is running yet. During play the same reconcile runs every tick but is **never** waited on. A loading progress fraction is available as `resident / (resident + in_flight)`. + +## Multiplayer + +No per-player streaming pipeline exists. Every player anchor's cylinder is unioned into one desired set, reconciled against one chunk store served by one worker pool. A player joining or leaving is simply an entity entering or leaving the ECS query; it requires no streaming-specific code. The only per-player concern is the loading gate, which for a joining player checks that player's cylinder against the resident set rather than the global set. Backpressure and fairness across players (a bounded job channel, nearest-first priority) are shared-pipeline concerns deferred for later. + +## Level of detail + +Each job is currently a full-detail (LOD0) chunk. When LOD is introduced, the job payload grows from `ChunkPos` to `(ChunkPos, Lod)`; the worker-pool plumbing is LOD-agnostic and does not change. + +## Testing + +The pure cylinder math and the async reconcile behaviour are unit-tested in `world_server.rs`: + +- `cylinder_chunks` symmetry, boundary inclusion, and translation invariance. +- `reconcile_converges_over_multiple_passes`: an initial pass dispatches work and leaves nothing resident; repeated passes drain the pool until every desired position is resident. +- `evicted_chunk_is_not_repopulated_on_arrival`: a dispatched chunk that stops being wanted is discarded on arrival and never becomes resident. This test is timing-independent because every pass after dispatch reconciles against an empty desired set. From e6895de451e0e709caf6a5c25a775bf922eb6ce6 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 7 Jul 2026 22:37:58 +0200 Subject: [PATCH 8/9] docs(server): index chunk streaming note in docs README --- docs/README.md | 1 + 1 file changed, 1 insertion(+) diff --git a/docs/README.md b/docs/README.md index 03f9ace..cfcf2d3 100644 --- a/docs/README.md +++ b/docs/README.md @@ -44,5 +44,6 @@ Subsystem notes: - [`packs.md`](packs.md): data packs & resource packs (load order, layout, resolution). - [`rendering.md`](rendering.md): rendering & coordinate gotchas (Vulkan clip space, Blender/glTF import). +- [`chunk_streaming.md`](chunk_streaming.md): chunk streaming and async worker pipeline. Further subsystem notes are added here as systems are implemented and locked. From 12390d30088acb38a5a21761738117bbf9940e95 Mon Sep 17 00:00:00 2001 From: Serkyo Date: Tue, 7 Jul 2026 22:47:07 +0200 Subject: [PATCH 9/9] refactor(server): drop unused synchronous get_chunk path --- crates/server/src/world_server.rs | 10 ---------- 1 file changed, 10 deletions(-) diff --git a/crates/server/src/world_server.rs b/crates/server/src/world_server.rs index b6eac45..57946bf 100644 --- a/crates/server/src/world_server.rs +++ b/crates/server/src/world_server.rs @@ -30,8 +30,6 @@ pub struct StreamStats { /// The server's authoritative representation of the world. #[derive(Resource)] pub struct ServerWorld { - /// Deterministic voxel source used to generate missing chunks on demand. - generator: Arc, /// Currently resident chunks, keyed by chunk-space position. chunks: HashMap, /// Sending end of the job channel; the main thread pushes positions that require generation. @@ -82,7 +80,6 @@ impl ServerWorld { drop(result_tx); Self { - generator, chunks: HashMap::new(), job_tx, result_rx, @@ -91,13 +88,6 @@ impl ServerWorld { } } - /// Retrieves a reference to the chunk at the given position, generating it if necessary. - pub fn get_chunk(&mut self, pos: ChunkPos) -> &Chunk { - self.chunks - .entry(pos) - .or_insert_with(|| self.generator.generate_chunk(pos)) - } - /// Number of chunks currently resident in memory. #[must_use] pub fn loaded_count(&self) -> usize {