From 8e198a54e3b10bdedaa4f3b9db1c735784d65699 Mon Sep 17 00:00:00 2001 From: Etienne Date: Fri, 29 Sep 2023 18:02:47 +0200 Subject: [PATCH] Clean camera and raymarching --- Cargo.lock | 1 + Cargo.toml | 3 +- lib/Cargo.toml | 1 + lib/src/camera_control.rs | 71 ++++++++++++++++ lib/src/demo_raymarching.rs | 21 ++++- lib/src/lib.rs | 9 ++ lib/src/mouse_input.rs | 60 ++++++++++++++ lib/src/program.rs | 4 + shaders/demo_raymarching/camera.wgsl | 18 ++++ shaders/demo_raymarching/common.wgsl | 5 +- shaders/demo_raymarching/draw_2d.wgsl | 1 - shaders/demo_raymarching/draw_3d.wgsl | 113 +++++++++++++------------- src/runner.rs | 18 +++- 13 files changed, 260 insertions(+), 65 deletions(-) create mode 100644 lib/src/camera_control.rs create mode 100644 lib/src/mouse_input.rs create mode 100644 shaders/demo_raymarching/camera.wgsl diff --git a/Cargo.lock b/Cargo.lock index fea7b3b..32c7cef 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -994,6 +994,7 @@ dependencies = [ "pollster", "rust-embed", "wgpu", + "winit", ] [[package]] diff --git a/Cargo.toml b/Cargo.toml index b7c8921..8a0fccc 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -18,6 +18,7 @@ wgpu = { version = "0.17.0", default-features = false } egui = { git = "https://github.com/emilk/egui.git", rev = "b896d641c578c04603adf07a9350b8bfc3c7ed9f" } pollster = "0.3.0" log = "0.4.19" +winit = "0.28.6" [dependencies] @@ -25,10 +26,10 @@ wgpu.workspace = true egui.workspace = true pollster.workspace = true log.workspace = true +winit.workspace = true lib = { path = "./lib" } hot-lib-reloader = { version = "^0.6", optional = true } -winit = "0.28.6" env_logger = "0.10.0" wasm-bindgen = "0.2.87" diff --git a/lib/Cargo.toml b/lib/Cargo.toml index 79fc0dd..bbe8b99 100644 --- a/lib/Cargo.toml +++ b/lib/Cargo.toml @@ -4,6 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] +winit.workspace = true wgpu.workspace = true egui.workspace = true pollster.workspace = true diff --git a/lib/src/camera_control.rs b/lib/src/camera_control.rs new file mode 100644 index 0000000..5004470 --- /dev/null +++ b/lib/src/camera_control.rs @@ -0,0 +1,71 @@ +use winit::event::MouseButton; + +use crate::mouse_input::MouseState; + +// Naive look-at camera. +// This version removes the use of quaternion to avoid adding a dependency. +// To avoid having to do linear algebra ourselves, most computations are done in the shader. +// This is sub-optimal. Improving this is left as an exercise to the reader. +#[derive(Debug)] +pub struct CameraLookAt { + // Object the camera is looking at. + pub center: [f32; 3], + // Angle around the object on the horizontal plane, in radians. + pub angle: f32, + // Height between -1 and 1, 0 is flat, 1 is zenith, -1 is nadir + pub height: f32, + // Distance from center + pub distance: f32, +} + +impl Default for CameraLookAt { + fn default() -> Self { + // See object in 0,0,0 from the front top left + CameraLookAt { + center: [0.0, 0.0, 0.0], + angle: 2.0 * std::f32::consts::FRAC_PI_3, + height: 0.3, + distance: f32::sqrt(72.0), + } + } +} + +impl CameraLookAt { + /// Pan the camera with middle mouse click, zoom with scroll wheel, orbit with right mouse click. + pub fn update(&mut self, mouse_state: &MouseState, window_size: [f32; 2]) { + // change input mapping for orbit and panning here + let orbit_button = MouseButton::Right; + let translation_button = MouseButton::Middle; + + if mouse_state.position_delta[0] != 0.0 || mouse_state.position_delta[1] != 0.0 { + if mouse_state.pressed(orbit_button) { + let delta_x = + mouse_state.position_delta[0] / window_size[0] * std::f32::consts::PI * 2.0; + let delta_y = mouse_state.position_delta[1] / window_size[1] * std::f32::consts::PI; + self.angle += delta_x; + self.height += delta_y; + self.height = self + .height + .max(-std::f32::consts::FRAC_PI_2 + 0.001) + .min(std::f32::consts::FRAC_PI_2 - 0.001); + } + + if mouse_state.pressed(translation_button) { + let dir = [self.angle.cos(), self.angle.sin()]; + let translation_dir = [-dir[1], dir[0]]; + let translation_weight = + mouse_state.position_delta[0] / window_size[0] * self.distance; + + self.center[0] += translation_dir[0] * translation_weight; + self.center[2] += translation_dir[1] * translation_weight; + self.center[1] += mouse_state.position_delta[1] / window_size[1] * self.distance; + } + } + + if mouse_state.scroll_delta != 0.0 { + self.distance -= mouse_state.scroll_delta * self.distance * 0.2; + // Don't allow zoom to reach 0 or 1e6 to avoid getting stuck / in float precision issue realm. + self.distance = self.distance.max(0.05).min(1e6); + } + } +} diff --git a/lib/src/demo_raymarching.rs b/lib/src/demo_raymarching.rs index dd8508c..54f1cee 100644 --- a/lib/src/demo_raymarching.rs +++ b/lib/src/demo_raymarching.rs @@ -1,5 +1,6 @@ use wgpu::util::DeviceExt; +use crate::camera_control::CameraLookAt; use crate::frame_rate::FrameRate; use crate::program::{Program, ProgramError}; use crate::shader_builder::ShaderBuilder; @@ -53,6 +54,7 @@ pub struct DemoRaymarchingProgram { elapsed: f32, // elapsed take the speed into consideration frame_rate: FrameRate, size: [f32; 2], + camera: CameraLookAt, } impl Program for DemoRaymarchingProgram { @@ -72,6 +74,7 @@ impl Program for DemoRaymarchingProgram { elapsed: 0.0, frame_rate: FrameRate::new(200), size: [0.0, 0.0], + camera: CameraLookAt::default(), }) } @@ -115,7 +118,17 @@ impl Program for DemoRaymarchingProgram { queue.write_buffer( &self.render_pass.uniform_buf, 0, - bytemuck::cast_slice(&[self.elapsed, self.size[0], self.size[1], 0.0]), + bytemuck::cast_slice(&[ + self.elapsed, + self.size[0], + self.size[1], + self.camera.angle, + self.camera.center[0], + self.camera.center[1], + self.camera.center[2], + self.camera.height, + self.camera.distance, + ]), ); } @@ -158,6 +171,10 @@ impl Program for DemoRaymarchingProgram { ui.separator(); ui.label(std::format!("framerate: {:.0}fps", self.frame_rate.get())); } + + fn get_camera(&mut self) -> Option<&mut crate::camera_control::CameraLookAt> { + Some(&mut self.camera) + } } impl DemoRaymarchingProgram { @@ -214,7 +231,7 @@ impl DemoRaymarchingProgram { // create uniform buffer. let uniforms = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { label: Some("Camera Buffer"), - contents: bytemuck::cast_slice(&[0.0, 0.0, 0.0, 0.0]), + contents: bytemuck::cast_slice(&[0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0, 0.0]), usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, }); diff --git a/lib/src/lib.rs b/lib/src/lib.rs index ef4c84d..e8f9d34 100644 --- a/lib/src/lib.rs +++ b/lib/src/lib.rs @@ -7,7 +7,9 @@ mod demo_boids; mod demo_polygon; mod demo_raymarching; +pub mod camera_control; mod frame_rate; +pub mod mouse_input; pub mod program; pub mod reload_flags; mod shader_builder; @@ -111,3 +113,10 @@ pub fn program_required_downlevel_capabilities() -> wgpu::DownlevelCapabilities pub fn program_required_limits() -> wgpu::Limits { CurrentProgram::required_limits() } + +#[no_mangle] +pub fn get_program_camera( + program: &mut CurrentProgram, +) -> Option<&mut crate::camera_control::CameraLookAt> { + program.get_camera() +} diff --git a/lib/src/mouse_input.rs b/lib/src/mouse_input.rs new file mode 100644 index 0000000..5bb2e40 --- /dev/null +++ b/lib/src/mouse_input.rs @@ -0,0 +1,60 @@ +use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; + +// Naive mouse state. +#[derive(Debug, Default)] +pub struct MouseState { + pub position: [f32; 2], + pub position_delta: [f32; 2], + pub left: bool, + pub right: bool, + pub middle: bool, + pub scroll_delta: f32, +} + +impl MouseState { + pub fn pressed(&self, button: MouseButton) -> bool { + match button { + MouseButton::Left => self.left, + MouseButton::Right => self.right, + MouseButton::Middle => self.middle, + _ => false, + } + } + + // Called on Event::RedrawRequested since it's the start of a new frame. + pub fn clear_deltas(&mut self) { + self.position_delta = [0.0, 0.0]; + self.scroll_delta = 0.0; + } + + // Called on relevant window events. + pub fn on_window_event(&mut self, window_event: WindowEvent) { + self.position_delta = [0.0, 0.0]; + self.scroll_delta = 0.0; + match window_event { + WindowEvent::MouseInput { button, state, .. } => match button { + MouseButton::Left => self.left = state == ElementState::Pressed, + MouseButton::Right => self.right = state == ElementState::Pressed, + MouseButton::Middle => self.middle = state == ElementState::Pressed, + _ => (), + }, + WindowEvent::CursorMoved { position, .. } => { + self.position_delta = [ + position.x as f32 - self.position[0], + position.y as f32 - self.position[1], + ]; + self.position = [position.x as f32, position.y as f32]; + } + WindowEvent::MouseWheel { delta, .. } => { + log::info!("delta: {:?}", delta); + match delta { + // native mode: line delta should be 1 or -1 + MouseScrollDelta::LineDelta(_, y) => self.scroll_delta = y, + // wasm: pixel delta is around 100 * display_scale + MouseScrollDelta::PixelDelta(pos) => self.scroll_delta = pos.y as f32 / 100.0, + } + } + _ => (), + } + } +} diff --git a/lib/src/program.rs b/lib/src/program.rs index 8fc9d19..d34af9d 100644 --- a/lib/src/program.rs +++ b/lib/src/program.rs @@ -93,4 +93,8 @@ pub trait Program: Sized { // These downlevel limits will allow the code to run on all possible hardware wgpu::Limits::downlevel_webgl2_defaults() } + + fn get_camera(&mut self) -> Option<&mut crate::camera_control::CameraLookAt> { + None + } } diff --git a/shaders/demo_raymarching/camera.wgsl b/shaders/demo_raymarching/camera.wgsl new file mode 100644 index 0000000..161922e --- /dev/null +++ b/shaders/demo_raymarching/camera.wgsl @@ -0,0 +1,18 @@ +fn get_camera_ray(x: f32, y: f32) -> vec3 { + let fov = 60.0; + let xy: vec2 = vec2(x, y) - vec2(uniforms.width, uniforms.height) / 2.0; + let z: f32 = uniforms.height / tan(radians(fov) / 2.0); + return normalize(vec3(xy.x, -xy.y, -z)); +} + +fn get_view_matrix(eye: vec3, center: vec3, up: vec3) -> mat4x4 { + let f = normalize(center - eye); + let s = normalize(cross(f, up)); + let u = cross(s, f); + return mat4x4( + vec4(s, 0.0), + vec4(u, 0.0), + vec4(-f, 0.0), + vec4(-dot(eye, s), -dot(eye, u), dot(eye, f), 1.0) + ); +} diff --git a/shaders/demo_raymarching/common.wgsl b/shaders/demo_raymarching/common.wgsl index e2c5c84..6bee3d1 100644 --- a/shaders/demo_raymarching/common.wgsl +++ b/shaders/demo_raymarching/common.wgsl @@ -3,7 +3,10 @@ struct Uniforms { elapsed: f32, width: f32, height: f32, - _padding: f32, // padding to 16 bytes, required for WebGL. + camera_angle: f32, + camera_center: vec3, + camera_height: f32, + camera_distance: f32, }; struct VertexInput { diff --git a/shaders/demo_raymarching/draw_2d.wgsl b/shaders/demo_raymarching/draw_2d.wgsl index 5f0f0e1..175c729 100644 --- a/shaders/demo_raymarching/draw_2d.wgsl +++ b/shaders/demo_raymarching/draw_2d.wgsl @@ -4,7 +4,6 @@ fn sdf_circle(pos: vec2, origin: vec2, radius: f32) -> f32 { return length(pos - origin) - radius; } - fn sdf_square(pos: vec2, origin: vec2, size: f32, rounding: f32) -> f32 { let d = abs(pos - origin) - vec2(size - rounding, size - rounding); return length(max(d, vec2(0.0, 0.0))) + min(max(d.x, d.y), 0.0) - rounding; diff --git a/shaders/demo_raymarching/draw_3d.wgsl b/shaders/demo_raymarching/draw_3d.wgsl index 695a4bf..0cfc9dc 100644 --- a/shaders/demo_raymarching/draw_3d.wgsl +++ b/shaders/demo_raymarching/draw_3d.wgsl @@ -1,47 +1,22 @@ -const EPSILON = 0.001; - -fn get_camera_ray(x: f32, y: f32) -> vec3 { - let fov = 60.0; - let xy = vec2(x, y) - vec2(uniforms.width, uniforms.height) / 2.0; - let z = uniforms.height / tan(radians(fov) / 2.0); - return normalize(vec3(xy, -z)); -} - +#import "demo_raymarching/camera.wgsl" -fn estimateNormal(p: vec3) -> vec3 { - return normalize(vec3( - sdf_scene(vec3(p.x + EPSILON, p.y, p.z)).x - sdf_scene(vec3(p.x - EPSILON, p.y, p.z)).x, - sdf_scene(vec3(p.x, p.y + EPSILON, p.z)).x - sdf_scene(vec3(p.x, p.y - EPSILON, p.z)).x, - sdf_scene(vec3(p.x, p.y, p.z + EPSILON)).x - sdf_scene(vec3(p.x, p.y, p.z - EPSILON)).x - )); -} +const EPSILON = 0.001; fn sdf_sphere(p: vec3, origin: vec3, radius: f32) -> f32 { return length(p - origin) - radius; } - fn sdf_round_box(position: vec3, origin: vec3, radius: f32, rounding: f32) -> f32 { let q = abs(position - origin) - vec3(radius); return length(max(q, vec3(0.0))) + min(max(q.x, max(q.y, q.z)), 0.0) - rounding; } - - fn sdf_cube(p: vec3, origin: vec3, radius: f32) -> f32 { - // If d.x < 0, then -1 < p.x < 1, and same logic applies to p.y, p.z - // So if all components of d are negative, then p is inside the unit cube let d = abs(p - origin) - vec3(radius); - - // Assuming p is inside the cube, how far is it from the surface? - // Result will be negative or zero. let insideDistance = min(max(d.x, max(d.y, d.z)), 0.0); - - // Assuming p is outside the cube, how far is it from the surface? - // Result will be positive or zero. let outsideDistance = length(max(d, vec3(0.0))); - return insideDistance + outsideDistance; } +// colored sdf: vec4 == (distance, r, g, b) fn sdf_col(a: vec4, b: vec4) -> vec4 { if a.x < b.x { return a; @@ -55,8 +30,9 @@ fn sdf_scene(position: vec3) -> vec4 { let id = round(position / s); var pos = position - s * id; - pos += sin(uniforms.elapsed * (id.z + id.x + id.y) * 2.0); + pos += 0.5 * sin((uniforms.elapsed + (id.x + id.y + id.z)) * 10.0); + // colored sdf: (distance, r, g, b) var d = vec4(1000.0, 0.0, 0.0, 0.0); // body @@ -64,15 +40,15 @@ fn sdf_scene(position: vec3) -> vec4 { // eyes let sqrt2 = sqrt(2.0) / 2.0; - d = sdf_col(d, vec4(sdf_round_box(pos, vec3(sqrt2 - 0.2, -sqrt2, 0.7), 0.2, 0.15), 0.02, 0.02, 0.02)); - d = sdf_col(d, vec4(sdf_round_box(pos, vec3(-sqrt2 + 0.2, -sqrt2, 0.7), 0.2, 0.15), 0.02, 0.02, 0.02)); + d = sdf_col(d, vec4(sdf_round_box(pos, vec3(-(sqrt2 - 0.2), sqrt2, 0.7), 0.2, 0.15), 0.02, 0.02, 0.02)); + d = sdf_col(d, vec4(sdf_round_box(pos, vec3(sqrt2 - 0.2, sqrt2, 0.7), 0.2, 0.15), 0.02, 0.02, 0.02)); // nose - d = sdf_col(d, vec4(sdf_sphere(pos, vec3(0.0, -0.2, 0.7), 0.35), 1.0, 0.3, 0.0)); + d = sdf_col(d, vec4(sdf_sphere(pos, vec3(0.0, 0.2, 0.7), 0.35), 1.0, 0.3, 0.0)); // smile - let big_sphere = sdf_sphere(pos, vec3(0.0, -0.7, 0.6), 1.0); - let small_sphere = sdf_sphere(pos, vec3(0.0, -0.35, 0.6), 0.8); + let big_sphere = sdf_sphere(pos, vec3(0.0, 0.7, 0.4), 1.0); + let small_sphere = sdf_sphere(pos, vec3(0.0, 0.35, 0.4), 0.8); let smile = max(-big_sphere, small_sphere); d = sdf_col(d, vec4(smile, 4.0, 0.0, 0.0)); @@ -80,8 +56,16 @@ fn sdf_scene(position: vec3) -> vec4 { return d; } +fn estimate_normal(p: vec3) -> vec3 { + return normalize(vec3( + sdf_scene(vec3(p.x + EPSILON, p.y, p.z)).x - sdf_scene(vec3(p.x - EPSILON, p.y, p.z)).x, + sdf_scene(vec3(p.x, p.y + EPSILON, p.z)).x - sdf_scene(vec3(p.x, p.y - EPSILON, p.z)).x, + sdf_scene(vec3(p.x, p.y, p.z + EPSILON)).x - sdf_scene(vec3(p.x, p.y, p.z - EPSILON)).x + )); +} + fn phong_lighting(k_d: f32, k_s: f32, alpha: f32, position: vec3, eye: vec3, light_pos: vec3, light_intensity: vec3) -> vec3 { - let N = estimateNormal(position); + let N = estimate_normal(position); let L = normalize(light_pos - position); let V = normalize(eye - position); let R = normalize(reflect(-L, N)); @@ -102,46 +86,59 @@ fn phong_lighting(k_d: f32, k_s: f32, alpha: f32, position: vec3, eye: vec3 return light_intensity * (k_d * dotLN + k_s * pow(dotRV, alpha)); } -fn viewMatrix(eye: vec3, center: vec3, up: vec3) -> mat4x4 { - let f = normalize(center - eye); - let s = normalize(cross(f, up)); - let u = cross(s, f); - return mat4x4( - vec4(s, 0.0), - vec4(u, 0.0), - vec4(-f, 0.0), - vec4(0.0, 0.0, 0.0, 1.0) - ); +// interpolation on a sphere. +fn slerp(p0: vec3, p1: vec3, t: f32) -> vec3 { + let dotp = dot(normalize(p0), normalize(p1)); + if (dotp > 1.0 - EPSILON) || (dotp < -1.0 + EPSILON) { + if t <= 0.5 { + return p0; + } + return p1; + } + let theta = acos(dotp); + var P = ((p0 * sin((1.0 - t) * theta) + p1 * sin(t * theta)) / sin(theta)); + return P; } - +// entry point of the 3d raymarching. fn sdf_3d(p: vec2) -> vec4 { - var ray = get_camera_ray(p.x, p.y); var time: f32 = uniforms.elapsed; - // time = 0.0; - var eye = vec3(-2.6 + sin(time), -2.4 + cos(time), 6.0); - let look_at = vec3(2.0 * sin(time), 2.0 * cos(2.0 * time), 0.0); - let up = vec3(0.0, 0.2 * cos(time * 0.2), abs(sin(time * 0.7)) - 0.3); - let matrix = viewMatrix(eye, look_at, normalize(up)); - ray = (matrix * vec4(ray, 0.0)).xyz; + // camera look at. + let look_at: vec3 = uniforms.camera_center; + + // compute direction. + var angle: vec3 = vec3(cos(uniforms.camera_angle), 0.0, sin(uniforms.camera_angle)); + let up = vec3(0.0, 1.0, 0.0); + angle = slerp(angle, up, uniforms.camera_height); + + // camera position. + var eye: vec3 = look_at + angle * uniforms.camera_distance; + // compute ray direction. + let matrix: mat4x4 = get_view_matrix(eye, look_at, up); + var ray: vec3 = get_camera_ray(p.x, p.y); // ray in camera space. + ray = (matrix * vec4(ray, 0.0)).xyz; // ray in world space. + + // actual ray marching. let MAX_STEPS = 100; var position = eye; + var dist = vec4(0.0); for (var i = 0; i < MAX_STEPS; i++) { - let dist = sdf_scene(position); - if dist.x < 0.001 { + dist = sdf_scene(position); + if dist.x < EPSILON { break; } position += ray * dist.x; } - let dist = sdf_scene(position); - var color = dist.yzw; if dist.x < EPSILON { - color = phong_lighting(0.8, 0.5, 50.0, position, eye, vec3(-2.0, -3.0, 4.0), dist.yzw); + // add lighting only if we hit something. + color = phong_lighting(0.8, 0.5, 50.0, position, eye, vec3(-5.0, 5.0, 5.0), dist.yzw); } + + // add fog. let view_dist = length(position - eye); let fog = exp(-0.04 * view_dist); color = mix(color, vec3(0.00, 0.0, 0.05), 1.0 - fog); diff --git a/src/runner.rs b/src/runner.rs index e05e7d1..551ec24 100644 --- a/src/runner.rs +++ b/src/runner.rs @@ -85,8 +85,11 @@ async fn run( .get_default_config(&adapter, size.width, size.height) .expect("Surface isn't supported by the adapter."); - // Comment to disable freerun and enable v-sync. - config.present_mode = wgpu::PresentMode::Immediate; + // Comment to disable freerun and enable v-sync. Note that this is only valid in native. + #[cfg(not(target_arch = "wasm32"))] + { + config.present_mode = wgpu::PresentMode::Immediate; + } surface.configure(&device, &config); @@ -101,6 +104,8 @@ async fn run( let egui_context = egui::Context::default(); let mut egui_renderer = Renderer::new(&device, config.format, None, 1); + let mut mouse_state = lib::mouse_input::MouseState::default(); + event_loop.run(move |event, _, control_flow| { // Have the closure take ownership of the resources. // `event_loop.run` never returns, therefore we must do this to ensure @@ -132,6 +137,14 @@ async fn run( library_bridge::resize_program(&mut program, &config, &device, &queue); } } + WindowEvent::CursorMoved { .. } + | WindowEvent::MouseInput { .. } + | WindowEvent::MouseWheel { .. } => { + mouse_state.on_window_event(window_event); + if let Some(camera) = library_bridge::get_program_camera(&mut program) { + camera.update(&mouse_state, [size.width as f32, size.height as f32]); + }; + } _ => { // ignore event response. let _ = egui_state.on_event(&egui_context, &window_event); @@ -142,6 +155,7 @@ async fn run( window.request_redraw(); } Event::RedrawRequested(_) => { + mouse_state.clear_deltas(); let mut data = data.lock().unwrap(); // Reload shaders if needed if !data.shaders.is_empty() {