30 Commits

Author SHA1 Message Date
0e9fc87976 path helpers 2026-03-10 07:57:10 -07:00
673142c493 update to graphics v0.0.7 2026-03-10 07:57:10 -07:00
0b5a8453dc use surface in wasm 2026-03-10 07:38:31 -07:00
de2bcc77a0 surface api 2026-03-10 07:36:09 -07:00
3f0acaae30 surface 2026-03-10 07:25:37 -07:00
935cc87663 fix resize 2026-03-10 07:06:10 -07:00
839c59ea54 waaa 2026-03-10 07:01:33 -07:00
1c6461464f wip refactor surface (rename to TextureView?) 2026-03-09 11:54:56 -07:00
0afc5cddad video encoder 2026-03-09 11:32:24 -07:00
c8eb2f7878 update graphics to remove SurfaceConfig from interface 2026-03-09 11:31:11 -07:00
66cb1fc5ff player v0.2.0 better seek init 2026-03-08 08:59:39 -07:00
0cb0f6a423 use workspace dep 2026-03-08 08:58:33 -07:00
890e5c1905 wasm-module: add get_angles_yaw_delta 2026-03-07 15:46:01 -08:00
3d8b5a0dfe lib: initialize PlaybackState more thoroughly 2026-03-07 14:55:04 -08:00
495092f79f wasm-module: add get_angles doc 2026-03-06 19:04:37 -08:00
e0a8175355 add get_angles 2026-03-06 19:03:06 -08:00
006a70a18b lib: simplify Head internals with InterpolateOutput 2026-03-06 09:25:33 -08:00
7ce2ca8b0a lib: use DVec2 for get_sensitivity 2026-03-06 08:57:41 -08:00
6ef6c67703 lib: opportunistic const 2026-03-06 08:56:40 -08:00
8dfb5f5094 lib: add PlaybackState getters 2026-03-06 08:48:57 -08:00
9e0e9a62e7 wasm-module: add get_game_controls 2026-03-06 08:48:57 -08:00
6fbeba94ae wasm-module: add get_speed 2026-03-06 08:35:06 -08:00
01916e0682 lib: add helper for interpolating output events 2026-03-06 08:33:11 -08:00
2af2134f72 configure webgl with feature flag 2026-03-04 15:55:03 -08:00
a3e7b5ff99 lib v0.1.1 SurfaceAppearance fixes + webgl support 2026-03-04 09:39:36 -08:00
58f9a70e16 add webgl support 2026-03-04 09:34:17 -08:00
3b218856c9 update deps, notably strafesnet_graphics 2026-03-04 09:34:00 -08:00
00393490a0 surface errors in setup_graphics 2026-02-28 17:58:58 -08:00
f96891dcbc update deps 2026-02-28 15:54:26 -08:00
35a90f28ae add dynamic world offset 2026-02-28 09:33:27 -08:00
19 changed files with 977 additions and 281 deletions

