21 Commits

Author SHA1 Message Date
102dd7fa6f improve closest_time_to_point accuracy 2026-03-17 11:19:33 -07:00
f8996c958c integration test 2026-03-17 09:35:50 -07:00
f91fcf6b6f fix huge bvh node at end 2026-03-17 09:35:50 -07:00
4593514954 fix implementation mistake 2026-03-16 09:19:05 -07:00
31a3e31e70 avoid ugly .0 tuple access 2026-03-14 18:26:02 -07:00
4873e0298c discard unnecessary comment information 2026-03-14 09:36:46 -07:00
637fb38131 factor in world offset 2026-03-13 18:38:24 -07:00
ae624f90dc bvh demo 2026-03-13 10:56:04 -07:00
1d17e6acf0 add Bvh 2026-03-13 10:56:04 -07:00
a53cf8a8c7 delete get_angles 2026-03-13 10:40:40 -07:00
9007de1a2d bodge mp4 files 2026-03-11 09:07:31 -07:00
6df057de17 add numerous errors 2026-03-11 09:07:31 -07:00
4fe2eed922 encode command 2026-03-11 09:07:31 -07:00
e83d0e5ff9 mv 2026-03-11 07:25:20 -07:00
4587d8161d add clap dep 2026-03-11 07:23:17 -07:00
7b56dacb73 wgpu workspace dep 2026-03-11 07:23:17 -07:00
f19e846e0f use normal format 2026-03-10 10:09:11 -07:00
6240b0ae86 try tricks of the trade to fix color 2026-03-10 09:58:09 -07:00
8d1ec94ac2 add video encoder 2026-03-10 09:12:59 -07:00
f82d860822 update graphics 2026-03-10 07:55:22 -07:00
c8eb2f7878 update graphics to remove SurfaceConfig from interface 2026-03-09 11:31:11 -07:00
24 changed files with 1292 additions and 90 deletions

351
Cargo.lock generated
View File

