separate out renderer crate (#13)

* add keyboard handler

* some cleanup

* add l key binding

* Fix WASM shared memory generation for Rust nightly 2025-10-20+

Add explicit linker flags for shared memory generation after Rust PR #147225
removed automatic flags with +atomics. This fixes DataCloneError when sharing
memory with workers.

Changes:
- Add --shared-memory, --max-memory, --import-memory flags
- Export TLS symbols: __wasm_init_tls, __tls_size, __tls_align, __tls_base
- Update both Cargo.toml and package.json build scripts

Amp-Thread-ID: https://ampcode.com/threads/T-41d99b69-5754-4fdf-b06e-a46343fa7485
Co-authored-by: Amp <amp@ampcode.com>

* add a level-editor crate

* remove redundant worker setup

* simplify app setup

* add SceneBuilder

* separate out default scene implementation in a crate

* define default method for MeshBuilder

---------

Co-authored-by: Amp <amp@ampcode.com>
This commit is contained in:
Akash Shakdwipeea
2025-10-22 18:08:02 +05:30
committed by GitHub
co-authored by Amp
parent cb65a52e95
commit b295c0bf0b
33 changed files with 5516 additions and 995 deletions
+222
View File
@@ -0,0 +1,222 @@
use std::sync::mpsc::{self, Sender};
use wasm_bindgen::closure::Closure;
use wasm_bindgen::prelude::*;
use wasm_bindgen::JsCast;
#[cfg(target_arch = "wasm32")]
use web_sys::AddEventListenerOptions;
#[cfg(target_arch = "wasm32")]
use wgpu::Error;
use crate::message::WindowEvent;
#[cfg(target_arch = "wasm32")]
use crate::platform::web;
#[cfg(target_arch = "wasm32")]
use crate::platform::web::worker::MainWorker;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen_futures::spawn_local;
/// Helper struct to store event listener closures
#[cfg(target_arch = "wasm32")]
pub struct EventListeners {
pub resize_listener: Option<Closure<dyn FnMut()>>,
pub mousemove_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
pub mousedown_listener: Option<Closure<dyn FnMut(web_sys::MouseEvent)>>,
pub wheel_listener: Option<Closure<dyn FnMut(web_sys::WheelEvent)>>,
pub keyboard_listener: Option<Closure<dyn FnMut(web_sys::KeyboardEvent)>>,
}
#[cfg(target_arch = "wasm32")]
impl EventListeners {
pub fn new() -> Self {
Self {
resize_listener: None,
mousemove_listener: None,
mousedown_listener: None,
wheel_listener: None,
keyboard_listener: None,
}
}
}
/// Setup default window event listeners that forward events to the worker thread
#[cfg(target_arch = "wasm32")]
pub fn setup_event_listeners(worker_chan: &Sender<WindowEvent>) -> Result<EventListeners, JsValue> {
let window = web_sys::window().unwrap();
let resize_worker_chan = worker_chan.clone();
let resize_listener: Closure<dyn FnMut()> = Closure::new(move || {
use crate::message::ResizeMessage;
let window = web_sys::window().unwrap();
let width = window.inner_width().ok().unwrap().as_f64().unwrap();
let height = window.inner_height().ok().unwrap().as_f64().unwrap();
resize_worker_chan
.send(WindowEvent::Resize(ResizeMessage {
width,
height,
scale_factor: window.device_pixel_ratio(),
}))
.unwrap();
});
window.add_event_listener_with_callback("resize", resize_listener.as_ref().unchecked_ref())?;
let mousemove_worker_chan = worker_chan.clone();
let mousemove_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
Closure::new(move |event: web_sys::MouseEvent| {
use crate::message::MouseMessage;
if event.buttons() & 0x04 != 0 {
event.prevent_default();
}
let mouse_event_data = MouseMessage::from_evt(event.clone());
let mut event_data = WindowEvent::PointerMove(mouse_event_data.clone());
if event.type_() == "click" {
event_data = WindowEvent::PointerClick(mouse_event_data.clone());
}
mousemove_worker_chan.clone().send(event_data).unwrap();
});
window.add_event_listener_with_callback(
"mousemove",
mousemove_listener.as_ref().unchecked_ref(),
)?;
window
.add_event_listener_with_callback("click", mousemove_listener.as_ref().unchecked_ref())?;
let mousedown_listener: Closure<dyn FnMut(web_sys::MouseEvent)> =
Closure::new(move |event: web_sys::MouseEvent| {
if event.button() == 1 {
event.prevent_default();
}
});
window.add_event_listener_with_callback(
"mousedown",
mousedown_listener.as_ref().unchecked_ref(),
)?;
let wheel_worker_chan = worker_chan.clone();
let wheel_listener: Closure<dyn FnMut(web_sys::WheelEvent)> =
Closure::new(move |event: web_sys::WheelEvent| {
use crate::message::WheelMessage;
event.prevent_default();
let wheel_event_data = WheelMessage::from_evt(event);
wheel_worker_chan
.send(WindowEvent::PointerWheel(wheel_event_data))
.unwrap();
});
let wheel_options = {
let options = AddEventListenerOptions::new();
options.set_passive(false);
options
};
window.add_event_listener_with_callback_and_add_event_listener_options(
"wheel",
wheel_listener.as_ref().unchecked_ref(),
&wheel_options,
)?;
let keyboard_worker_chan = worker_chan.clone();
let keyboard_listener: Closure<dyn FnMut(web_sys::KeyboardEvent)> =
Closure::new(move |event: web_sys::KeyboardEvent| {
use crate::message::KeyboardMessage;
let keyboard_event_data = KeyboardMessage::from_evt(event);
keyboard_worker_chan
.send(WindowEvent::Keyboard(keyboard_event_data))
.unwrap();
});
window
.add_event_listener_with_callback("keydown", keyboard_listener.as_ref().unchecked_ref())?;
Ok(EventListeners {
resize_listener: Some(resize_listener),
mousemove_listener: Some(mousemove_listener),
mousedown_listener: Some(mousedown_listener),
wheel_listener: Some(wheel_listener),
keyboard_listener: Some(keyboard_listener),
})
}
/// Runtime resources required to keep a WASM application running.
#[cfg(target_arch = "wasm32")]
pub struct WebAppRuntime {
worker: MainWorker,
worker_chan: Sender<WindowEvent>,
_event_listeners: EventListeners,
}
#[cfg(target_arch = "wasm32")]
impl WebAppRuntime {
/// Initialize the web worker, canvas ownership, and event listeners.
pub fn new<T: crate::renderer::scene::Scene + 'static>(worker_name: &str, canvas_selector: &str) -> Result<Self, JsValue> {
let (sender, receiver) = mpsc::channel::<WindowEvent>();
let canvas = web::get_canvas_element(canvas_selector);
let worker = MainWorker::spawn(worker_name, 1, move || {
spawn_local(async move {
MainWorker::run_render_loop::<T>(receiver).await;
});
})?;
worker.transfer_ownership(&canvas);
let event_listeners = setup_event_listeners(&sender)?;
Ok(Self {
worker,
worker_chan: sender,
_event_listeners: event_listeners,
})
}
/// Access the worker channel sender for dispatching custom window events.
pub fn sender(&self) -> &Sender<WindowEvent> {
&self.worker_chan
}
/// Access the spawned worker reference.
pub fn worker(&self) -> &MainWorker {
&self.worker
}
}
/// Trait for applications that rely on the renderer's default WASM setup.
#[cfg(target_arch = "wasm32")]
pub trait WebApp {
type Scene: crate::renderer::scene::Scene + 'static;
/// Name used for the spawned `MainWorker`.
fn worker_name() -> &'static str {
"main-worker"
}
/// CSS selector for the canvas element that will be transferred to the worker.
fn canvas_selector() -> &'static str {
"#canvas0"
}
/// Hook invoked after the runtime has been created.
fn on_runtime_initialized(_runtime: &mut WebAppRuntime) {}
/// Perform the default WASM initialization routine.
fn setup_runtime() -> Result<WebAppRuntime, JsValue> {
let mut runtime = WebAppRuntime::new::<Self::Scene>(
Self::worker_name(),
Self::canvas_selector(),
)?;
Self::on_runtime_initialized(&mut runtime);
Ok(runtime)
}
}
+3
View File
@@ -0,0 +1,3 @@
fn main() {
println!("hello world!");
}
+296
View File
@@ -0,0 +1,296 @@
use std::f32::consts::PI;
use ultraviolet::{projection, Bivec3, Mat4, Rotor3, Vec3};
use wgpu::util::DeviceExt;
use crate::{message::WheelMessage, renderer::scene::UniformResource};
const MIN_DISTANCE: f32 = 0.1;
const MAX_PITCH: f32 = PI / 2.0 - 0.01;
const ORBIT_SENSITIVITY: f32 = 0.005;
const ZOOM_SENSITIVITY: f32 = 0.002;
#[repr(C)]
pub struct Camera {
// Hot data - cached computed matrix (64 bytes, 1 cache line)
pub view_proj: [[f32; 4]; 4],
// Warm data - frequently accessed vectors (36 bytes)
position: Vec3,
target: Vec3,
up: Vec3,
// Cold data - projection parameters (16 bytes)
fov: f32,
aspect_ratio: f32,
z_near: f32,
z_far: f32,
// Rotor orientation for orbit camera behaviour
rotor: Rotor3,
distance: f32,
// Dirty flag for lazy evaluation
dirty: bool,
}
struct OrthonormalBasis {
right: Vec3,
up: Vec3,
forward: Vec3,
}
impl OrthonormalBasis {
pub fn new(right: Vec3, up: Vec3, forward: Vec3) -> Self {
Self { right, up, forward }
}
pub fn from_camera(camera: &Camera) -> Self {
let mut forward_offset = camera.target - camera.position;
if forward_offset.mag_sq() <= f32::EPSILON {
forward_offset = -Vec3::unit_z();
}
let forward = forward_offset.normalized();
let mut right = forward.cross(camera.up);
// Check if right vector is near zero (forward and up are parallel)
if right.mag_sq() < 1e-10 {
// Try alternate axes to find a valid right vector
let alternate_axes = [Vec3::unit_y(), Vec3::unit_x()];
for axis in alternate_axes.iter() {
right = forward.cross(*axis);
if right.mag_sq() >= 1e-10 {
break;
}
}
}
right = right.normalized();
let up = right.cross(forward).normalized();
Self::new(right, up, forward)
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Zeroable, bytemuck::Pod)]
pub struct CameraUniform {
view_proj: [[f32; 4]; 4],
}
impl Camera {
pub fn new(aspect_ratio: f32) -> Self {
let mut camera = Camera {
view_proj: [[0.0; 4]; 4],
position: Vec3::new(0.0, 0.5, 3.0),
target: Vec3::new(0.0, 0.0, 0.0),
up: Vec3::unit_y(),
fov: PI / 3.0,
aspect_ratio,
z_near: 0.1,
z_far: 100000.0,
rotor: Rotor3::identity(),
distance: 1.0,
dirty: true,
};
camera.compute_rotor();
camera.compute_view_proj_mat();
camera
}
pub fn compute_view_proj_mat(&mut self) {
let view = Mat4::look_at(self.position, self.target, self.up);
let proj = projection::rh_yup::perspective_wgpu_dx(
self.fov,
self.aspect_ratio,
self.z_near,
self.z_far,
);
self.view_proj = (proj * view).into();
self.dirty = false;
}
pub fn look_at(&mut self, position: Vec3, target: Vec3) {
self.position = position;
self.target = target;
self.up = Vec3::unit_y();
self.compute_rotor();
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn set_depth_range(&mut self, z_near: f32, z_far: f32) {
self.z_near = z_near;
self.z_far = z_far.max(z_near + f32::EPSILON);
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn position(&self) -> Vec3 {
self.position
}
pub fn update_aspect_ratio(&mut self, aspect_ratio: f32) {
self.aspect_ratio = aspect_ratio;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn orbit(&mut self, delta_x: f32, delta_y: f32) {
// Skip tiny movements to reduce unnecessary computations
if delta_x.abs() < 0.001 && delta_y.abs() < 0.001 {
return;
}
let yaw_theta = delta_x * ORBIT_SENSITIVITY;
let yaw_rotor =
Rotor3::from_angle_plane(yaw_theta, Bivec3::from_normalized_axis(Vec3::unit_y()));
let basis = OrthonormalBasis::from_camera(self);
let pitch_angle = (delta_y * ORBIT_SENSITIVITY).clamp(-MAX_PITCH, MAX_PITCH);
let pitch_rotor =
Rotor3::from_angle_plane(pitch_angle, Bivec3::from_normalized_axis(basis.right));
let orbit_rotor = (yaw_rotor * pitch_rotor).normalized();
self.rotor = (orbit_rotor * self.rotor).normalized();
let mut offset = self.position - self.target;
if offset.mag_sq() <= f32::EPSILON {
offset = Vec3::unit_z() * self.distance.max(MIN_DISTANCE);
}
orbit_rotor.rotate_vec(&mut offset);
self.distance = offset.mag().max(MIN_DISTANCE);
self.position = offset + self.target;
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn zoom(&mut self, msg: &WheelMessage) {
let mut delta = msg.delta_y as f32;
// Match browser delta modes so the wheel delta is always roughly pixels.
match msg.delta_mode {
1 => delta *= 16.0,
2 => delta *= 800.0,
_ => {}
}
// Scrolling up should zoom in.
delta = -delta;
if delta.abs() <= f32::EPSILON {
return;
}
// Get forward direction from camera position to target
let mut forward_vec = self.target - self.position;
if forward_vec.mag_sq() <= f32::EPSILON {
forward_vec = Vec3::unit_z();
}
let forward_dir = forward_vec.normalized();
let current_distance = forward_vec.mag();
// Scale dolly movement by distance to target for consistent perceived zoom speed
let dolly_distance = delta * ZOOM_SENSITIVITY * current_distance;
let dolly_translation = forward_dir * dolly_distance;
self.position += dolly_translation;
self.target += dolly_translation;
self.compute_rotor();
self.dirty = true;
self.compute_view_proj_mat();
}
pub fn create_uniform_resource(&self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: "camera uniform buffer".into(),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
contents: bytemuck::cast_slice(&[self.view_proj]),
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Camera bind group layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Camera bind group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group,
bind_group_layout,
}
}
fn compute_rotor(&mut self) {
let offset = self.position - self.target;
let distance = (offset.x * offset.x + offset.y * offset.y + offset.z * offset.z).sqrt();
self.distance = distance.max(MIN_DISTANCE);
// to compute the initial rotor we will do two rotations
// these will orient the camera to the new coordinates
//
// but first we need the orthonormal basis for the current camera
let basis = OrthonormalBasis::from_camera(self);
// first rotation
// this is the swing to make position face the target
let camera_local_up = Vec3::unit_z();
let swing_rotor = Rotor3::from_rotation_between(camera_local_up, -basis.forward);
// now we need a twist rotor which aligns the camera up
let mut up_after_swing = self.up.clone();
swing_rotor.rotate_vec(&mut up_after_swing);
// to rotate a vector by a rotor we need
// - a bivector (represents the axis of rotation)
// - angle of rotation
let twist_axis = (-basis.forward).normalized();
let twist_plane = Bivec3::from_normalized_axis(twist_axis);
// Calculate twist angle between the up vectors:
// u1 × uc ⋅ (-f)
// θ = atan2( ————————————— , u1 ⋅ uc )
// ‖u1 × uc‖
//
// Where:
// u1 = up vector after swing rotation
// uc = camera's current up vector
// f = forward vector (twist axis)
let theta = up_after_swing
.cross(self.up)
.dot(twist_axis)
.atan2(up_after_swing.dot(self.up));
let twist_rotor = Rotor3::from_angle_plane(theta, twist_plane);
self.rotor = (swing_rotor * twist_rotor).normalized();
}
}
+35
View File
@@ -0,0 +1,35 @@
struct UniformData {
mouse_move: vec2<f32>,
mouse_click: vec2<f32>,
resolution: vec2<f32>,
time: f32,
}
@group(0) @binding(0) var<uniform> uni: UniformData;
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
struct VertexInput {
@location(0) pos: vec3<f32>,
@location(1) color: vec3<f32>
}
struct VertexOutput {
@builtin(position) pos: vec4<f32>,
@location(1) color: vec3<f32>
}
@vertex
fn v_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
out.pos = vec4<f32>(in.pos, 1.0);
let fluc = sin(modf(uni.time).fract * 3.141592) * 0.3 + 0.7;
out.color = in.color * fluc;
return out;
}
@fragment
fn f_main(in: VertexOutput) -> @location(0) vec4<f32> {
let x = select(0.0, 0.3, distance(in.pos.xy, uni.mouse_move) < 25.0);
let y = select(0.0, 0.3, distance(in.pos.xy, uni.mouse_click) < 25.0);
return vec4f(in.color + x - y, 1.0);
}
+216
View File
@@ -0,0 +1,216 @@
use gltf::Gltf;
use ultraviolet::{Mat4, Vec3};
use wgpu::TextureFormat;
use crate::renderer::scene::{mesh_vertex_layout, MeshBuilder};
#[derive(Clone, Copy, Debug)]
pub struct ModelBounds {
pub min: [f32; 3],
pub max: [f32; 3],
}
impl ModelBounds {
fn new(min: [f32; 3], max: [f32; 3]) -> Self {
Self { min, max }
}
fn include_point(&mut self, point: [f32; 3]) {
for i in 0..3 {
self.min[i] = self.min[i].min(point[i]);
self.max[i] = self.max[i].max(point[i]);
}
}
}
#[derive(Debug, thiserror::Error)]
pub enum ImportError {
#[error("failed to fetch the model")]
Http(#[from] reqwest::Error),
#[error("failed to decode bytes")]
GltfParse(#[from] gltf::Error),
#[error("failed to load model")]
LoadError,
#[error("{0}")]
Other(String),
}
fn convert_tex_coords(tex_coords: gltf::mesh::util::ReadTexCoords<'_>) -> Vec<[f32; 2]> {
use gltf::mesh::util::ReadTexCoords;
match tex_coords {
ReadTexCoords::F32(iter) => iter.collect(),
ReadTexCoords::U16(iter) => iter
.map(|[u, v]| [u as f32 / u16::MAX as f32, v as f32 / u16::MAX as f32])
.collect(),
ReadTexCoords::U8(iter) => iter
.map(|[u, v]| [u as f32 / u8::MAX as f32, v as f32 / u8::MAX as f32])
.collect(),
}
}
fn convert_indices(indices: gltf::mesh::util::ReadIndices<'_>) -> Vec<u32> {
use gltf::mesh::util::ReadIndices;
match indices {
ReadIndices::U8(iter) => iter.map(|i| i as u32).collect(),
ReadIndices::U16(iter) => iter.map(|i| i as u32).collect(),
ReadIndices::U32(iter) => iter.collect(),
}
}
fn visit_node<'a>(
node: gltf::Node<'a>,
parent_transform: Mat4,
device: &wgpu::Device,
resources: &mut crate::renderer::GpuResources,
meshes: &mut Vec<crate::renderer::scene::Mesh>,
data_blob: &[u8],
pipeline_index: usize,
model_bounds: &mut Option<ModelBounds>,
) {
let local_transform = Mat4::from(node.transform().matrix());
let world_transform = parent_transform * local_transform;
let normal_matrix = world_transform.inversed().transposed();
if let Some(mesh) = node.mesh() {
for primitive in mesh.primitives() {
let reader = primitive.reader(|buffer| match buffer.source() {
gltf::buffer::Source::Bin => Some(&data_blob[..]),
_ => None,
});
let positions: Vec<[f32; 3]> = match reader.read_positions() {
Some(iter) => iter.collect(),
None => Vec::new(),
};
if positions.is_empty() {
continue;
}
let vertex_count = positions.len();
let default_normal_vec = normal_matrix.transform_vec3(Vec3::unit_y()).normalized();
let default_normal = [
default_normal_vec.x,
default_normal_vec.y,
default_normal_vec.z,
];
let mut normals: Vec<[f32; 3]> = reader
.read_normals()
.map(|iter| {
iter.map(|normal| {
let vec = Vec3::new(normal[0], normal[1], normal[2]);
let transformed = normal_matrix.transform_vec3(vec).normalized();
[transformed.x, transformed.y, transformed.z]
})
.collect()
})
.unwrap_or_else(|| vec![default_normal; vertex_count]);
if normals.len() != vertex_count {
normals.resize(vertex_count, default_normal);
}
let mut uvs: Vec<[f32; 2]> = reader
.read_tex_coords(0)
.map(convert_tex_coords)
.unwrap_or_else(|| vec![[0.0, 0.0]; vertex_count]);
if uvs.len() != vertex_count {
uvs.resize(vertex_count, [0.0, 0.0]);
}
for position in &positions {
let vec = Vec3::new(position[0], position[1], position[2]);
let transformed = world_transform.transform_point3(vec);
let world_point = [transformed.x, transformed.y, transformed.z];
if let Some(bounds) = model_bounds.as_mut() {
bounds.include_point(world_point);
} else {
*model_bounds = Some(ModelBounds::new(world_point, world_point));
}
}
let indices: Vec<u32> = reader
.read_indices()
.map(convert_indices)
.unwrap_or_else(|| (0..vertex_count as u32).collect());
if indices.is_empty() {
continue;
}
let mesh = MeshBuilder::default()
.with_vertices(device, resources, &positions, &normals, &uvs)
.with_indices(device, resources, &indices)
.with_pipeline(pipeline_index)
.with_model_matrix(device, resources, world_transform)
.build();
meshes.push(mesh);
}
}
for child in node.children() {
visit_node(
child,
world_transform,
device,
resources,
meshes,
data_blob,
pipeline_index,
model_bounds,
);
}
}
pub async fn load_gltf_model(
device: &wgpu::Device,
resources: &mut crate::renderer::GpuResources,
meshes: &mut Vec<crate::renderer::scene::Mesh>,
surface_format: TextureFormat,
) -> Result<Option<ModelBounds>, ImportError> {
let glb_data = reqwest::get("http://localhost:8080/themanor.glb")
.await?
.bytes()
.await?;
let model = Gltf::from_slice(&glb_data)?;
let data_blob = model.blob.as_ref().ok_or(ImportError::LoadError)?;
let vertex_layout = mesh_vertex_layout();
let pipeline_index = resources.get_or_create_pipeline(
device,
"gltf_standard",
&vertex_layout,
include_str!("./gltf.wgsl"),
surface_format,
);
let mut model_bounds: Option<ModelBounds> = None;
for scene in model.scenes() {
for node in scene.nodes() {
visit_node(
node,
Mat4::identity(),
device,
resources,
meshes,
data_blob,
pipeline_index,
&mut model_bounds,
);
}
}
Ok(model_bounds)
}
+68
View File
@@ -0,0 +1,68 @@
struct UniformData {
mouse_move: vec2<f32>,
mouse_click: vec2<f32>,
resolution: vec2<f32>,
time: f32,
_padding0: f32,
camera_position: vec4<f32>,
}
@group(0) @binding(0) var<uniform> uni: UniformData;
@group(1) @binding(0) var<uniform> view_proj: mat4x4<f32>;
struct VertexInput {
@location(0) pos: vec3<f32>,
@location(1) normal: vec3<f32>,
@location(2) uv: vec2<f32>,
@location(3) model_col0: vec4<f32>,
@location(4) model_col1: vec4<f32>,
@location(5) model_col2: vec4<f32>,
@location(6) model_col3: vec4<f32>,
}
struct VertexOutput {
@builtin(position) clip_position: vec4<f32>,
@location(0) world_pos: vec3<f32>,
@location(1) normal: vec3<f32>
}
@vertex
fn vs_main(in: VertexInput) -> VertexOutput {
var out: VertexOutput;
let model = mat4x4<f32>(
in.model_col0,
in.model_col1,
in.model_col2,
in.model_col3,
);
let world_position = model * vec4<f32>(in.pos, 1.0);
out.clip_position = view_proj * world_position;
out.world_pos = world_position.xyz;
out.normal = normalize(in.normal);
return out;
}
@fragment
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
let light_direction = normalize(vec3<f32>(0.35, 1.0, 0.45));
let light_color = vec3<f32>(1.0, 0.95, 0.85);
let base_color = vec3<f32>(0.2, 0.2, 0.2);
let normal = normalize(in.normal);
let view_dir = normalize(uni.camera_position.xyz - in.world_pos);
let diffuse_strength = max(dot(normal, light_direction), 0.0);
let ambient = 0.15;
var specular = 0.0;
if diffuse_strength > 0.0 {
let halfway_dir = normalize(light_direction + view_dir);
specular = pow(max(dot(normal, halfway_dir), 0.0), 32.0);
}
let lighting = min(base_color * (ambient + diffuse_strength) + light_color * specular, vec3<f32>(1.0));
let x = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_move) < 25.0);
let y = select(0.0, 0.3, distance(in.clip_position.xy, uni.mouse_click) < 25.0);
return vec4<f32>(lighting + x - y, 1.0);
}
+30
View File
@@ -0,0 +1,30 @@
pub mod app_setup;
pub mod camera;
pub mod gltf;
pub mod message;
pub mod platform;
pub mod renderer;
/// Worker entrypoint helper - executes the closure it is spawned with
/// Applications should export this with #[wasm_bindgen]
pub fn worker_entrypoint_impl(ptr: u32) {
let work = unsafe { Box::from_raw(ptr as *mut Box<dyn FnOnce()>) };
(*work)();
}
/// Macro to export the worker_entrypoint function in application crates
///
/// Usage:
/// ```rust
/// use renderer::export_worker_entrypoint;
/// export_worker_entrypoint!();
/// ```
#[macro_export]
macro_rules! export_worker_entrypoint {
() => {
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn worker_entrypoint(ptr: u32) {
$crate::worker_entrypoint_impl(ptr);
}
};
}
+113
View File
@@ -0,0 +1,113 @@
use core::fmt;
#[derive(Debug)]
pub enum WindowEvent {
Resize(ResizeMessage),
PointerMove(MouseMessage),
PointerClick(MouseMessage),
PointerWheel(WheelMessage),
Keyboard(KeyboardMessage),
}
// Display for WindowEvent
impl fmt::Display for WindowEvent {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
WindowEvent::Resize(msg) => write!(f, "Resize: {:?}", msg),
WindowEvent::PointerMove(msg) => write!(f, "PointerMove: {:?}", msg),
WindowEvent::PointerClick(msg) => write!(f, "PointerClick: {:?}", msg),
WindowEvent::PointerWheel(msg) => write!(f, "PointerWheel: {:?}", msg),
WindowEvent::Keyboard(msg) => write!(f, "Keyboard: {:?}", msg),
}
}
}
#[derive(Debug, Clone)]
pub struct ResizeMessage {
pub scale_factor: f64,
pub width: f64,
pub height: f64,
}
#[derive(Debug, Clone)]
pub struct MouseMessage {
pub scale_factor: f64,
pub button: f64,
pub buttons: u16,
pub client_x: f64,
pub client_y: f64,
pub movement_x: f64,
pub movement_y: f64,
pub offset_x: f64,
pub offset_y: f64,
}
impl MouseMessage {
pub fn from_evt(event: web_sys::MouseEvent) -> Self {
let window = web_sys::window().unwrap();
Self {
scale_factor: window.device_pixel_ratio(),
button: event.button() as f64,
buttons: event.buttons(),
client_x: event.client_x() as f64,
client_y: event.client_y() as f64,
movement_x: event.movement_x() as f64,
movement_y: event.movement_y() as f64,
offset_x: event.offset_x() as f64,
offset_y: event.offset_y() as f64,
}
}
}
#[derive(Debug, Clone)]
pub struct WheelMessage {
pub scale_factor: f64,
pub delta_x: f64,
pub delta_y: f64,
pub delta_z: f64,
pub delta_mode: u32,
pub client_x: f64,
pub client_y: f64,
}
impl WheelMessage {
pub fn from_evt(event: web_sys::WheelEvent) -> Self {
let window = web_sys::window().unwrap();
Self {
scale_factor: window.device_pixel_ratio(),
delta_x: event.delta_x(),
delta_y: event.delta_y(),
delta_z: event.delta_z(),
delta_mode: event.delta_mode(),
client_x: event.client_x() as f64,
client_y: event.client_y() as f64,
}
}
}
#[derive(Debug, Clone)]
pub struct KeyboardMessage {
pub key: String,
pub code: String,
pub alt_key: bool,
pub ctrl_key: bool,
pub meta_key: bool,
pub shift_key: bool,
pub location: u32,
pub repeat: bool,
}
impl KeyboardMessage {
pub fn from_evt(event: web_sys::KeyboardEvent) -> Self {
Self {
key: event.key(),
code: event.code(),
alt_key: event.alt_key(),
ctrl_key: event.ctrl_key(),
meta_key: event.meta_key(),
shift_key: event.shift_key(),
location: event.location(),
repeat: event.repeat(),
}
}
}
+5
View File
@@ -0,0 +1,5 @@
#[cfg(target_arch = "wasm32")]
pub mod web;
#[cfg(not(target_arch = "wasm32"))]
mod native;
+1
View File
@@ -0,0 +1 @@
+16
View File
@@ -0,0 +1,16 @@
use wasm_bindgen::JsCast;
pub mod worker;
pub fn get_canvas_element(selectors: &str) -> web_sys::HtmlCanvasElement {
let window = web_sys::window().unwrap();
let document = window.document().unwrap();
let element = document.query_selector(selectors).unwrap().unwrap();
let canvas = element.dyn_into::<web_sys::HtmlCanvasElement>().unwrap();
let scale_factor = window.device_pixel_ratio();
let width = (canvas.client_width() as f64 * scale_factor) as u32;
let height = (canvas.client_height() as f64 * scale_factor) as u32;
canvas.set_width(width);
canvas.set_height(height);
canvas
}
@@ -0,0 +1,32 @@
// Generic worker that imports the app's WASM module relative to the generated pkg folder.
// Works for any application because the relative depth from this file to pkg is stable.
import initWasm, { worker_entrypoint } from "../../../../../../wasm-index.js";
export function attachMain() {}
let isReady = false;
onmessage = async (event) => {
console.log("worker received message", event);
if (isReady) return;
isReady = true;
const wasmModule = event.data[0]; // WebAssembly.Module from wasm_bindgen::module()
const workerId = event.data[1]; // worker ID
const memory = event.data[2]; // shared memory
const entryPtr = event.data[3]; // worker entrypoint function pointer
console.log(
"worker: initializing with WASM module",
wasmModule,
"id:",
workerId,
);
// Initialize WASM with the shared module and memory forwarded from the main thread.
await initWasm({ module_or_path: wasmModule, memory });
// Call the app-provided worker entrypoint once initialization completes.
worker_entrypoint(entryPtr);
};
+149
View File
@@ -0,0 +1,149 @@
use crate::message::WindowEvent;
use log::info;
use std::sync::mpsc::Receiver;
use std::{cell::RefCell, fmt::Debug, ops::Deref, rc::Rc};
use wasm_bindgen::{prelude::*, JsValue};
use wasm_bindgen_futures::JsFuture;
use web_sys::MessageEvent;
/// Binds JS.
#[wasm_bindgen(module = "/src/platform/web/worker/workerGen.js")]
extern "C" {
/// Spawn new worker in JS side in order to make bundler know about dependency.
#[wasm_bindgen(js_name = "createWorker")]
fn create_worker(kind: &str, name: &str) -> web_sys::Worker;
}
/// Binds JS.
/// This makes wasm-bindgen bring `mainWorker.js` to the `pkg` directory.
/// So that bundler can bundle it together.
#[wasm_bindgen(module = "/src/platform/web/worker/mainWorker.js")]
extern "C" {
/// Nothing to do.
#[wasm_bindgen]
fn attachMain();
}
pub struct MainWorker {
handle: web_sys::Worker,
name: String,
_callback: Closure<dyn FnMut(web_sys::Event)>,
}
impl Drop for MainWorker {
/// Terminates web worker *immediately*.
fn drop(&mut self) {
self.handle.terminate();
info!("Worker({}) was terminated", &self.name);
}
}
impl Debug for MainWorker {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.debug_struct("MainWorker")
.field("handle", &self.handle)
.field("name", &self.name)
.finish()
}
}
impl MainWorker {
/// Spawns main worker from the window context.
pub fn spawn(
name: &str,
id: usize,
f: impl FnOnce() + Send + 'static,
) -> Result<Self, JsValue> {
// Creates a new worker.
let handle = create_worker("main", name);
// Double-boxing because `dyn FnOnce` is unsized and so `Box<dyn FnOnce()>` has
// an undefined layout (although I think in practice its a pointer and a length?).
let ptr = Box::into_raw(Box::new(Box::new(f) as Box<dyn FnOnce()>));
// Sets default callback.
let callback = Closure::new(|_ev| {
info!("got a message..canvas?");
});
handle.set_onmessage(Some(callback.as_ref().unchecked_ref()));
let msg: js_sys::Array = [
&wasm_bindgen::module(),
&id.into(),
&wasm_bindgen::memory(),
&JsValue::from(ptr as u32),
]
.into_iter()
.collect();
info!("posting message");
handle.post_message(&msg)?;
Ok(Self {
handle,
name: name.to_owned(),
_callback: callback,
})
}
pub fn transfer_ownership(&self, canvas: &web_sys::HtmlCanvasElement) {
let offscreen_canvas = canvas.transfer_control_to_offscreen().unwrap();
let transfer_list = js_sys::Array::new();
transfer_list.push(&offscreen_canvas);
info!("posting canvas (is_undefined: {})", canvas.is_undefined());
self.handle
.post_message_with_transfer(&offscreen_canvas, &transfer_list)
.unwrap();
}
pub async fn run_render_loop<T: crate::renderer::scene::Scene + 'static>(
events_chan: Receiver<WindowEvent>,
) {
use crate::renderer::Renderer;
let canvas = wait_for_canvas_transfer().await;
let renderer = Rc::new(RefCell::new(Renderer::<T>::new(canvas, events_chan).await));
Renderer::run_render_loop(renderer);
}
}
impl Deref for MainWorker {
type Target = web_sys::Worker;
#[inline]
fn deref(&self) -> &Self::Target {
&self.handle
}
}
pub async fn wait_for_canvas_transfer() -> web_sys::OffscreenCanvas {
let global = js_sys::global().unchecked_into::<web_sys::DedicatedWorkerGlobalScope>();
let promise = js_sys::Promise::new(&mut |resolve, _reject| {
let handler = Closure::once(move |event: MessageEvent| {
let data = event.data();
info!("data received: {:?}", data);
// Check if the received data is an OffscreenCanvas directly
if data.is_instance_of::<web_sys::OffscreenCanvas>() {
resolve
.call1(&JsValue::NULL, &data)
.expect("resolve failed");
}
});
global.set_onmessage(Some(handler.as_ref().unchecked_ref()));
handler.forget();
});
let canvas: web_sys::OffscreenCanvas = JsFuture::from(promise)
.await
.expect("promise rejected")
.unchecked_into();
info!("received canvas: {:?}", canvas);
canvas
}
@@ -0,0 +1,13 @@
export function createWorker(kind, name) {
switch (kind) {
case 'main':
const main = new Worker(new URL('./mainWorker.js', import.meta.url), {
type: 'module',
/* @vite-ignore */ name, // vite doesn't allow non static value here.
});
return main;
default:
console.log("unsurpported type of worker: ", kind);
return undefined;
}
}
+748
View File
@@ -0,0 +1,748 @@
use std::{cell::RefCell, collections::HashMap, marker::PhantomData, rc::Rc, sync::mpsc::Receiver};
use futures::channel::oneshot;
use log::info;
use ultraviolet::Vec4;
use wasm_bindgen::{prelude::Closure, JsCast};
use wasm_bindgen_futures::{spawn_local, JsFuture};
use web_sys::{DedicatedWorkerGlobalScope, File, MessageEvent};
use crate::{
gltf::{load_gltf_model, ImportError, ModelBounds},
message::{MouseMessage, ResizeMessage, WindowEvent},
renderer::scene::Scene,
};
pub mod scene;
// Re-export commonly used types
pub use scene::Mesh;
const DEPTH_FORMAT: wgpu::TextureFormat = wgpu::TextureFormat::Depth32Float;
pub struct GpuResources {
// Core resources
buffers: Vec<wgpu::Buffer>,
pipelines: Vec<wgpu::RenderPipeline>,
textures: Vec<wgpu::Texture>,
// Layout management
pipeline_layouts: Vec<wgpu::PipelineLayout>,
bind_group_layouts: Vec<wgpu::BindGroupLayout>,
// Simple name-based pipeline lookup
pipeline_registry: HashMap<String, usize>,
// Shader modules cache
shader_modules: HashMap<String, wgpu::ShaderModule>,
}
impl GpuResources {
pub fn new() -> Self {
Self {
buffers: Vec::new(),
pipelines: Vec::new(),
textures: Vec::new(),
pipeline_layouts: Vec::new(),
bind_group_layouts: Vec::new(),
pipeline_registry: HashMap::new(),
shader_modules: HashMap::new(),
}
}
pub fn add_position_buffer(&mut self, buffer: wgpu::Buffer) -> BufferIndex<Position> {
let index = self.buffers.len() as u32;
self.buffers.push(buffer);
BufferIndex {
index,
_buffer_type: PhantomData,
}
}
pub fn add_normal_buffer(&mut self, buffer: wgpu::Buffer) -> BufferIndex<Normal> {
let index = self.buffers.len() as u32;
self.buffers.push(buffer);
BufferIndex {
index,
_buffer_type: PhantomData,
}
}
pub fn add_uv_buffer(&mut self, buffer: wgpu::Buffer) -> BufferIndex<UV> {
let index = self.buffers.len() as u32;
self.buffers.push(buffer);
BufferIndex {
index,
_buffer_type: PhantomData,
}
}
pub fn add_index_buffer(&mut self, buffer: wgpu::Buffer) -> BufferIndex<Index> {
let index = self.buffers.len() as u32;
self.buffers.push(buffer);
BufferIndex {
index,
_buffer_type: PhantomData,
}
}
pub fn add_model_matrix_buffer(&mut self, buffer: wgpu::Buffer) -> BufferIndex<ModelMatrix> {
let index = self.buffers.len() as u32;
self.buffers.push(buffer);
BufferIndex {
index,
_buffer_type: PhantomData,
}
}
#[inline(always)]
pub fn get_buffer<T>(&self, id: &BufferIndex<T>) -> &wgpu::Buffer {
&self.buffers[id.index as usize]
}
pub fn create_pipeline(
&mut self,
device: &wgpu::Device,
name: &str,
vertex_layout: &[wgpu::VertexBufferLayout],
shader_source: &str,
surface_format: wgpu::TextureFormat,
) -> Result<usize, String> {
if self.pipeline_registry.contains_key(name) {
return Err(format!("Pipeline '{}' already exists", name));
}
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some(name),
source: wgpu::ShaderSource::Wgsl(shader_source.into()),
});
let layout = self.get_or_create_pipeline_layout(device, name);
// Determine entry points based on pipeline name
let (vertex_entry, fragment_entry) = match name {
"triangle_colored" => ("v_main", "f_main"),
_ => ("vs_main", "fs_main"),
};
let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(name),
layout: Some(&layout),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(vertex_entry),
compilation_options: wgpu::PipelineCompilationOptions::default(),
buffers: vertex_layout,
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
strip_index_format: None,
front_face: wgpu::FrontFace::Ccw,
cull_mode: Some(wgpu::Face::Back),
unclipped_depth: false,
polygon_mode: wgpu::PolygonMode::Fill,
conservative: false,
},
depth_stencil: Some(wgpu::DepthStencilState {
format: DEPTH_FORMAT,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fragment_entry),
compilation_options: wgpu::PipelineCompilationOptions::default(),
targets: &[Some(wgpu::ColorTargetState {
format: surface_format,
blend: Some(wgpu::BlendState::REPLACE),
write_mask: wgpu::ColorWrites::ALL,
})],
}),
multiview: None,
cache: None,
});
let index = self.pipelines.len();
self.pipelines.push(pipeline);
self.pipeline_registry.insert(name.to_string(), index);
Ok(index)
}
pub fn get_pipeline(&self, name: &str) -> Option<usize> {
self.pipeline_registry.get(name).copied()
}
pub fn get_or_create_pipeline(
&mut self,
device: &wgpu::Device,
name: &str,
vertex_layout: &[wgpu::VertexBufferLayout],
shader_source: &str,
surface_format: wgpu::TextureFormat,
) -> usize {
if let Some(index) = self.get_pipeline(name) {
return index;
}
self.create_pipeline(device, name, vertex_layout, shader_source, surface_format)
.expect(&format!("Failed to create pipeline '{}'", name))
}
pub fn get_pipeline_by_index(&self, index: usize) -> &wgpu::RenderPipeline {
&self.pipelines[index]
}
pub fn set_bind_group_layouts(&mut self, layouts: &[wgpu::BindGroupLayout; 2]) {
self.bind_group_layouts = layouts.to_vec();
}
fn get_or_create_pipeline_layout(
&mut self,
device: &wgpu::Device,
label: &str,
) -> wgpu::PipelineLayout {
if self.pipeline_layouts.is_empty() {
let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some(label),
bind_group_layouts: &self.bind_group_layouts.iter().collect::<Vec<_>>(),
push_constant_ranges: &[],
});
self.pipeline_layouts.push(layout);
}
self.pipeline_layouts[0].clone()
}
}
impl Default for GpuResources {
fn default() -> Self {
Self::new()
}
}
#[repr(transparent)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct BufferIndex<T> {
pub index: u32,
_buffer_type: PhantomData<T>,
}
impl<T> BufferIndex<T> {
pub fn new(index: u32) -> Self {
Self {
index,
_buffer_type: PhantomData,
}
}
}
// Kinds of buffers supported
pub struct Position;
pub struct Normal;
pub struct UV;
pub struct Index;
pub struct ModelMatrix;
pub struct RendererContext {
pub device: wgpu::Device,
pub queue: wgpu::Queue,
pub surface_config: wgpu::SurfaceConfiguration,
pub surface: wgpu::Surface<'static>,
pub depth_texture: wgpu::Texture,
pub depth_view: wgpu::TextureView,
}
pub struct Renderer<T: scene::Scene> {
canvas: web_sys::OffscreenCanvas,
events_chan: Receiver<WindowEvent>,
context: RendererContext,
resources: GpuResources,
scene: T,
}
impl<T: Scene + 'static> Renderer<T> {
fn create_depth_texture(
device: &wgpu::Device,
config: &wgpu::SurfaceConfiguration,
) -> (wgpu::Texture, wgpu::TextureView) {
let size = wgpu::Extent3d {
width: config.width.max(1),
height: config.height.max(1),
depth_or_array_layers: 1,
};
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("depth texture"),
size,
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: DEPTH_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let view = texture.create_view(&wgpu::TextureViewDescriptor::default());
(texture, view)
}
fn recreate_depth_texture(&mut self) {
let (texture, view) =
Self::create_depth_texture(&self.context.device, &self.context.surface_config);
self.context.depth_texture = texture;
self.context.depth_view = view;
}
pub async fn new(canvas: web_sys::OffscreenCanvas, events_chan: Receiver<WindowEvent>) -> Self {
let id = wgpu::InstanceDescriptor {
backends: wgpu::Backends::BROWSER_WEBGPU,
..Default::default()
};
let instance = wgpu::Instance::new(&id);
let surface = instance
.create_surface(wgpu::SurfaceTarget::OffscreenCanvas(canvas.clone()))
.unwrap();
let adapter = instance
.request_adapter(&wgpu::RequestAdapterOptions {
compatible_surface: Some(&surface),
force_fallback_adapter: false,
..Default::default()
})
.await
.unwrap();
info!("Adapter info: {:?}", adapter.get_info());
info!("Adapter features: {:?}", adapter.features());
info!("Adapter limits: {:?}", adapter.limits());
let descriptor = wgpu::DeviceDescriptor {
required_features: wgpu::Features::empty(),
required_limits: wgpu::Limits::default(),
label: None,
memory_hints: wgpu::MemoryHints::default(),
trace: wgpu::Trace::default(),
};
let (device, queue) = adapter.request_device(&descriptor).await.unwrap();
let surface_caps = surface.get_capabilities(&adapter);
let surface_config = wgpu::SurfaceConfiguration {
usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
format: surface_caps.formats[0],
width: canvas.clone().width(),
height: canvas.clone().height(),
present_mode: surface_caps.present_modes[0],
alpha_mode: surface_caps.alpha_modes[0],
view_formats: vec![],
desired_maximum_frame_latency: 2,
};
info!(
"suface size: {} x {}",
surface_config.width, surface_config.height
);
surface.configure(&device, &surface_config);
let (depth_texture, depth_view) = Self::create_depth_texture(&device, &surface_config);
let mut resources = GpuResources::new();
let context = RendererContext {
surface,
device,
queue,
surface_config,
depth_texture,
depth_view,
};
let scene = T::setup(&context, &mut resources);
Self {
canvas,
events_chan,
context,
scene,
resources,
}
}
fn render(&mut self, time: f32) {
self.scene.update(&self.context, &mut self.resources);
let surface_texture = self.context.surface.get_current_texture().unwrap();
let texture_view = surface_texture.texture.create_view(&Default::default());
let mut encoder =
self.context
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("Render command encoder"),
});
{
let mut render_pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("Render pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
depth_slice: None,
view: &texture_view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: 0.0,
g: 0.0,
b: 0.0,
a: 1.0,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.context.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
occlusion_query_set: None,
timestamp_writes: None,
});
for (i, bind_group) in self.scene.bind_groups().iter().enumerate() {
render_pass.set_bind_group(i as u32, bind_group, &[]);
}
for mesh in self.scene.meshes() {
render_pass.set_pipeline(self.resources.get_pipeline_by_index(mesh.pipeline_index));
render_pass.set_vertex_buffer(
0,
self.resources
.get_buffer(&mesh.position_buffer_index)
.slice(..),
);
render_pass.set_vertex_buffer(
1,
self.resources
.get_buffer(&mesh.normal_buffer_index)
.slice(..),
);
render_pass.set_vertex_buffer(
2,
self.resources.get_buffer(&mesh.uv_buffer_index).slice(..),
);
render_pass.set_vertex_buffer(
3,
self.resources
.get_buffer(&mesh.model_buffer_index)
.slice(..),
);
render_pass.set_index_buffer(
self.resources
.get_buffer(&mesh.index_buffer_index)
.slice(..),
mesh.index_format,
);
render_pass.draw_indexed(0..mesh.index_count, 0, 0..mesh.instance_count);
}
}
self.context.queue.submit(std::iter::once(encoder.finish()));
surface_texture.present();
}
pub async fn read_pixel_from_texture(&self, x: u32, y: u32) -> Vec4 {
let width = self.context.depth_texture.width();
let height = self.context.depth_texture.height();
if width == 0 || height == 0 {
log::warn!("Depth texture has zero extent ({} x {})", width, height);
return Vec4::zero();
}
// Validate coordinates
if x >= width || y >= height {
log::warn!(
"Pixel coordinates ({}, {}) out of bounds for texture size {}x{}",
x,
y,
width,
height
);
return Vec4::zero();
}
let pixel_size = std::mem::size_of::<f32>() as u32;
let unpadded_row_bytes = width * pixel_size;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded_row_bytes = if unpadded_row_bytes % align == 0 {
unpadded_row_bytes
} else {
(unpadded_row_bytes / align + 1) * align
};
let buffer_size = padded_row_bytes as u64 * height as u64;
let buffer = self.context.device.create_buffer(&wgpu::BufferDescriptor {
label: Some("depth pixel read buffer"),
size: buffer_size,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
// Copy just the single pixel
let mut encoder =
self.context
.device
.create_command_encoder(&wgpu::CommandEncoderDescriptor {
label: Some("copy depth pixel to buffer"),
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &self.context.depth_texture,
mip_level: 0,
origin: wgpu::Origin3d { x: 0, y: 0, z: 0 },
aspect: wgpu::TextureAspect::DepthOnly,
},
wgpu::TexelCopyBufferInfo {
buffer: &buffer,
layout: wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(padded_row_bytes),
rows_per_image: Some(height),
},
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
self.context.queue.submit(std::iter::once(encoder.finish()));
// Map the buffer and read the pixel
let slice = buffer.slice(..);
let (tx, rx) = oneshot::channel();
slice.map_async(wgpu::MapMode::Read, move |result| {
tx.send(result).unwrap();
});
// Poll the device to process the mapping
rx.await.unwrap().unwrap();
let depth_value = {
let data = slice.get_mapped_range();
let row_pitch = padded_row_bytes as usize;
let byte_offset = y as usize * row_pitch + x as usize * pixel_size as usize;
let mut depth_bytes = [0u8; 4];
depth_bytes.copy_from_slice(&data[byte_offset..byte_offset + 4]);
f32::from_le_bytes(depth_bytes)
};
buffer.unmap();
Vec4::new(depth_value, 0.0, 0.0, 0.0)
}
pub async fn handle_event(renderer: Rc<RefCell<Self>>, event: WindowEvent) {
match event {
WindowEvent::PointerMove(msg) => {
renderer.borrow_mut().mouse_move(msg);
}
WindowEvent::Resize(msg) => {
renderer.borrow_mut().resize(msg);
}
WindowEvent::PointerClick(msg) => {
{
let mut r = renderer.borrow_mut();
let x = (msg.offset_x * msg.scale_factor) as f32;
let y = (msg.offset_y * msg.scale_factor) as f32;
r.scene.handle_mouse_click(x, y);
log::info!("clicked");
}
// Read pixel from depth texture at click coordinates
// let renderer_clone = renderer.clone();
// let x_coord = msg.offset_x as u32;
// let y_coord = msg.offset_y as u32;
// let pixel_value = renderer_clone
// .borrow()
// .read_pixel_from_texture(x_coord, y_coord)
// .await;
// log::info!(
// "Depth pixel at ({}, {}): {:?}",
// x_coord,
// y_coord,
// pixel_value
// );
}
WindowEvent::PointerWheel(msg) => {
let mut r = renderer.borrow_mut();
r.scene.handle_zoom(msg.delta_y as f32);
}
WindowEvent::Keyboard(msg) => {
log::info!("Key event received: {:?}", msg);
// Check for 'L' key press
if msg.key == "l" || msg.key == "L" {
let renderer_clone = renderer.clone();
spawn_local(async move {
if let Err(e) = Self::show_file_picker_and_load(renderer_clone).await {
log::error!("Failed to load file: {:?}", e);
}
});
}
}
}
}
pub fn run_render_loop(renderer: Rc<RefCell<Renderer<T>>>) {
let render_frame: Closure<dyn FnMut(f32)> = Closure::new(move |time: f32| {
{
if let Ok(r) = renderer.try_borrow_mut() {
let event = r.events_chan.try_recv();
if let Ok(event) = event {
let renderer_clone = renderer.clone();
spawn_local(async move {
Self::handle_event(renderer_clone, event).await;
});
}
}
}
{
if let Ok(mut r) = renderer.try_borrow_mut() {
r.render(time);
}
}
Self::run_render_loop(renderer.clone());
});
let global = js_sys::global().unchecked_into::<DedicatedWorkerGlobalScope>();
global
.request_animation_frame(render_frame.as_ref().unchecked_ref())
.unwrap();
render_frame.forget();
}
fn resize(&mut self, msg: ResizeMessage) {
let new_width = (msg.width * msg.scale_factor) as u32;
let new_height = (msg.height * msg.scale_factor) as u32;
if new_width != self.canvas.width() || new_height != self.canvas.height() {
self.context.surface_config.width = new_width;
self.context.surface_config.height = new_height;
self.context
.surface
.configure(&self.context.device, &self.context.surface_config);
self.recreate_depth_texture();
self.scene.resize(
new_width as f64,
new_height as f64,
msg.scale_factor,
&self.context.queue,
);
info!(
"Resized: ({}, {}), scale: {}",
new_width, new_height, msg.scale_factor
);
}
}
pub fn mouse_move(&mut self, msg: MouseMessage) {
if (msg.buttons & 0x04) != 0 {
let delta_x = (msg.movement_x * msg.scale_factor) as f32;
let delta_y = (msg.movement_y * msg.scale_factor) as f32;
self.scene.handle_orbit(delta_x, delta_y);
}
}
// currently this replaces everything, will need more sophisticated mechanisms later
pub async fn load_assets_async(renderer: Rc<RefCell<Renderer<T>>>) -> Result<(), ImportError> {
let (device, surface_format) = {
let r = renderer.borrow();
(
r.context.device.clone(),
r.context.surface_config.format,
)
};
let mut meshes = Vec::new();
let mut original_resources = {
let mut r = renderer.borrow_mut();
r.scene.clear();
std::mem::take(&mut r.resources)
};
let bounds = load_gltf_model(
&device,
&mut original_resources,
&mut meshes,
surface_format,
)
.await?;
{
let mut r = renderer.borrow_mut();
r.resources = original_resources;
for mesh in meshes {
r.scene.add_mesh(mesh);
}
if let Some(ModelBounds { min, max }) = bounds {
let center = ultraviolet::Vec3::new(
(min[0] + max[0]) * 0.5,
(min[1] + max[1]) * 0.5,
(min[2] + max[2]) * 0.5,
);
let extent =
ultraviolet::Vec3::new(max[0] - min[0], max[1] - min[1], max[2] - min[2]);
let radius =
0.5 * (extent.x * extent.x + extent.y * extent.y + extent.z * extent.z).sqrt();
let radius = radius.max(1.0);
// set the camera position after load, so we are not disoriented
let eye_offset = ultraviolet::Vec3::new(0.0, radius * 0.05, radius * 0.25);
// Keep the near plane proportional to the model size to avoid
// extreme depth ranges when loading very large assets
let near_plane = (radius * 0.001).max(0.1);
// The far plane must be far enough to cover the entire model.
// Using a fixed upper clamp caused large models to be clipped
// completely; relying on the model radius instead.
let far_plane = (radius * 4.0).max(near_plane + 1.0);
r.scene.set_camera_depth_range(near_plane, far_plane);
r.scene.set_camera_look_at(center + eye_offset, center);
}
}
Ok(())
}
async fn show_file_picker_and_load(renderer: Rc<RefCell<Renderer<T>>>) -> Result<(), ImportError> {
// For now, we'll just call load_assets_async which loads the default model
// In a full implementation, we'd modify load_gltf_model to accept the file data
Self::load_assets_async(renderer).await
}
}
impl<T: scene::Scene> From<BufferIndex<T>> for u32 {
fn from(value: BufferIndex<T>) -> Self {
value.index
}
}
+388
View File
@@ -0,0 +1,388 @@
use ultraviolet::Mat4;
use wgpu::util::DeviceExt;
use crate::{
camera::Camera,
renderer::{self, BufferIndex, GpuResources, Index, ModelMatrix, Normal, Position, UV},
};
pub struct UniformResource {
pub buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
pub bind_group_layout: wgpu::BindGroupLayout,
}
/// Simple uniform data.
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable, Debug, Default)]
pub struct FrameMetadata {
pub mouse_move: [f32; 2],
pub mouse_click: [f32; 2],
pub resolution: [f32; 2],
time: f32,
_padding0: f32,
pub camera_position: [f32; 4],
}
impl FrameMetadata {
pub fn new(dimension: ultraviolet::Vec2) -> Self {
FrameMetadata {
resolution: dimension.into(),
mouse_move: [std::f32::MIN, std::f32::MIN],
mouse_click: [std::f32::MIN, std::f32::MIN],
_padding0: 0.0,
camera_position: [0.0, 0.0, 0.0, 1.0],
..Default::default()
}
}
pub fn set_camera_position(&mut self, position: ultraviolet::Vec3) {
self.camera_position = [position.x, position.y, position.z, 1.0];
}
pub fn update_dimension(&mut self, dimension: ultraviolet::Vec2) {
self.resolution = dimension.into();
}
pub fn create_uniform_resource(self, device: &wgpu::Device) -> UniformResource {
let buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("frame metadata uniform buffer"),
contents: bytemuck::cast_slice(&[self][..]),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("Uniform bind group layout"),
entries: &[wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
}],
});
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("Uniform bind group"),
layout: &bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: buffer.as_entire_binding(),
}],
});
UniformResource {
buffer,
bind_group_layout,
bind_group,
}
}
}
pub struct Mesh {
pub pipeline_index: usize,
pub position_buffer_index: BufferIndex<Position>,
pub normal_buffer_index: BufferIndex<Normal>,
pub uv_buffer_index: BufferIndex<UV>,
pub model_buffer_index: BufferIndex<ModelMatrix>,
pub index_buffer_index: BufferIndex<Index>,
pub index_format: wgpu::IndexFormat,
pub index_count: u32,
pub instance_count: u32,
}
type VertexBufferSet = (BufferIndex<Position>, BufferIndex<Normal>, BufferIndex<UV>);
type IndexBufferInfo = (BufferIndex<Index>, u32, wgpu::IndexFormat);
pub fn mesh_vertex_layout() -> [wgpu::VertexBufferLayout<'static>; 4] {
[
wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
}],
},
wgpu::VertexBufferLayout {
array_stride: 12,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
offset: 0,
shader_location: 1,
format: wgpu::VertexFormat::Float32x3,
}],
},
wgpu::VertexBufferLayout {
array_stride: 8,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[wgpu::VertexAttribute {
offset: 0,
shader_location: 2,
format: wgpu::VertexFormat::Float32x2,
}],
},
wgpu::VertexBufferLayout {
array_stride: 64,
step_mode: wgpu::VertexStepMode::Instance,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 3,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: 16,
shader_location: 4,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: 32,
shader_location: 5,
format: wgpu::VertexFormat::Float32x4,
},
wgpu::VertexAttribute {
offset: 48,
shader_location: 6,
format: wgpu::VertexFormat::Float32x4,
},
],
},
]
}
pub struct MeshBuilder<I, V, P, M> {
indices: I,
vertices: V,
pipeline: P,
model_matrix: M,
instance_count: u32,
}
impl Default for MeshBuilder<(), (), (), ()> {
fn default() -> Self {
Self {
indices: (),
vertices: (),
pipeline: (),
model_matrix: (),
instance_count: 1,
}
}
}
impl<P, M> MeshBuilder<(), (), P, M> {
pub fn with_vertices(
self,
device: &wgpu::Device,
resources: &mut GpuResources,
positions: &[[f32; 3]],
normals: &[[f32; 3]],
uvs: &[[f32; 2]],
) -> MeshBuilder<(), VertexBufferSet, P, M> {
let position_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Positions"),
contents: bytemuck::cast_slice(positions),
usage: wgpu::BufferUsages::VERTEX,
});
let normal_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Normals"),
contents: bytemuck::cast_slice(normals),
usage: wgpu::BufferUsages::VERTEX,
});
let uv_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh UVs"),
contents: bytemuck::cast_slice(uvs),
usage: wgpu::BufferUsages::VERTEX,
});
let position_buffer_index = resources.add_position_buffer(position_buffer);
let normal_buffer_index = resources.add_normal_buffer(normal_buffer);
let uv_buffer_index = resources.add_uv_buffer(uv_buffer);
MeshBuilder {
vertices: (position_buffer_index, normal_buffer_index, uv_buffer_index),
indices: self.indices,
pipeline: self.pipeline,
model_matrix: self.model_matrix,
instance_count: self.instance_count,
}
}
}
impl<V, P, M> MeshBuilder<(), V, P, M> {
pub fn with_indices(
self,
device: &wgpu::Device,
resources: &mut GpuResources,
indices: &[u32],
) -> MeshBuilder<IndexBufferInfo, V, P, M> {
let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Indices"),
contents: bytemuck::cast_slice(indices),
usage: wgpu::BufferUsages::INDEX,
});
let index_buffer_index = resources.add_index_buffer(index_buffer);
MeshBuilder {
indices: (
index_buffer_index,
indices.len() as u32,
wgpu::IndexFormat::Uint32,
),
vertices: self.vertices,
pipeline: self.pipeline,
model_matrix: self.model_matrix,
instance_count: self.instance_count,
}
}
}
impl<I, V, M> MeshBuilder<I, V, (), M> {
pub fn with_pipeline(self, pipeline_index: usize) -> MeshBuilder<I, V, usize, M> {
MeshBuilder {
pipeline: pipeline_index,
indices: self.indices,
vertices: self.vertices,
model_matrix: self.model_matrix,
instance_count: self.instance_count,
}
}
}
impl<I, V, P> MeshBuilder<I, V, P, ()> {
pub fn with_model_matrix(
self,
device: &wgpu::Device,
resources: &mut GpuResources,
matrix_columns: Mat4,
) -> MeshBuilder<I, V, P, BufferIndex<ModelMatrix>> {
let model_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("Mesh Model Matrix"),
contents: bytemuck::cast_slice(matrix_columns.as_slice()),
usage: wgpu::BufferUsages::VERTEX,
});
let model_buffer_index = resources.add_model_matrix_buffer(model_buffer);
MeshBuilder {
indices: self.indices,
vertices: self.vertices,
pipeline: self.pipeline,
model_matrix: model_buffer_index,
instance_count: self.instance_count,
}
}
}
impl MeshBuilder<IndexBufferInfo, VertexBufferSet, usize, BufferIndex<ModelMatrix>> {
pub fn build(self) -> Mesh {
Mesh {
pipeline_index: self.pipeline,
position_buffer_index: (self.vertices).0,
normal_buffer_index: (self.vertices).1,
uv_buffer_index: (self.vertices).2,
model_buffer_index: self.model_matrix,
index_buffer_index: (self.indices).0,
index_count: (self.indices).1,
index_format: (self.indices).2,
instance_count: self.instance_count,
}
}
}
pub trait Scene: Sized {
fn setup(renderer_context: &renderer::RendererContext, resources: &mut GpuResources) -> Self;
fn bind_groups(&self) -> &[wgpu::BindGroup];
fn meshes(&self) -> &[Mesh];
fn handle_mouse_click(&mut self, x: f32, y: f32);
fn handle_zoom(&mut self, delta_y: f32);
fn handle_orbit(&mut self, delta_x: f32, delta_y: f32);
fn clear(&mut self);
fn add_mesh(&mut self, mesh: Mesh);
fn set_camera_depth_range(&mut self, near: f32, far: f32);
fn set_camera_look_at(&mut self, eye: ultraviolet::Vec3, center: ultraviolet::Vec3);
fn frame_metadata_mut(&mut self) -> Option<&mut FrameMetadata> {
None
}
fn camera_mut(&mut self) -> Option<&mut Camera> {
None
}
fn uniform_buffers(&self) -> Option<&[wgpu::Buffer]> {
None
}
fn resize(&mut self, width: f64, height: f64, _scale_factor: f64, queue: &wgpu::Queue) {
let fm_copy = if let Some(fm) = self.frame_metadata_mut() {
let dimension = ultraviolet::Vec2::new(width as f32, height as f32);
fm.update_dimension(dimension);
*fm
} else {
return;
};
let view_proj_copy = if let Some(cam) = self.camera_mut() {
cam.update_aspect_ratio(width as f32 / height as f32);
cam.view_proj
} else {
return;
};
if let Some(buffers) = self.uniform_buffers() {
if buffers.len() >= 2 {
queue.write_buffer(&buffers[0], 0, bytemuck::cast_slice(&[fm_copy]));
queue.write_buffer(&buffers[1], 0, bytemuck::cast_slice(&[view_proj_copy]));
}
}
}
fn update(
&mut self,
renderer_context: &renderer::RendererContext,
_resources: &mut GpuResources,
) {
let camera_position = if let Some(cam) = self.camera_mut() {
cam.position()
} else {
return;
};
let fm_copy = if let Some(fm) = self.frame_metadata_mut() {
let time = (js_sys::Date::now() as f32) * 0.001;
fm.time = time;
fm.set_camera_position(camera_position);
*fm
} else {
return;
};
let view_proj_copy = if let Some(cam) = self.camera_mut() {
cam.view_proj
} else {
return;
};
if let Some(buffers) = self.uniform_buffers() {
if buffers.len() >= 2 {
renderer_context.queue.write_buffer(
&buffers[0],
0,
bytemuck::cast_slice(&[fm_copy]),
);
renderer_context.queue.write_buffer(
&buffers[1],
0,
bytemuck::cast_slice(&[view_proj_copy]),
);
}
}
}
}