449
Cargo.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -2,6 +2,7 @@
members = [
"lib",
"native-player",
"video-encoder",
"wasm-module"
]
resolver = "3"
@@ -12,7 +13,16 @@ strip = true
codegen-units = 1
[workspace.dependencies]
strafesnet_common = { version = "0.8.5", registry = "strafesnet" }
strafesnet_graphics = { version = "0.0.2", registry = "strafesnet" }
glam = "0.32.0"
strafesnet_common = { version = "0.8.6", 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

@@ -1,10 +1,10 @@
[package]
name = "strafesnet_roblox_bot_player"
version = "0.1.0"
version = "0.2.0"
edition = "2024"
[dependencies]
glam = "0.31.0"
glam.workspace = true
strafesnet_common.workspace = true
strafesnet_graphics.workspace = true
strafesnet_roblox_bot_file.workspace = true

View File

@@ -11,6 +11,7 @@ pub struct CompleteBot{
timelines:v0::Block,
timer:TimerFixed<Realtime<PlaybackTimeInner,PhysicsTimeInner>,Unpaused>,
duration:PhysicsTime,
world_offset:glam::Vec3,
}
impl CompleteBot{
pub(crate) const CAMERA_OFFSET:glam::Vec3=glam::vec3(0.0,2.0,0.0);
@@ -19,10 +20,15 @@ impl CompleteBot{
)->Self{
let start=crate::time::from_float(timelines.output_events.first().unwrap().time).unwrap();
let end=crate::time::from_float(timelines.output_events.last().unwrap().time).unwrap();
let world_position=timelines.world_events.iter().find_map(|event|match &event.event{
v0::WorldEvent::Reset(world_reset_event)=>Some(world_reset_event.position),
_=>None,
}).expect("Map must contain a WorldReset event");
Self{
timer:TimerFixed::new(PlaybackTime::ZERO,start),
duration:end-start,
timelines,
world_offset:glam::vec3(world_position.x,world_position.y,world_position.z),
}
}
pub fn time(&self,time:PlaybackTime)->PhysicsTime{
@@ -31,6 +37,9 @@ impl CompleteBot{
pub const fn duration(&self)->PhysicsTime{
self.duration
}
pub const fn world_offset(&self)->glam::Vec3{
self.world_offset
}
pub const fn timelines(&self)->&v0::Block{
&self.timelines
}

View File

@@ -3,48 +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,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

@@ -97,30 +97,57 @@ impl PlaybackHead{
}
}
}
pub fn get_position_angles(&self,bot:&CompleteBot,time:SessionTime)->(glam::Vec3,glam::Vec2){
fn interpolate_output<'a>(&self,bot:&'a CompleteBot,time:SessionTime)->InterpolateOutput<'a>{
let time=bot.time(self.time(time));
let event0=&bot.timelines().output_events[self.head.get_event_index(EventType::Output)-1];
let event1=&bot.timelines().output_events[self.head.get_event_index(EventType::Output)];
let p0=vector3_to_glam(&event0.event.position);
let p1=vector3_to_glam(&event1.event.position);
// let v0=vector3_to_glam(&event0.event.velocity);
// let v1=vector3_to_glam(&event1.event.velocity);
// let a0=vector3_to_glam(&event0.event.acceleration);
// let a1=vector3_to_glam(&event1.event.acceleration);
let t0=event0.time;
let t1=event1.time;
let time_float:f64=time.into();
let t=((time_float-t0)/(t1-t0)) as f32;
let p=p0.lerp(p1,t);
// let v=v0.lerp(v1,t);
// let a=a0.lerp(a1,t);
//println!("position={:?}",p);
let angles0=vector3_to_glam(&event0.event.angles);
let angles1=vector3_to_glam(&event1.event.angles);
let angles=angles0.lerp(angles1,t);
(p+CompleteBot::CAMERA_OFFSET,angles.yx())
InterpolateOutput{
event0:&event0.event,
event1:&event1.event,
t:t,
}
}
pub fn get_position_angles(&self,bot:&CompleteBot,time:SessionTime)->(glam::Vec3,glam::Vec2){
let interp=self.interpolate_output(bot,time);
let p=interp.position();
let a=interp.angles();
(p-bot.world_offset()+CompleteBot::CAMERA_OFFSET,a.yx())
}
pub fn get_velocity(&self,bot:&CompleteBot,time:SessionTime)->glam::Vec3{
let interp=self.interpolate_output(bot,time);
interp.velocity()
}
pub fn get_angles(&self,bot:&CompleteBot,time:SessionTime)->glam::Vec3{
let interp=self.interpolate_output(bot,time);
interp.angles()
}
}
struct InterpolateOutput<'a>{
event0:&'a strafesnet_roblox_bot_file::v0::OutputEvent,
event1:&'a strafesnet_roblox_bot_file::v0::OutputEvent,
t:f32,
}
impl InterpolateOutput<'_>{
fn position(&self)->glam::Vec3{
let p0=vector3_to_glam(&self.event0.position);
let p1=vector3_to_glam(&self.event1.position);
p0.lerp(p1,self.t)
}
fn velocity(&self)->glam::Vec3{
let v0=vector3_to_glam(&self.event0.velocity);
let v1=vector3_to_glam(&self.event1.velocity);
v0.lerp(v1,self.t)
}
fn angles(&self)->glam::Vec3{
let a0=vector3_to_glam(&self.event0.angles);
let a1=vector3_to_glam(&self.event1.angles);
a0.lerp(a1,self.t)
}
}

View File

@@ -2,14 +2,4 @@ pub mod bot;
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