@@ -73,6 +73,56 @@ dependencies = [
"libc",
]
[[package]]
name = "anstream"
version = "0.6.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "43d5b281e737544384e969a5ccad3f1cdd24b48086a0fc1b2a5262a26b8f4f4a"
dependencies = [
"anstyle",
"anstyle-parse",
"anstyle-query",
"anstyle-wincon",
"colorchoice",
"is_terminal_polyfill",
"utf8parse",
]
[[package]]
name = "anstyle"
version = "1.0.13"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5192cca8006f1fd4f7237516f40fa183bb07f8fbdfedaa0036de5ea9b0b45e78"
[[package]]
name = "anstyle-parse"
version = "0.2.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4e7644824f0aa2c7b9384579234ef10eb7efb6a0deb83f9630a49594dd9c15c2"
dependencies = [
"utf8parse",
]
[[package]]
name = "anstyle-query"
version = "1.1.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "anstyle-wincon"
version = "3.0.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
dependencies = [
"anstyle",
"once_cell_polyfill",
"windows-sys 0.61.2",
]
[[package]]
name = "array-init"
version = "2.1.0"
@@ -169,6 +219,12 @@ version = "2.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "843867be96c8daad0d758b57df9392b6d8d271134fce549de6ce169ff98a92af"
[[package]]
name = "bitstream-io"
version = "2.6.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6099cdc01846bc367c4e7dd630dc5966dccf36b652fae7a74e17b640411a91b2"
[[package]]
name = "block"
version = "0.1.6"
@@ -284,6 +340,46 @@ version = "0.2.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
[[package]]
name = "clap"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2797f34da339ce31042b27d23607e051786132987f595b02ba4f6a6dffb7030a"
dependencies = [
"clap_builder",
"clap_derive",
]
[[package]]
name = "clap_builder"
version = "4.5.60"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "24a241312cea5059b13574bb9b3861cabf758b879c15190b37b6d6fd63ab6876"
dependencies = [
"anstream",
"anstyle",
"clap_lex",
"strsim",
]
[[package]]
name = "clap_derive"
version = "4.5.55"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a92793da1a46a5f2a02a6f4c46c6496b28c43638adea8306fcb0caa1634f24e5"
dependencies = [
"heck",
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "clap_lex"
version = "1.0.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3a822ea5bc7590f9d40f1ba12c0dc3c2760f3482c6984db1573ad11031420831"
[[package]]
name = "codespan-reporting"
version = "0.12.0"
@@ -295,6 +391,12 @@ dependencies = [
"unicode-width",
]
[[package]]
name = "colorchoice"
version = "1.0.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b05b61dc5112cbb17e4b6cd61790d9845d13888356391624cbe7e41efeac1e75"
[[package]]
name = "combine"
version = "4.6.7"
@@ -405,6 +507,17 @@ dependencies = [
"num-traits",
]
[[package]]
name = "derivative"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fcc3dd5e9e9c0b295d6e1e4d811fb6f157d5ffd784b8d202fc62eac8035a770b"
dependencies = [
"proc-macro2",
"quote",
"syn 1.0.109",
]
[[package]]
name = "dispatch"
version = "0.2.0"
@@ -531,6 +644,12 @@ version = "0.3.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "aa9a19cbb55df58761df49b23516a86d432839add4af60fc256da840f66ed35b"
[[package]]
name = "four-cc"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "795cbfc56d419a7ce47ccbb7504dd9a5b7c484c083c356e797de08bd988d9629"
[[package]]
name = "futures-core"
version = "0.3.32"
@@ -649,6 +768,19 @@ dependencies = [
"bitflags 2.11.0",
]
[[package]]
name = "h264-reader"
version = "0.8.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "036a78b2620d92f0ec57690bc792b3bb87348632ee5225302ba2e66a48021c6c"
dependencies = [
"bitstream-io",
"hex-slice",
"log",
"memchr",
"rfc6381-codec",
]
[[package]]
name = "half"
version = "2.7.1"
@@ -681,12 +813,24 @@ dependencies = [
"foldhash 0.2.0",
]
[[package]]
name = "heck"
version = "0.5.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
[[package]]
name = "hermit-abi"
version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fc0fef456e4baa96da950455cd02c081ca953b141298e41db3fc7e36b1da849c"
[[package]]
name = "hex-slice"
version = "0.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "5491a308e0214554f07a81d8944abe45f552871c12e3c3c6e7e5d354039a6c4c"
[[package]]
name = "hexf-parse"
version = "0.2.1"
@@ -714,6 +858,21 @@ dependencies = [
"hashbrown 0.16.1",
]
[[package]]
name = "integration-tests"
version = "0.1.0"
dependencies = [
"strafesnet_common",
"strafesnet_roblox_bot_file",
"strafesnet_roblox_bot_player",
]
[[package]]
name = "is_terminal_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
[[package]]
name = "itertools"
version = "0.14.0"
@@ -723,6 +882,12 @@ dependencies = [
"either",
]
[[package]]
name = "itoa"
version = "1.0.17"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92ecc6618181def0457392ccd0ee51198e065e016d1d527a7ac1b6dc7c1f09d2"
[[package]]
name = "jni"
version = "0.21.1"
@@ -899,6 +1064,35 @@ dependencies = [
"paste",
]
[[package]]
name = "mp4"
version = "0.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9ef834d5ed55e494a2ae350220314dc4aacd1c43a9498b00e320e0ea352a5c3"
dependencies = [
"byteorder",
"bytes",
"num-rational",
"serde",
"serde_json",
"thiserror 1.0.69",
]
[[package]]
name = "mp4ra-rust"
version = "0.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fdbc3d3867085d66ac6270482e66f3dd2c5a18451a3dc9ad7269e94844a536b7"
dependencies = [
"four-cc",
]
[[package]]
name = "mpeg4-audio-const"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "96a1fe2275b68991faded2c80aa4a33dba398b77d276038b8f50701a22e55918"
[[package]]
name = "naga"
version = "28.0.0"
@@ -919,7 +1113,7 @@ dependencies = [
"log",
"num-traits",
"once_cell",
"rustc-hash",
"rustc-hash 1.1.0",
"spirv",
"thiserror 2.0.18",
"unicode-ident",
@@ -955,6 +1149,37 @@ dependencies = [
"jni-sys",
]
[[package]]
name = "num-bigint"
version = "0.4.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a5e44f723f1133c9deac646763579fdb3ac745e418f2a7af9cd0c431da1f20b9"
dependencies = [
"num-integer",
"num-traits",
]
[[package]]
name = "num-integer"
version = "0.1.46"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7969661fd2958a5cb096e56c8e1ad0444ac2bbcd0061bd28660485a44879858f"
dependencies = [
"num-traits",
]
[[package]]
name = "num-rational"
version = "0.4.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824"
dependencies = [
"num-bigint",
"num-integer",
"num-traits",
"serde",
]
[[package]]
name = "num-traits"
version = "0.2.19"
@@ -1205,6 +1430,12 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "once_cell_polyfill"
version = "1.70.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
[[package]]
name = "orbclient"
version = "0.3.50"
@@ -1452,12 +1683,28 @@ version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832"
[[package]]
name = "rfc6381-codec"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ed54c20f5c3ec82eab6d998b313dc75ec5d5650d4f57675e61d72489040297fd"
dependencies = [
"mp4ra-rust",
"mpeg4-audio-const",
]
[[package]]
name = "rustc-hash"
version = "1.1.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08d43f7aa6b08d49f382cde6a7982047c3426db949b1424bc4b7ec9ae12c6ce2"
[[package]]
name = "rustc-hash"
version = "2.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "357703d41365b4b27c590e3ed91eabb1b663f07c4c084095e60cbed4362dff0d"
[[package]]
name = "rustix"
version = "0.38.44"
@@ -1554,6 +1801,19 @@ dependencies = [
"syn 2.0.117",
]
[[package]]
name = "serde_json"
version = "1.0.149"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "83fc039473c5595ace860d8c4fafa220ff474b3fc6bfdb4293327f1a37e94d86"
dependencies = [
"itoa",
"memchr",
"serde",
"serde_core",
"zmij",
]
[[package]]
name = "shlex"
version = "1.3.0"
@@ -1632,9 +1892,9 @@ checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f"
[[package]]
name = "strafesnet_common"
version = "0.8.6"
version = "0.8.7"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "fb31424f16d189979d9f5781067ff29169a258c11da6ff46a4196bffd96d61dc"
checksum = "ac4eb613a8d86986b61aa6b52bd74ef605d370c149778fe96cfab16dc4377636"
dependencies = [
"arrayvec",
"bitflags 2.11.0",
@@ -1647,9 +1907,9 @@ dependencies = [
[[package]]
name = "strafesnet_graphics"
version = "0.0.5"
version = "0.0.7"
source = "sparse+https://git.itzana.me/api/packages/strafesnet/cargo/"
checksum = "786feeee41d2a65707eea824ad928f1dad5be3fc82e80d541bb36e45fda1c9d6"
checksum = "5b0afe15871f8205f3387dd5816e797c0576429d3013a33a3daed293bcaeedef"
dependencies = [
"bytemuck",
"ddsfile",
@@ -1729,6 +1989,12 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6637bab7722d379c8b41ba849228d680cc12d0a45ba1fa2b48f2a30577a06731"
[[package]]
name = "strsim"
version = "0.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
[[package]]
name = "syn"
version = "1.0.109"
@@ -1862,14 +2128,29 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"
dependencies = [
"pin-project-lite",
"tracing-attributes",
"tracing-core",
]
[[package]]
name = "tracing-attributes"
version = "0.1.31"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.117",
]
[[package]]
name = "tracing-core"
version = "0.1.36"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"
dependencies = [
"once_cell",
]
[[package]]
name = "ttf-parser"
@@ -1895,12 +2176,64 @@ version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b4ac048d71ede7ee76d585517add45da530660ef4390e49b098733c6e897f254"
[[package]]
name = "utf8parse"
version = "0.2.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
[[package]]
name = "version_check"
version = "0.9.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0b928f33d975fc6ad9f86c8f283853ad26bdd5b10b7f1542aa2fa15e2289105a"
[[package]]
name = "video-encoder"
version = "0.1.0"
dependencies = [
"clap",
"glam",
"mp4",
"strafesnet_common",
"strafesnet_graphics",
"strafesnet_roblox_bot_file",
"strafesnet_roblox_bot_player",
"strafesnet_snf",
"vk-video",
"wgpu",
]
[[package]]
name = "vk-mem"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0cb12b79bcec57a3334d0284f1364c1846f378bb47df9779c6dbfcfc245c9404"
dependencies = [
"ash",
"bitflags 2.11.0",
"cc",
]
[[package]]
name = "vk-video"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "66b2031523b3ed32d99d650ace95b70606bba44fc7a1178ba7bbbe1c17fa0a2b"
dependencies = [
"ash",
"bytes",
"cfg_aliases",
"derivative",
"h264-reader",
"memchr",
"rustc-hash 2.1.1",
"thiserror 1.0.69",
"tracing",
"vk-mem",
"wgpu",
]
[[package]]
name = "walkdir"
version = "2.5.0"
@@ -2160,7 +2493,7 @@ dependencies = [
"portable-atomic",
"profiling",
"raw-window-handle",
"rustc-hash",
"rustc-hash 1.1.0",
"smallvec",
"thiserror 2.0.18",
"wgpu-core-deps-apple",
@@ -2693,3 +3026,9 @@ dependencies = [
"quote",
"syn 2.0.117",
]
[[package]]
name = "zmij"
version = "1.0.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b8848ee67ecc8aedbaf3e4122217aff892639231befc6a1b58d29fff4c2cabaa"

View File

@@ -1,7 +1,9 @@
[workspace]
members = [
"integration-tests",
"lib",
"native-player",
"video-encoder",
"wasm-module"
]
resolver = "3"
@@ -13,10 +15,16 @@ codegen-units = 1
[workspace.dependencies]
glam = "0.32.0"
wgpu = "28.0.0"
strafesnet_common = { version = "0.8.6", registry = "strafesnet" }
strafesnet_graphics = { version = "0.0.5", registry = "strafesnet" }
strafesnet_graphics = { version = "0.0.7", registry = "strafesnet" }
strafesnet_roblox_bot_file = { version = "0.9.3", registry = "strafesnet" }
strafesnet_snf = { version = "0.3.2", registry = "strafesnet" }
strafesnet_roblox_bot_player = { version = "0.2.0", path = "lib" }
# strafesnet_common = { path = "../strafe-project/lib/common" }
# strafesnet_graphics = { path = "../strafe-project/engine/graphics" }
# strafesnet_roblox_bot_file = { path = "../roblox_bot_file" }
# strafesnet_snf = { path = "../strafe-project/lib/snf" }

View File

@@ -0,0 +1,9 @@
[package]
name = "integration-tests"
version = "0.1.0"
edition = "2024"
[dependencies]
strafesnet_common.workspace = true
strafesnet_roblox_bot_file.workspace = true
strafesnet_roblox_bot_player.workspace = true

View File

@@ -0,0 +1,25 @@
use strafesnet_roblox_bot_file::v0;
use strafesnet_roblox_bot_player::{bot,bvh,head};
use head::Time as PlaybackTime;
use strafesnet_common::session::Time as SessionTime;
fn main(){
let bot=include_bytes!("../../web-demo/bhop_marble_7cf33a64-7120-4514-b9fa-4fe29d9523d.qbot");
let timelines=v0::read_all_to_block(std::io::Cursor::new(bot)).unwrap();
let bot=bot::CompleteBot::new(timelines);
let bvh=bvh::Bvh::new(&bot);
// sample the position at 0.24s
let mut playback0=head::PlaybackHead::new(&bot,SessionTime::ZERO);
for i in 0..10{
let sample_time=PlaybackTime::from_millis(6543+1*i);
playback0.set_time(&bot,SessionTime::ZERO,sample_time);
let pos=playback0.get_position(&bot,SessionTime::ZERO);
// get the closest time on the timeline (convert to PlaybackTime which starts at 0)
let closest_time=bot.playback_time(bvh.closest_time_to_point(&bot,pos).unwrap());
println!("time={sample_time} closest_time={closest_time}");
}
// let mut playback1=head::PlaybackHead::new(&bot,SessionTime::ZERO);
// playback1.set_time(&bot,SessionTime::ZERO,sample_time);
}

View File

@@ -5,7 +5,7 @@ edition = "2024"
[dependencies]
glam.workspace = true
wgpu.workspace = true
strafesnet_common.workspace = true
strafesnet_graphics.workspace = true
strafesnet_roblox_bot_file.workspace = true
wgpu = "28.0.0"

View File

@@ -34,6 +34,10 @@ impl CompleteBot{
pub fn time(&self,time:PlaybackTime)->PhysicsTime{
self.timer.time(time)
}
pub fn playback_time(&self,time:PhysicsTime)->PlaybackTime{
use strafesnet_common::timer::TimerState;
time.coerce()-self.timer.clone().into_state().get_offset().coerce()
}
pub const fn duration(&self)->PhysicsTime{
self.duration
}

162
lib/src/bvh.rs Normal file
View File

@@ -0,0 +1,162 @@
use core::ops::Range;
use strafesnet_common::aabb::Aabb;
use strafesnet_common::bvh::generate_bvh;
use strafesnet_common::integer::vec3;
use strafesnet_common::integer::{Fixed,Planar64};
use strafesnet_common::physics::Time as PhysicsTime;
use crate::bot::CompleteBot;
use strafesnet_roblox_bot_file::v0;
const MAX_SLICE_LEN:usize=16;
struct EventSlice{
slice:Range<usize>,
inclusive:bool,
}
pub struct Bvh{
bvh:strafesnet_common::bvh::BvhNode<EventSlice>,
}
impl Bvh{
pub fn new(bot:&CompleteBot)->Self{
let output_events=&bot.timelines().output_events;
// iterator over the event timeline and capture slices of contiguous output events.
// create an Aabb for each slice and then generate a BVH.
let mut bvh_nodes=Vec::new();
let it=output_events
.array_windows()
.enumerate()
// find discontinuities
.filter(|&(_,[event0,event1])|
event0.time==event1.time&&!(
event0.event.position.x==event1.event.position.x
&&event0.event.position.y==event1.event.position.y
&&event0.event.position.z==event1.event.position.z
)
);
let mut last_index=0;
let mut push_slices=|index:usize|{
let len=index-last_index;
let count=len.div_ceil(MAX_SLICE_LEN);
let slice_len=MAX_SLICE_LEN;
bvh_nodes.reserve(count);
// 0123456789
// split into groups of MAX_SLICE_LEN=4
// [0123][4567][89]
let mut push_slice=|slice:Range<usize>,inclusive:bool|{
let mut aabb=Aabb::default();
for event in &output_events[slice.start..slice.end]{
aabb.grow(vec3::try_from_f32_array([event.event.position.x,event.event.position.y,event.event.position.z]).unwrap());
}
if inclusive{
let event=&output_events[slice.end];
aabb.grow(vec3::try_from_f32_array([event.event.position.x,event.event.position.y,event.event.position.z]).unwrap());
}
bvh_nodes.push((EventSlice{slice,inclusive},aabb));
};
// push fixed-size groups
for i in 0..count-1{
push_slice((last_index+i*slice_len)..(last_index+(i+1)*slice_len),true);
}
// push last group which may be shorter
push_slice((last_index+(count-1)*slice_len)..index,false);
last_index=index;
};
// find discontinuities (teleports) and avoid forming a bvh node across them
for (split_index,_) in it{
// we want to use the index of event1
push_slices(split_index+1);
}
// there are no more discontinuities, push the remaining slices
push_slices(output_events.len());
let bvh=generate_bvh(bvh_nodes);
Self{bvh}
}
/// Find the exact timestamp on the bot timeline that is closest to the given point.
pub fn closest_time_to_point<'a>(&self,bot:&'a CompleteBot,point:glam::Vec3)->Option<PhysicsTime>{
let point=point+bot.world_offset();
let start_point=vec3::try_from_f32_array(point.to_array()).unwrap();
let output_events=&bot.timelines().output_events;
// grow a sphere starting at start_point until we find the closest point on the bot output events
let intersect_leaf=|event_slice:&EventSlice|{
// calculate the distance to the leaf contents
let mut best_distance=output_events[event_slice.slice.start..event_slice.slice.end].iter().map(|event|{
let p=event.event.position;
let p=vec3::try_from_f32_array([p.x,p.y,p.z]).unwrap();
(start_point-p).length_squared()
}).min()?;
let mut prev_event=&output_events[event_slice.slice.start];
let mut f=|event:&'a v0::Timed<v0::OutputEvent>|{
let p0=vec3::try_from_f32_array([prev_event.event.position.x,prev_event.event.position.y,prev_event.event.position.z]).unwrap();
let p1=vec3::try_from_f32_array([event.event.position.x,event.event.position.y,event.event.position.z]).unwrap();
let d=p1-p0;
let d0=p0.dot(d);
let d1=p1.dot(d);
let sp_d=start_point.dot(d);
// must be on the segment
if d0<sp_d&&sp_d<d1{
let t0=d1-sp_d;
let t1=sp_d-d0;
let dt=d1-d0;
let distance=(((p0*t0+p1*t1)/dt).divide().wrap_1()-start_point).length_squared();
if distance<best_distance{
best_distance=distance;
}
}
prev_event=event;
};
for event in &output_events[event_slice.slice.start+1..event_slice.slice.end]{
f(event);
}
if event_slice.inclusive{
f(&output_events[event_slice.slice.end]);
}
Some(best_distance)
};
let intersect_aabb=|aabb:&Aabb|{
// calculate the distance to the aabb
let clamped_point=start_point.min(aabb.max()).max(aabb.min());
Some((start_point-clamped_point).length_squared())
};
let (_,event_slice)=self.bvh.traverse(start_point,Fixed::ZERO,Fixed::MAX,intersect_leaf,intersect_aabb)?;
// find time at the closest point
let (best_time,mut best_distance)=output_events[event_slice.slice.start..event_slice.slice.end].iter().map(|event|{
let p=event.event.position;
let p=vec3::try_from_f32_array([p.x,p.y,p.z]).unwrap();
(event.time,(start_point-p).length_squared())
}).min_by_key(|&(_,distance)|distance)?;
let mut best_time=crate::time::from_float(best_time).unwrap();
let mut prev_event=&output_events[event_slice.slice.start];
let mut f=|event:&'a v0::Timed<v0::OutputEvent>|{
let p0=vec3::try_from_f32_array([prev_event.event.position.x,prev_event.event.position.y,prev_event.event.position.z]).unwrap();
let p1=vec3::try_from_f32_array([event.event.position.x,event.event.position.y,event.event.position.z]).unwrap();
let d=p1-p0;
let d0=p0.dot(d);
let d1=p1.dot(d);
let sp_d=start_point.dot(d);
// must be on the segment
if d0<sp_d&&sp_d<d1{
let t0=d1-sp_d;
let t1=sp_d-d0;
let dt=d1-d0;
let distance=(((p0*t0+p1*t1)/dt).divide().wrap_1()-start_point).length_squared();
if distance<best_distance{
best_distance=distance;
let p0:Planar64=prev_event.time.try_into().unwrap();
let p1:Planar64=event.time.try_into().unwrap();
best_time=((p0*t0+p1*t1)/dt).into();
}
}
prev_event=event;
};
for event in &output_events[event_slice.slice.start+1..event_slice.slice.end]{
f(event);
}
if event_slice.inclusive{
f(&output_events[event_slice.slice.end]);
}
Some(best_time)
}
}

View File

@@ -3,54 +3,28 @@ use strafesnet_graphics::graphics::GraphicsState;
/// The graphics state, essentially a handle to all the information on the GPU.
pub struct Graphics{
graphics:GraphicsState,
config:wgpu::SurfaceConfiguration,
device:wgpu::Device,
queue:wgpu::Queue,
start_offset:glam::Vec3,
}
impl Graphics{
pub fn new(device:wgpu::Device,queue:wgpu::Queue,config:wgpu::SurfaceConfiguration)->Self{
let graphics=strafesnet_graphics::graphics::GraphicsState::new(&device,&queue,&config);
pub fn new(device:&wgpu::Device,queue:&wgpu::Queue,size:glam::UVec2,view_format:wgpu::TextureFormat)->Self{
let graphics=strafesnet_graphics::graphics::GraphicsState::new(device,queue,size,view_format);
Self{
graphics,
device,
queue,
config,
start_offset:glam::Vec3::ZERO,
}
}
pub fn change_map(&mut self,map:&strafesnet_common::map::CompleteMap){
pub fn change_map(&mut self,device:&wgpu::Device,queue:&wgpu::Queue,map:&strafesnet_common::map::CompleteMap){
self.graphics.clear();
self.graphics.generate_models(&self.device,&self.queue,map);
self.graphics.generate_models(device,queue,map);
let modes=map.modes.clone().denormalize();
let mode=modes.get_mode(strafesnet_common::gameplay_modes::ModeId::MAIN).expect("Map does not have a main mode");
let start_zone=map.models.get(mode.get_start().get() as usize).expect("Map does not have a start zone");
self.start_offset=glam::Vec3::from_array(start_zone.transform.translation.map(|f|f.into()).to_array());
}
pub fn resize(&mut self,surface:&wgpu::Surface<'_>,size:glam::UVec2,fov:glam::Vec2){
self.config.width=size.x.max(1);
self.config.height=size.y.max(1);
surface.configure(&self.device,&self.config);
self.graphics.resize(&self.device,&self.config,fov);
pub fn resize(&mut self,device:&wgpu::Device,size:glam::UVec2,fov:glam::Vec2){
self.graphics.resize(device,size,fov);
}
pub fn render(&mut self,surface:&wgpu::Surface<'_>,pos:glam::Vec3,angles:glam::Vec2){
//this has to go deeper somehow
let frame=match surface.get_current_texture(){
Ok(frame)=>frame,
Err(_)=>{
surface.configure(&self.device,&self.config);
surface
.get_current_texture()
.expect("Failed to acquire next surface texture!")
}
};
let view=frame.texture.create_view(&wgpu::TextureViewDescriptor{
format:Some(self.config.view_formats[0]),
..wgpu::TextureViewDescriptor::default()
});
self.graphics.render(&view,&self.device,&self.queue,strafesnet_graphics::graphics::view_inv(pos+self.start_offset,angles));
frame.present();
pub fn encode_commands(&mut self,encoder:&mut wgpu::CommandEncoder,view:&wgpu::TextureView,pos:glam::Vec3,angles:glam::Vec2){
self.graphics.encode_commands(encoder,view,strafesnet_graphics::graphics::view_inv(pos+self.start_offset,angles));
}
}

View File

@@ -119,6 +119,10 @@ impl PlaybackHead{
(p-bot.world_offset()+CompleteBot::CAMERA_OFFSET,a.yx())
}
pub fn get_position(&self,bot:&CompleteBot,time:SessionTime)->glam::Vec3{
let interp=self.interpolate_output(bot,time);
interp.position()-bot.world_offset()
}
pub fn get_velocity(&self,bot:&CompleteBot,time:SessionTime)->glam::Vec3{
let interp=self.interpolate_output(bot,time);
interp.velocity()

View File

@@ -1,15 +1,6 @@
pub mod bot;
pub mod bvh;
pub mod head;
pub mod time;
pub mod state;
// pub mod surface;
pub mod graphics;
// Create Surface
// Create Graphics from map file and with surface as sample
// Create bot from bot file
// Create playback head
// loop{
// advance head
// render frame
// }

View File

@@ -1,2 +0,0 @@
/// A render surface configuration, containing information such as resolution and pixel format
pub struct Surface{}

View File

@@ -4,10 +4,10 @@ version = "0.1.0"
edition = "2024"
[dependencies]
glam.workspace = true
pollster = "0.4.0"
wgpu = "28.0.0"
winit = "0.30.12"
glam.workspace = true
wgpu.workspace = true
strafesnet_roblox_bot_player.workspace = true
strafesnet_common.workspace = true
strafesnet_graphics.workspace = true

View File

@@ -2,6 +2,7 @@ use strafesnet_common::instruction::TimedInstruction;
use strafesnet_common::session::Time as SessionTime;
use strafesnet_common::timer::TimerState;
use strafesnet_roblox_bot_player::{bot::CompleteBot,graphics::Graphics,head::{PlaybackHead,Time as PlaybackTime}};
use strafesnet_graphics::surface::Surface;
pub enum SessionControlInstruction{
SetPaused(bool),
@@ -35,22 +36,22 @@ struct Playback{
}
pub struct PlayerWorker<'a>{
surface:wgpu::Surface<'a>,
graphics_thread:Graphics,
surface:Surface<'a>,
playback:Option<Playback>,
}
impl<'a> PlayerWorker<'a>{
pub fn new(
surface:wgpu::Surface<'a>,
graphics_thread:Graphics,
surface:Surface<'a>,
)->Self{
Self{
surface,
graphics_thread,
surface,
playback:None,
}
}
pub fn send(&mut self,ins:TimedInstruction<Instruction,SessionTime>){
pub fn send(&mut self,device:&wgpu::Device,queue:&wgpu::Queue,ins:TimedInstruction<Instruction,SessionTime>){
match ins.instruction{
Instruction::SessionControl(SessionControlInstruction::SetPaused(paused))=>if let Some(playback)=&mut self.playback{
playback.playback_head.set_paused(ins.time,paused);
@@ -77,15 +78,28 @@ impl<'a> PlayerWorker<'a>{
Instruction::Render=>if let Some(playback)=&mut self.playback{
playback.playback_head.advance_time(&playback.bot,ins.time);
let (pos,angles)=playback.playback_head.get_position_angles(&playback.bot,ins.time);
self.graphics_thread.render(&self.surface,pos,angles);
//this has to go deeper somehow
let frame=self.surface.new_frame(device);
let mut encoder=device.create_command_encoder(&wgpu::CommandEncoderDescriptor{label:None});
self.graphics_thread.encode_commands(&mut encoder,frame.view(),pos,angles);
queue.submit([encoder.finish()]);
frame.present();
},
Instruction::Resize(physical_size)=>if let Some(playback)=&self.playback{
let fov_y=playback.playback_head.state().get_fov_y();
let fov_x=fov_y*physical_size.width as f64/physical_size.height as f64;
self.graphics_thread.resize(&self.surface,glam::uvec2(physical_size.width,physical_size.height),glam::vec2(fov_x as f32,fov_y as f32));
let fov=glam::vec2(fov_x as f32,fov_y as f32);
let size=glam::uvec2(physical_size.width,physical_size.height);
self.surface.configure(device,size);
self.graphics_thread.resize(device,size,fov);
},
Instruction::ChangeMap(complete_map)=>{
self.graphics_thread.change_map(&complete_map);
self.graphics_thread.change_map(device,queue,&complete_map);
},
Instruction::LoadReplay(bot)=>{
let bot=CompleteBot::new(bot);

View File

@@ -22,7 +22,7 @@ pub async fn setup_and_start(title:&str){
let (device,queue)=setup::step4::request_device(&adapter).await.unwrap();
let size=window.inner_size();
let config=setup::step5::configure_surface(&adapter,&device,&surface,(size.width,size.height)).unwrap();
let surface=setup::step5::configure_surface(&adapter,&device,surface,(size.width,size.height)).unwrap();
//dedicated thread to ping request redraw back and resize the window doesn't seem logical
@@ -32,7 +32,6 @@ pub async fn setup_and_start(title:&str){
device,
queue,
surface,
config,
);
for arg in std::env::args().skip(1){

View File

@@ -13,15 +13,20 @@ pub struct WindowContext<'a>{
simulation_paused:bool,
window:&'a winit::window::Window,
physics_thread:PlayerWorker<'a>,
device:wgpu::Device,
queue:wgpu::Queue,
}
impl WindowContext<'_>{
fn phys(&mut self,ins:TimedInstruction<crate::player::Instruction,strafesnet_common::session::Time>){
self.physics_thread.send(&self.device,&self.queue,ins);
}
fn window_event(&mut self,time:SessionTime,event:winit::event::WindowEvent){
match event{
winit::event::WindowEvent::DroppedFile(path)=>{
match crate::file::load(path.as_path()){
Ok(LoadFormat::Map(map))=>self.physics_thread.send(TimedInstruction{time,instruction:PhysicsWorkerInstruction::ChangeMap(map)}),
Ok(LoadFormat::Bot(bot))=>self.physics_thread.send(TimedInstruction{time,instruction:PhysicsWorkerInstruction::LoadReplay(bot)}),
Ok(LoadFormat::Map(map))=>self.phys(TimedInstruction{time,instruction:PhysicsWorkerInstruction::ChangeMap(map)}),
Ok(LoadFormat::Bot(bot))=>self.phys(TimedInstruction{time,instruction:PhysicsWorkerInstruction::LoadReplay(bot)}),
Err(e)=>println!("Failed to load file: {e}"),
}
},
@@ -31,7 +36,7 @@ impl WindowContext<'_>{
return;
}
//pause unpause
self.physics_thread.send(TimedInstruction{
self.phys(TimedInstruction{
time,
instruction:PhysicsWorkerInstruction::SessionControl(SessionControlInstruction::SetPaused(!state)),
});
@@ -74,7 +79,7 @@ impl WindowContext<'_>{
},
_=>None,
}{
self.physics_thread.send(TimedInstruction{
self.phys(TimedInstruction{
time,
instruction,
});
@@ -83,7 +88,7 @@ impl WindowContext<'_>{
}
},
winit::event::WindowEvent::Resized(size)=>{
self.physics_thread.send(
self.phys(
TimedInstruction{
time,
instruction:PhysicsWorkerInstruction::Resize(size)
@@ -92,7 +97,7 @@ impl WindowContext<'_>{
},
winit::event::WindowEvent::RedrawRequested=>{
self.window.request_redraw();
self.physics_thread.send(
self.phys(
TimedInstruction{
time,
instruction:PhysicsWorkerInstruction::Render
@@ -121,17 +126,19 @@ impl WindowContext<'_>{
window:&'a winit::window::Window,
device:wgpu::Device,
queue:wgpu::Queue,
surface:wgpu::Surface<'a>,
config:wgpu::SurfaceConfiguration,
surface:strafesnet_graphics::surface::Surface<'a>,
)->WindowContext<'a>{
let graphics=strafesnet_roblox_bot_player::graphics::Graphics::new(device,queue,config);
let size=surface.size();
let graphics=strafesnet_roblox_bot_player::graphics::Graphics::new(&device,&queue,size,surface.view_format());
WindowContext{
simulation_paused:false,
window,
physics_thread:crate::player::PlayerWorker::new(
surface,
graphics,
surface,
),
device,
queue,
}
}
}

16
video-encoder/Cargo.toml Normal file
View File

@@ -0,0 +1,16 @@
[package]
name = "video-encoder"
version = "0.1.0"
edition = "2024"
[dependencies]
glam.workspace = true
wgpu.workspace = true
strafesnet_roblox_bot_player.workspace = true
strafesnet_common.workspace = true
strafesnet_graphics.workspace = true
strafesnet_roblox_bot_file.workspace = true
strafesnet_snf.workspace = true
vk-video = "0.2.0"
clap = { version = "4.5.60", features = ["derive"] }
mp4 = "0.14.0"

4
video-encoder/README.md Normal file
View File

@@ -0,0 +1,4 @@
### How it works
- Render RGB to graphics_texture
- Convert RGB to YUV on video_texture
- Encode video frame

View File

@@ -0,0 +1,55 @@
struct VertexOutput {
@builtin(position) position: vec4<f32>,
@location(1) uv: vec2<f32>,
}
@vertex
fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput {
// hacky way to draw a large triangle
let tmp1 = i32(vertex_index) / 2;
let tmp2 = i32(vertex_index) & 1;
var result:VertexOutput;
result.position=vec4<f32>(
f32(tmp1) * 4.0 - 1.0,
f32(tmp2) * 4.0 - 1.0,
1.0,
1.0
);
result.uv=vec2<f32>(
f32(tmp1) * 2.0,
1.0 - f32(tmp2) * 2.0
);
return result;
}
@group(0)
@binding(0)
var texture: texture_2d<f32>;
@group(0)
@binding(1)
var texture_sampler: sampler;
const RGB_TO_Y:vec3<f32> =
vec3(0.2126,0.7152,0.0722);
const RGB_TO_UV:mat3x2<f32> = mat3x2<f32>(
-0.09991,0.615,
-0.33609,-0.55861,
0.436,-0.05639
);
const BIAS:vec2<f32> = vec2<f32>(0.5, 0.5);
@fragment
fn fs_main_y(input: VertexOutput) -> @location(0) f32 {
let color = textureSample(texture, texture_sampler, input.uv).rgb;
let y = dot(RGB_TO_Y,color);
let y_limited = mix(16.0/255.0,240.0/255.0,y);
return clamp(y_limited, 0.0, 1.0);
}
@fragment
fn fs_main_uv(input: VertexOutput) -> @location(0) vec2<f32> {
let color = textureSample(texture, texture_sampler, input.uv).rgb;
let uv = RGB_TO_UV * color + BIAS;
let uv_limited = mix(vec2(16.0/255.0),vec2(240.0/255.0),uv);
return clamp(uv_limited, vec2(0.0, 0.0), vec2(1.0, 1.0));
}

509
video-encoder/src/encode.rs Normal file
View File

@@ -0,0 +1,509 @@
use std::num::NonZeroU32;
use std::path::PathBuf;
use strafesnet_common::session::Time as SessionTime;
#[derive(clap::Subcommand)]
pub enum Commands{
Encode(EncodeSubcommand),
}
impl Commands{
pub fn run(self){
match self{
Commands::Encode(command)=>command.run().unwrap(),
}
}
}
#[derive(clap::Args)]
pub struct EncodeSubcommand{
#[arg(long,short)]
map:PathBuf,
#[arg(long,short)]
bot:PathBuf,
#[arg(long,short)]
output_file:Option<PathBuf>,
#[arg(long,short)]
width:Option<NonZeroU32>,
#[arg(long,short)]
height:Option<NonZeroU32>,
#[arg(long)]
fps:Option<u32>,
#[arg(long)]
target_bitrate:Option<u64>,
#[arg(long)]
max_bitrate:Option<u64>,
#[arg(long)]
device:Option<String>,
}
impl EncodeSubcommand{
fn run(self)->Result<(),EncodeError>{
encode(EncodeParams{
width:self.width.unwrap_or(NonZeroU32::new(1920).unwrap()),
height:self.width.unwrap_or(NonZeroU32::new(1080).unwrap()),
target_framerate:self.fps.unwrap_or(60),
average_bitrate:self.target_bitrate.unwrap_or(6_000_000),
max_bitrate:self.max_bitrate.unwrap_or(6_000_000),
device:self.device,
output_file:self.output_file.unwrap_or_else(||{
let mut output_file:PathBuf=self.bot.file_stem().unwrap().into();
output_file.set_extension("mp4");
output_file
}),
map:self.map,
bot:self.bot,
})
}
}
#[expect(dead_code)]
#[derive(Debug)]
enum EncodeError{
ReadMap(std::io::Error),
ReadBot(std::io::Error),
DecodeSNF(strafesnet_snf::Error),
DecodeMap(strafesnet_snf::map::Error),
DecodeBot(strafesnet_roblox_bot_file::v0::Error),
CreateInstance(vk_video::VulkanInitError),
CreateAdapter(vk_video::VulkanInitError),
NoAdapter,
CreateDevice(vk_video::VulkanInitError),
VideoEncodeParams(vk_video::VulkanEncoderError),
VideoCreateTextures(vk_video::VulkanEncoderError),
VideoEncodeFrame(vk_video::VulkanEncoderError),
OutputCreateFile(std::io::Error),
OutputMp4Start(mp4::Error),
OutputMp4AddTrack(mp4::Error),
OutputMp4WriteSample(mp4::Error),
OutputMp4End(mp4::Error),
}
struct EncodeParams{
width:NonZeroU32,
height:NonZeroU32,
target_framerate:u32,
average_bitrate:u64,
max_bitrate:u64,
device:Option<String>,
map:PathBuf,
bot:PathBuf,
output_file:PathBuf,
}
fn encode(params:EncodeParams)->Result<(),EncodeError>{
let size = glam::uvec2(params.width.get(),params.height.get());
let target_framerate = params.target_framerate;
let average_bitrate = params.average_bitrate;
let max_bitrate = params.max_bitrate;
let map_file=std::fs::read(params.map).map_err(EncodeError::ReadMap)?;
let bot_file=std::fs::read(params.bot).map_err(EncodeError::ReadBot)?;
// read files
let map=strafesnet_snf::read_map(std::io::Cursor::new(map_file))
.map_err(EncodeError::DecodeSNF)?
.into_complete_map()
.map_err(EncodeError::DecodeMap)?;
let timelines=strafesnet_roblox_bot_file::v0::read_all_to_block(std::io::Cursor::new(bot_file))
.map_err(EncodeError::DecodeBot)?;
// vulkan init
let vulkan_instance = vk_video::VulkanInstance::new().map_err(EncodeError::CreateInstance)?;
let vulkan_adapter = if let Some(filter)=params.device.as_deref(){
vulkan_instance.iter_adapters(None)
.map_err(EncodeError::CreateAdapter)?
.find(|adapter|adapter.info().name.contains(filter))
.ok_or(EncodeError::NoAdapter)?
}else{
vulkan_instance.create_adapter(None).map_err(EncodeError::CreateAdapter)?
};
let vulkan_device = vulkan_adapter
.create_device(
wgpu::Features::TEXTURE_COMPRESSION_BC,
wgpu::ExperimentalFeatures::disabled(),
wgpu::Limits::defaults(),
)
.map_err(EncodeError::CreateDevice)?;
// playback
let bot=strafesnet_roblox_bot_player::bot::CompleteBot::new(timelines);
let mut playback_head=strafesnet_roblox_bot_player::head::PlaybackHead::new(&bot,SessionTime::ZERO);
let mut wgpu_state = WgpuState::new(
vulkan_device.wgpu_device(),
vulkan_device.wgpu_queue(),
size,
);
wgpu_state.change_map(&map);
let mut encoder = vulkan_device
.create_wgpu_textures_encoder(
vulkan_device
.encoder_parameters_high_quality(
vk_video::parameters::VideoParameters {
width:params.width,
height:params.height,
target_framerate:target_framerate.into(),
},
vk_video::parameters::RateControl::VariableBitrate {
average_bitrate,
max_bitrate,
virtual_buffer_size: std::time::Duration::from_secs(2),
},
)
.map_err(EncodeError::VideoEncodeParams)?,
)
.map_err(EncodeError::VideoCreateTextures)?;
let output_file=std::fs::File::create(params.output_file)
.map_err(EncodeError::OutputCreateFile)?;
let mp4_config=mp4::Mp4Config{
major_brand: str::parse("isom").unwrap(),
minor_version: 512,
compatible_brands: vec![
str::parse("isom").unwrap(),
str::parse("iso2").unwrap(),
str::parse("avc1").unwrap(),
str::parse("mp41").unwrap(),
],
timescale:target_framerate,
};
let mut mp4=mp4::Mp4Writer::write_start(output_file,&mp4_config)
.map_err(EncodeError::OutputMp4Start)?;
let avc_config=mp4::AvcConfig{
width:params.width.get() as u16,
height:params.height.get() as u16,
// make up some data to prevent this underdeveloped library from crashing
seq_param_set:vec![0,0,0,0],
pic_param_set:vec![],
};
let track_config=mp4::TrackConfig{
track_type:mp4::TrackType::Video,
timescale:target_framerate,
language:"eng".to_owned(),
media_conf:mp4::MediaConfig::AvcConfig(avc_config),
};
const TRACK_ID:u32=1;
mp4.add_track(&track_config)
.map_err(EncodeError::OutputMp4AddTrack)?;
let duration = bot.duration();
for i in 0..duration.get()*target_framerate as i64/SessionTime::ONE_SECOND.get() {
let time=SessionTime::raw(i*SessionTime::ONE_SECOND.get()/target_framerate as i64);
playback_head.advance_time(&bot,time);
let (pos,angles)=playback_head.get_position_angles(&bot,time);
wgpu_state.render(pos,angles);
let frame=vk_video::Frame{
data:wgpu_state.video_texture.clone(),
pts:None,
};
let res=unsafe{encoder.encode(frame,false)}
.map_err(EncodeError::VideoEncodeFrame)?;
let mp4_sample=mp4::Mp4Sample{
start_time:i as u64,
duration:1,
rendering_offset:0,
is_sync:false,
bytes:res.data.into(),
};
mp4.write_sample(TRACK_ID,&mp4_sample)
.map_err(EncodeError::OutputMp4WriteSample)?;
}
mp4.write_end()
.map_err(EncodeError::OutputMp4End)?;
Ok(())
}
struct WgpuState {
device: wgpu::Device,
queue: wgpu::Queue,
// graphics output
graphics:strafesnet_roblox_bot_player::graphics::Graphics,
// not sure if this needs to stay bound to keep the TextureView valid
#[expect(unused)]
graphics_texture: wgpu::Texture,
graphics_texture_view: wgpu::TextureView,
// video output
video_texture: wgpu::Texture,
y_renderer: PlaneRenderer,
uv_renderer: PlaneRenderer,
}
impl WgpuState {
fn new(
device: wgpu::Device,
queue: wgpu::Queue,
size: glam::UVec2,
) -> WgpuState {
const FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Rgba8UnormSrgb;
let graphics = strafesnet_roblox_bot_player::graphics::Graphics::new(&device,&queue,size,FORMAT);
let shader = wgpu::include_wgsl!("../shaders/rgb_to_yuv.wgsl");
let shader = device.create_shader_module(shader);
let graphics_texture_bind_group_layout=device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor{
label:Some("RGB Bind Group Layout"),
entries:&[
wgpu::BindGroupLayoutEntry{
binding:0,
visibility:wgpu::ShaderStages::FRAGMENT,
ty:wgpu::BindingType::Texture{
sample_type:wgpu::TextureSampleType::Float{filterable:true},
multisampled:false,
view_dimension:wgpu::TextureViewDimension::D2,
},
count:None,
},
wgpu::BindGroupLayoutEntry{
binding:1,
visibility:wgpu::ShaderStages::FRAGMENT,
ty:wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count:None,
},
],
});
let graphics_texture=device.create_texture(&wgpu::TextureDescriptor{
label:Some("RGB texture"),
format:FORMAT,
size:wgpu::Extent3d{
width:size.x,
height:size.y,
depth_or_array_layers:1,
},
mip_level_count:1,
sample_count:1,
dimension:wgpu::TextureDimension::D2,
usage:wgpu::TextureUsages::RENDER_ATTACHMENT|wgpu::TextureUsages::TEXTURE_BINDING,
view_formats:&[],
});
let graphics_texture_view = graphics_texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("RGB texture view"),
aspect: wgpu::TextureAspect::All,
usage: Some(wgpu::TextureUsages::RENDER_ATTACHMENT|wgpu::TextureUsages::TEXTURE_BINDING),
..Default::default()
});
let clamp_sampler=device.create_sampler(&wgpu::SamplerDescriptor{
label:Some("Clamp Sampler"),
address_mode_u:wgpu::AddressMode::ClampToEdge,
address_mode_v:wgpu::AddressMode::ClampToEdge,
address_mode_w:wgpu::AddressMode::ClampToEdge,
mag_filter:wgpu::FilterMode::Linear,
min_filter:wgpu::FilterMode::Linear,
mipmap_filter:wgpu::MipmapFilterMode::Linear,
..Default::default()
});
let graphics_texture_bind_group=device.create_bind_group(&wgpu::BindGroupDescriptor{
layout:&graphics_texture_bind_group_layout,
entries:&[
wgpu::BindGroupEntry{
binding:0,
resource:wgpu::BindingResource::TextureView(&graphics_texture_view),
},
wgpu::BindGroupEntry{
binding:1,
resource:wgpu::BindingResource::Sampler(&clamp_sampler),
},
],
label:Some("Graphics Texture"),
});
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("wgpu pipeline layout"),
bind_group_layouts: &[
&graphics_texture_bind_group_layout
],
immediate_size: 0,
});
let video_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("wgpu render target"),
format: wgpu::TextureFormat::NV12,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
dimension: wgpu::TextureDimension::D2,
sample_count: 1,
view_formats: &[],
mip_level_count: 1,
size: wgpu::Extent3d {
width: size.x,
height: size.y,
depth_or_array_layers: 1,
},
});
let y_renderer = PlaneRenderer::new(
&device,
&pipeline_layout,
&shader,
"fs_main_y",
&video_texture,
wgpu::TextureAspect::Plane0,
graphics_texture_bind_group.clone(),
);
let uv_renderer = PlaneRenderer::new(
&device,
&pipeline_layout,
&shader,
"fs_main_uv",
&video_texture,
wgpu::TextureAspect::Plane1,
graphics_texture_bind_group,
);
WgpuState {
device,
queue,
graphics,
graphics_texture,
graphics_texture_view,
video_texture,
y_renderer,
uv_renderer,
}
}
fn change_map(&mut self,map:&strafesnet_common::map::CompleteMap){
self.graphics.change_map(&self.device,&self.queue,map);
}
fn render(&mut self,pos:glam::Vec3,angles:glam::Vec2) {
let mut encoder = self
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("wgpu encoder"),
});
self.graphics.encode_commands(&mut encoder,&self.graphics_texture_view,pos,angles);
self.y_renderer.render(&mut encoder);
self.uv_renderer.render(&mut encoder);
encoder.transition_resources(
[].into_iter(),
[wgpu::TextureTransition {
texture: &self.video_texture,
state: wgpu::TextureUses::COPY_SRC,
selector: None,
}]
.into_iter(),
);
let buffer = encoder.finish();
self.queue.submit([buffer]);
}
}
struct PlaneRenderer {
graphics_texture_bind_group: wgpu::BindGroup,
pipeline: wgpu::RenderPipeline,
plane: wgpu::TextureAspect,
plane_view: wgpu::TextureView,
}
impl PlaneRenderer {
fn new(
device: &wgpu::Device,
pipeline_layout: &wgpu::PipelineLayout,
shader: &wgpu::ShaderModule,
fragment_entry_point: &str,
texture: &wgpu::Texture,
plane: wgpu::TextureAspect,
graphics_texture_bind_group: wgpu::BindGroup,
) -> Self {
let format = texture.format().aspect_specific_format(plane).unwrap();
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("wgpu pipeline"),
layout: Some(pipeline_layout),
cache: None,
vertex: wgpu::VertexState {
module: shader,
buffers: &[],
entry_point: None,
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: shader,
entry_point: Some(fragment_entry_point),
compilation_options: Default::default(),
targets: &[Some(wgpu::ColorTargetState {
blend: None,
format,
write_mask: wgpu::ColorWrites::ALL,
})],
}),
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: Some(wgpu::Face::Back),
polygon_mode: wgpu::PolygonMode::Fill,
front_face: wgpu::FrontFace::Cw,
conservative: false,
unclipped_depth: false,
strip_index_format: None,
},
multiview_mask: None,
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
depth_stencil: None,
});
let plane_view = texture.create_view(&wgpu::TextureViewDescriptor {
label: Some("wgpu render target plane view"),
aspect: plane,
usage: Some(wgpu::TextureUsages::RENDER_ATTACHMENT),
..Default::default()
});
Self {
graphics_texture_bind_group,
pipeline,
plane,
plane_view,
}
}
fn render(&self, encoder: &mut wgpu::CommandEncoder) {
let clear_color = match self.plane {
wgpu::TextureAspect::Plane0 => wgpu::Color::BLACK,
wgpu::TextureAspect::Plane1 => wgpu::Color {
r: 0.5,
g: 0.5,
b: 0.0,
a: 1.0,
},
_ => unreachable!(),
};
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("wgpu render pass"),
timestamp_writes: None,
occlusion_query_set: None,
depth_stencil_attachment: None,
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &self.plane_view,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(clear_color),
store: wgpu::StoreOp::Store,
},
resolve_target: None,
depth_slice: None,
})],
multiview_mask: None,
});
render_pass.set_bind_group(0,&self.graphics_texture_bind_group,&[]);
render_pass.set_pipeline(&self.pipeline);
render_pass.draw(0..3, 0..1);
}
}

24
video-encoder/src/main.rs Normal file
View File

@@ -0,0 +1,24 @@
use clap::{Parser,Subcommand};
mod encode;
#[derive(Parser)]
#[command(author,version,about,long_about=None)]
#[command(propagate_version=true)]
struct Cli{
#[command(subcommand)]
command:Commands,
}
#[derive(Subcommand)]
enum Commands{
#[command(flatten)]
Encode(encode::Commands),
}
fn main(){
let cli=Cli::parse();
match cli.command{
Commands::Encode(commands)=>commands.run(),
}
}

View File

@@ -12,6 +12,7 @@ webgl = ["wgpu/webgl"]
[dependencies]
glam.workspace = true
wgpu.workspace = true
strafesnet_roblox_bot_player.workspace = true
strafesnet_common.workspace = true
strafesnet_graphics.workspace = true
@@ -20,7 +21,6 @@ strafesnet_snf.workspace = true
wasm-bindgen = "0.2.108"
wasm-bindgen-futures = "0.4.58"
web-sys = { version = "0.3.85", features = ["HtmlCanvasElement"] }
wgpu = { version = "28.0.0" }
[package.metadata.wasm-pack.profile.release]
wasm-opt = ["-Oz", "--enable-bulk-memory","--enable-nontrapping-float-to-int"]

View File

@@ -1,8 +1,8 @@
use wasm_bindgen::prelude::wasm_bindgen;
use wasm_bindgen::JsError;
use strafesnet_roblox_bot_file::v0;
use strafesnet_roblox_bot_player::{bot,head,time,graphics};
use strafesnet_graphics::setup;
use strafesnet_roblox_bot_player::{bot,bvh,head,time,graphics};
use strafesnet_graphics::{setup,surface};
// Hack to keep the code compiling,
// SurfaceTarget::Canvas is not available in IDE for whatever reason.
@@ -22,11 +22,13 @@ impl From<ToSurfaceTarget> for wgpu::SurfaceTarget<'static>{
#[wasm_bindgen]
pub struct Graphics{
graphics:graphics::Graphics,
surface:wgpu::Surface<'static>,
surface:surface::Surface<'static>,
device:wgpu::Device,
queue:wgpu::Queue,
}
#[wasm_bindgen]
pub async fn setup_graphics(canvas:web_sys::HtmlCanvasElement)->Result<Graphics,JsError>{
let size=(canvas.width(),canvas.height());
let size=glam::uvec2(canvas.width(),canvas.height());
let instance_desc=wgpu::InstanceDescriptor::from_env_or_default();
let instance=wgpu::util::new_instance_with_webgpu_detection(&instance_desc).await;
@@ -37,10 +39,12 @@ pub async fn setup_graphics(canvas:web_sys::HtmlCanvasElement)->Result<Graphics,
compatible_surface:Some(&surface),
}).await.map_err(|e|JsError::new(&e.to_string()))?;
let (device,queue)=setup::step4::request_device(&adapter).await.map_err(|e|JsError::new(&e.to_string()))?;
let config=setup::step5::configure_surface(&adapter,&device,&surface,size).map_err(|e|JsError::new(&e.to_string()))?;
let surface=setup::step5::configure_surface(&adapter,&device,surface,(size.x,size.y)).map_err(|e|JsError::new(&e.to_string()))?;
Ok(Graphics{
graphics:graphics::Graphics::new(device,queue,config),
surface:surface,
graphics:graphics::Graphics::new(&device,&queue,size,surface.view_format()),
surface,
device,
queue,
})
}
#[wasm_bindgen]
@@ -49,15 +53,21 @@ impl Graphics{
pub fn render(&mut self,bot:&CompleteBot,head:&PlaybackHead,time:f64){
let time=time::from_float(time).unwrap();
let (pos,angles)=head.head.get_position_angles(&bot.bot,time);
self.graphics.render(&self.surface,pos,angles);
let frame=self.surface.new_frame(&self.device);
let mut encoder=self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor{label:None});
self.graphics.encode_commands(&mut encoder,frame.view(),pos,angles);
self.queue.submit([encoder.finish()]);
frame.present();
}
#[wasm_bindgen]
pub fn resize(&mut self,width:u32,height:u32,fov_slope_x:f32,fov_slope_y:f32){
self.graphics.resize(&self.surface,[width,height].into(),[fov_slope_x as f32,fov_slope_y as f32].into());
let size=[width,height].into();
self.surface.configure(&self.device,size);
self.graphics.resize(&self.device,size,[fov_slope_x as f32,fov_slope_y as f32].into());
}
#[wasm_bindgen]
pub fn change_map(&mut self,map:&CompleteMap){
self.graphics.change_map(&map.map);
self.graphics.change_map(&self.device,&self.queue,&map.map);
}
}
@@ -183,12 +193,11 @@ impl PlaybackHead{
pub fn get_game_controls(&self)->u32{
self.head.state().get_controls().bits()
}
/// Returns an array of [pitch, yaw, roll] in radians. Yaw is not restricted to any particular range.
#[wasm_bindgen]
pub fn get_angles(&self,bot:&CompleteBot,time:f64)->Vec<f32>{
pub fn get_position(&self,bot:&CompleteBot,time:f64)->Vector3{
let time=time::from_float(time).unwrap();
let angles=self.head.get_angles(&bot.bot,time);
angles.to_array().to_vec()
let position=self.head.get_position(&bot.bot,time);
Vector3(position)
}
/// Returns the camera angles yaw delta between the last game tick and the most recent game tick.
#[wasm_bindgen]
@@ -196,3 +205,31 @@ impl PlaybackHead{
self.head.state().get_angles_delta().y
}
}
#[wasm_bindgen]
pub struct Vector3(glam::Vec3);
#[wasm_bindgen]
impl Vector3{
#[wasm_bindgen]
pub fn to_array(&self)->Vec<f32>{
self.0.to_array().to_vec()
}
}
#[wasm_bindgen]
pub struct Bvh{
bvh:bvh::Bvh,
}
#[wasm_bindgen]
impl Bvh{
#[wasm_bindgen(constructor)]
pub fn new(bot:&CompleteBot)->Self{
Self{
bvh:bvh::Bvh::new(&bot.bot),
}
}
#[wasm_bindgen]
pub fn closest_time_to_point(&self,bot:&CompleteBot,point:&Vector3)->Option<f64>{
Some(bot.bot.playback_time(self.bvh.closest_time_to_point(&bot.bot,point.0)?).into())
}
}

View File

@@ -58,6 +58,8 @@
<div class="hud">
<div id="hud_duration" class="timer">00:00:00</div>
<div id="hud_timer" class="timer">00:00:00</div>
<div id="diff_velocity" class="timer">-0.000 u/s</div>
<div id="diff_time" class="timer">-0.000s</div>
</div>
<div class="controls">
<button id="control_reset">↪️</button>

View File

@@ -3,6 +3,7 @@ import init, {
CompleteBot,
CompleteMap,
PlaybackHead,
Bvh,
} from "./pkg/strafesnet_roblox_bot_player_wasm_module.js";
// Loading
@@ -17,12 +18,16 @@ const graphics = await setup_graphics(canvas);
const bot = new CompleteBot(new Uint8Array(await b.arrayBuffer()));
const map = new CompleteMap(new Uint8Array(await m.arrayBuffer()));
const playback = new PlaybackHead(bot, 0);
const bvh_wr = new Bvh(bot);
const playback_wr = new PlaybackHead(bot, 0);
graphics.change_map(map);
// HUD
const hud_timer = document.getElementById("hud_timer");
const hud_duration = document.getElementById("hud_duration");
const diff_velocity = document.getElementById("diff_velocity");
const diff_time = document.getElementById("diff_time");
const MODE_MAIN = 0;
function timer_text(t) {
@@ -106,6 +111,22 @@ function animate(now) {
const time = playback.get_run_time(bot, elapsedSec, MODE_MAIN);
hud_timer.textContent = timer_text(time);
// show diff
const pos = playback.get_position(bot, elapsedSec);
const wr_playback_time = bvh_wr.closest_time_to_point(bot, pos);
playback_wr.set_head_time(bot, elapsedSec, wr_playback_time);
const wr_time = playback_wr.get_run_time(bot, elapsedSec, MODE_MAIN);
const run_speed = playback.get_speed(bot, elapsedSec);
const wr_speed = playback_wr.get_speed(bot, elapsedSec);
const v_diff = run_speed - wr_speed;
const wholespeed = Math.floor(Math.abs(v_diff));
const millispeed = Math.floor((Math.abs(v_diff) % 1) * 1000);
diff_velocity.textContent = `${v_diff<0?"-":"+"}${String(wholespeed)}.${String(millispeed).padStart(3, "0")} u/s`;
const t_diff = time - wr_time;
const s = Math.floor(Math.abs(t_diff));
const ms = Math.floor((Math.abs(t_diff) % 1) * 1000);
diff_time.textContent = `${t_diff<0?"-":"+"}${String(s)}.${String(ms).padStart(3, "0")}s`;
// Render the frame that the bot is at that time
graphics.render(bot, playback, elapsedSec);