@@ -55,6 +55,8 @@ pub struct PlaybackState{
mouse_pos:v0::Vector2,
// EventType::Output
jump_count:u32,
angles:v0::Vector3,
angles_delta:glam::Vec3,
// EventType::Sound
// EventType::World
// EventType::Gravity
@@ -77,6 +79,8 @@ impl PlaybackState{
game_controls:v0::GameControls::empty(),
mouse_pos:v0::Vector2{x:0.0,y:0.0},
jump_count:0,
angles:v0::Vector3{x:0.0,y:0.0,z:0.0},
angles_delta:glam::Vec3::ZERO,
gravity:v0::Vector3{x:0.0,y:0.0,z:0.0},
runs:HashMap::new(),
style:v0::Style::Autohop,
@@ -91,9 +95,15 @@ impl PlaybackState{
self.runs.get(&mode)
}
fn push_output(&mut self,event:&v0::OutputEvent){
// Jumps may occur during a substep
if event.tick_info.contains(v0::TickInfo::Jump){
self.jump_count+=1;
}
// Game tick "end", i.e. not a sub-step
if event.tick_info.contains(v0::TickInfo::TickEnd){
self.angles_delta=glam::vec3(event.angles.x,event.angles.y,event.angles.z)-glam::vec3(self.angles.x,self.angles.y,self.angles.z);
self.angles=event.angles;
}
}
fn push_input(&mut self,event:&v0::InputEvent){
self.game_controls=event.game_controls;
@@ -194,22 +204,43 @@ impl PlaybackState{
}
}
pub(crate) fn process_head(&mut self,block:&v0::Block,head:&v0::Head){
// The whole point of this is to avoid running the realtime events!
/*
for event in &block.input_events[0..head.get_event_index(v0::EventType::Input)]{
// Avoid running the realtime events from the beginning.
// Run the preceding input event to initialize the state.
if let Some(index)=head.get_event_index(v0::EventType::Input).checked_sub(1)
&&let Some(event)=block.input_events.get(index)
{
self.push_input(&event.event);
}
for event in &block.output_events[0..head.get_event_index(v0::EventType::Output)]{
// Helper function
fn is_output_tick_end(&(_,event):&(usize,&v0::Timed<v0::OutputEvent>))->bool{
event.event.tick_info.contains(v0::TickInfo::TickEnd)
}
// Run two preceding output events to flush out the default state.
let output_end_index=head.get_event_index(v0::EventType::Output);
let mut it=block.output_events[..output_end_index].iter().enumerate().rev();
// Find two TickEnd events before output_end_index
let _first=it.find(is_output_tick_end);
let second=it.find(is_output_tick_end);
// Get the index at the second event, if two TickEnd events don't exist then start at 0
let output_start_index=second.map_or(0,|(i,_)|i);
for event in &block.output_events[output_start_index..output_end_index]{
self.push_output(&event.event);
}
for event in &bot.sound_events[0..head.get_event_index(v0::EventType::Sound)]{
self.push_sound(&event.event);
}
*/
// for event in &bot.sound_events[0..head.get_event_index(v0::EventType::Sound)]{
// self.push_sound(&event.event);
// }
// Offline events have to be run from the beginning because they contain cumulative state.
// for event in &bot.world_events[0..head.get_event_index(v0::EventType::World)]{
// self.push_world(&event.event);
// }
for event in &block.gravity_events[0..head.get_event_index(v0::EventType::Gravity)]{
// Except for gravity, only the most recent event is relevant.
if let Some(index)=head.get_event_index(v0::EventType::Gravity).checked_sub(1)
&&let Some(event)=block.gravity_events.get(index)
{
self.push_gravity(&event.event);
}
for event in &block.run_events[0..head.get_event_index(v0::EventType::Run)]{
@@ -234,16 +265,28 @@ impl PlaybackState{
v0::EventType::Setting=>self.push_setting(&block.setting_events[event_index].event),
}
}
pub fn get_fov_y(&self)->f64{
pub const fn get_fov_y(&self)->f64{
let zoom_enabled=self.game_controls.contains(v0::GameControls::Zoom);
if zoom_enabled{self.fov_y*0.2}else{self.fov_y}
}
pub fn get_sensitivity(&self)->(f64,f64){
pub const fn get_sensitivity(&self)->glam::DVec2{
if self.absolute_sensitivity_enabled{
(self.sens_x,self.sens_x*self.vertical_sensitivity_multipler)
glam::dvec2(self.sens_x,self.sens_x*self.vertical_sensitivity_multipler)
}else{
let sens_x=self.sens_x*self.get_fov_y()/128.0;
(sens_x,sens_x*self.vertical_sensitivity_multipler)
glam::dvec2(sens_x,sens_x*self.vertical_sensitivity_multipler)
}
}
pub const fn get_controls(&self)->v0::GameControls{
self.game_controls
}
pub const fn get_jump_count(&self)->u32{
self.jump_count
}
pub const fn get_gravity(&self)->v0::Vector3{
self.gravity
}
pub const fn get_angles_delta(&self)->glam::Vec3{
self.angles_delta
}
}

View File

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

View File

@@ -4,11 +4,11 @@ version = "0.1.0"
edition = "2024"
[dependencies]
glam = "0.31.0"
glam.workspace = true
pollster = "0.4.0"
wgpu = "28.0.0"
winit = "0.30.12"
strafesnet_roblox_bot_player = { version = "0.1.0", path = "../lib" }
strafesnet_roblox_bot_player.workspace = true
strafesnet_common.workspace = true
strafesnet_graphics.workspace = true
strafesnet_roblox_bot_file.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

@@ -19,10 +19,10 @@ pub async fn setup_and_start(title:&str){
let adapter=setup::step3::pick_adapter(&instance,&surface).await.expect("No suitable GPU adapters found on the system!");
let (device,queue)=setup::step4::request_device(&adapter).await;
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));
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,
}
}
}

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

@@ -0,0 +1,14 @@
[package]
name = "video-encoder"
version = "0.1.0"
edition = "2024"
[dependencies]
glam.workspace = true
wgpu = "28.0.0"
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"

View File

@@ -0,0 +1,46 @@
struct VertexOutput {
@builtin(position) position: vec4<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
);
return result;
}
@group(0)
@binding(0)
var texture: texture_2d<f32>;
@group(0)
@binding(1)
var texture_sampler: sampler;
@fragment
fn fs_main_y(input: VertexOutput) -> @location(0) f32 {
let conversion_weights = vec3<f32>(0.2126, 0.7152, 0.0722);
let color = textureSample(texture, texture_sampler, input.position.xy).rgb;
return clamp(dot(color, conversion_weights), 0.0, 1.0);
}
@fragment
fn fs_main_uv(input: VertexOutput) -> @location(0) vec2<f32> {
let conversion_weights = mat3x2<f32>(
-0.1146, 0.5,
-0.3854, -0.4542,
0.5, -0.0458,
);
let conversion_bias = vec2<f32>(0.5, 0.5);
let color = textureSample(texture, texture_sampler, input.position.xy).rgb;
return clamp(conversion_weights * color + conversion_bias, vec2(0.0, 0.0), vec2(1.0, 1.0));
}

View File

@@ -0,0 +1,5 @@
mod setup;
fn main(){
setup::setup_and_start();
}

367
video-encoder/src/setup.rs Normal file
View File

@@ -0,0 +1,367 @@
use std::io::Write;
use strafesnet_common::session::Time as SessionTime;
pub fn setup_and_start(){
let vulkan_instance = vk_video::VulkanInstance::new().unwrap();
let vulkan_adapter = vulkan_instance.create_adapter(None).unwrap();
let vulkan_device = vulkan_adapter
.create_device(
wgpu::Features::TEXTURE_COMPRESSION_BC,
wgpu::ExperimentalFeatures::disabled(),
wgpu::Limits::defaults(),
)
.unwrap();
let size = glam::uvec2(1920,1080);
let target_framerate = 60;
let average_bitrate = 10_000_000;
let max_bitrate = 20_000_000;
let bot_file=include_bytes!("../../web-demo/bhop_marble_7cf33a64-7120-4514-b9fa-4fe29d9523d.qbot");
let map_file=include_bytes!("../../web-demo/bhop_marble_5692093612.snfm");
// decode
let timelines=strafesnet_roblox_bot_file::v0::read_all_to_block(std::io::Cursor::new(bot_file)).unwrap();
let map=strafesnet_snf::read_map(std::io::Cursor::new(map_file)).unwrap().into_complete_map().unwrap();
// 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:size.x.try_into().unwrap(),
height:size.y.try_into().unwrap(),
target_framerate:target_framerate.into(),
},
vk_video::parameters::RateControl::VariableBitrate {
average_bitrate,
max_bitrate,
virtual_buffer_size: std::time::Duration::from_secs(2),
},
)
.unwrap(),
)
.unwrap();
let mut output_file = std::fs::File::create("output.h264").unwrap();
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 res = unsafe {
encoder
.encode(
vk_video::Frame {
data: wgpu_state.video_texture.clone(),
pts: None,
},
false,
)
.unwrap()
};
output_file.write_all(&res.data).unwrap();
}
}
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::Bgra8UnormSrgb;
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::Ccw,
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);
}
}

View File

@@ -6,8 +6,13 @@ edition = "2024"
[lib]
crate-type = ["cdylib"]
[features]
default = []
webgl = ["wgpu/webgl"]
[dependencies]
strafesnet_roblox_bot_player = { version = "0.1.0", path = "../lib" }
glam.workspace = true
strafesnet_roblox_bot_player.workspace = true
strafesnet_common.workspace = true
strafesnet_graphics.workspace = true
strafesnet_roblox_bot_file.workspace = true
@@ -15,7 +20,7 @@ strafesnet_snf.workspace = true
wasm-bindgen = "0.2.108"
wasm-bindgen-futures = "0.4.58"
web-sys = { version = "0.3.85", features = ["HtmlCanvasElement"] }
wgpu = "28.0.0"
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::JsValue;
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_graphics::{setup,surface};
// Hack to keep the code compiling,
// SurfaceTarget::Canvas is not available in IDE for whatever reason.
@@ -22,21 +22,30 @@ 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)->Graphics{
let size=(canvas.width(),canvas.height());
pub async fn setup_graphics(canvas:web_sys::HtmlCanvasElement)->Result<Graphics,JsError>{
let size=glam::uvec2(canvas.width(),canvas.height());
let instance=setup::step1::create_instance();
let surface=setup::step2::create_surface(&instance,ToSurfaceTarget(canvas)).unwrap();
let adapter=setup::step3::pick_adapter(&instance,&surface).await.expect("No suitable GPU adapters found on the system!");
let (device,queue)=setup::step4::request_device(&adapter).await;
let config=setup::step5::configure_surface(&adapter,&device,&surface,size);
Graphics{
graphics:graphics::Graphics::new(device,queue,config),
surface:surface,
}
let instance_desc=wgpu::InstanceDescriptor::from_env_or_default();
let instance=wgpu::util::new_instance_with_webgpu_detection(&instance_desc).await;
let surface=setup::step2::create_surface(&instance,ToSurfaceTarget(canvas)).map_err(|e|JsError::new(&e.to_string()))?;
let adapter=instance.request_adapter(&wgpu::RequestAdapterOptions{
power_preference:wgpu::PowerPreference::HighPerformance,
force_fallback_adapter:false,
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 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,size,surface.view_format()),
surface,
device,
queue,
})
}
#[wasm_bindgen]
impl Graphics{
@@ -44,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);
}
}
@@ -63,8 +78,8 @@ pub struct CompleteBot{
#[wasm_bindgen]
impl CompleteBot{
#[wasm_bindgen(constructor)]
pub fn new(data:&[u8])->Result<Self,JsValue>{
let timelines=v0::read_all_to_block(std::io::Cursor::new(data)).map_err(|e|JsValue::from_str(&e.to_string()))?;
pub fn new(data:&[u8])->Result<Self,JsError>{
let timelines=v0::read_all_to_block(std::io::Cursor::new(data)).map_err(|e|JsError::new(&e.to_string()))?;
Ok(Self{
bot:bot::CompleteBot::new(timelines),
})
@@ -87,11 +102,11 @@ pub struct CompleteMap{
#[wasm_bindgen]
impl CompleteMap{
#[wasm_bindgen(constructor)]
pub fn new(data:&[u8])->Result<Self,JsValue>{
pub fn new(data:&[u8])->Result<Self,JsError>{
let map=strafesnet_snf::read_map(std::io::Cursor::new(data))
.map_err(|e|JsValue::from_str(&e.to_string()))?
.map_err(|e|JsError::new(&e.to_string()))?
.into_complete_map()
.map_err(|e|JsValue::from_str(&e.to_string()))?;
.map_err(|e|JsError::new(&e.to_string()))?;
Ok(Self{
map,
})
@@ -105,11 +120,11 @@ pub struct PlaybackHead{
#[wasm_bindgen]
impl PlaybackHead{
#[wasm_bindgen(constructor)]
pub fn new(bot:&CompleteBot,time:f64)->Result<Self,JsValue>{
pub fn new(bot:&CompleteBot,time:f64)->Self{
let time=time::from_float(time).unwrap();
Ok(Self{
Self{
head:head::PlaybackHead::new(&bot.bot,time),
})
}
}
#[wasm_bindgen]
pub fn advance_time(&mut self,bot:&CompleteBot,time:f64){
@@ -166,4 +181,28 @@ impl PlaybackHead{
pub fn get_fov_slope_y(&self)->f64{
self.head.state().get_fov_y()
}
#[wasm_bindgen]
pub fn get_speed(&self,bot:&CompleteBot,time:f64)->f32{
let time=time::from_float(time).unwrap();
let velocity=self.head.get_velocity(&bot.bot,time);
use glam::Vec3Swizzles;
velocity.xz().length()
}
#[wasm_bindgen]
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>{
let time=time::from_float(time).unwrap();
let angles=self.head.get_angles(&bot.bot,time);
angles.to_array().to_vec()
}
/// Returns the camera angles yaw delta between the last game tick and the most recent game tick.
#[wasm_bindgen]
pub fn get_angles_yaw_delta(&self)->f32{
self.head.state().get_angles_delta().y
}
}