diff --git a/.gitignore b/.gitignore index a547bf3..4587401 100644 --- a/.gitignore +++ b/.gitignore @@ -13,7 +13,7 @@ dist-ssr *.local # Editor directories and files -.vscode/* +# .vscode/* !.vscode/extensions.json .idea .DS_Store diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..2e62bd7 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "rust-analyzer.cargo.target": "wasm32-unknown-unknown" +} diff --git a/app/package-lock.json b/app/package-lock.json index f55ef63..15fdce1 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -7,6 +7,7 @@ "": { "name": "test-lit", "version": "0.0.0", + "license": "MIT", "dependencies": { "lit": "^2.6.1" }, diff --git a/cubeway/Cargo.toml b/cubeway/Cargo.toml index 3ad914e..cfd81e6 100644 --- a/cubeway/Cargo.toml +++ b/cubeway/Cargo.toml @@ -16,12 +16,15 @@ path = "src/main.rs" [dependencies] wasm-bindgen = "0.2" winit = "0.28" +cgmath = "0.18" env_logger = "0.10" log = "0.4" wgpu = "0.16" cfg-if = "1" pollster = "0.3" bytemuck = { version = "1.12", features = [ "derive" ] } +image = "0.24" +anyhow = "1.0" [target.'cfg(target_arch = "wasm32")'.dependencies] console_error_panic_hook = "0.1.6" @@ -35,6 +38,7 @@ web-sys = { version = "0.3", features = [ "Element", ]} +[build-dependencies] [profile.release] lto = true diff --git a/cubeway/src/happy-tree.png b/cubeway/src/happy-tree.png new file mode 100644 index 0000000..fc86db3 Binary files /dev/null and b/cubeway/src/happy-tree.png differ diff --git a/cubeway/src/lib.rs b/cubeway/src/lib.rs index 4c590e4..d24c0ed 100644 --- a/cubeway/src/lib.rs +++ b/cubeway/src/lib.rs @@ -1,5 +1,6 @@ use std::iter; +use cgmath::prelude::*; use wgpu::util::DeviceExt; use winit::{ event::*, @@ -7,60 +8,293 @@ use winit::{ window::{Window, WindowBuilder}, }; -mod greet; - #[cfg(target_arch = "wasm32")] use wasm_bindgen::prelude::*; -struct State { - surface: wgpu::Surface, - device: wgpu::Device, - queue: wgpu::Queue, - config: wgpu::SurfaceConfiguration, - size: winit::dpi::PhysicalSize, - render_pipeline: wgpu::RenderPipeline, - vertex_buffer: wgpu::Buffer, - num_vertices: u32, - window: Window, -} +mod greet; +mod texture; + +const NUM_INSTANCES_PER_ROW: u32 = 10; +const INSTANCE_DISPLACEMENT: cgmath::Vector3 = cgmath::Vector3::new( + NUM_INSTANCES_PER_ROW as f32 * 0.5, + 0.0, + NUM_INSTANCES_PER_ROW as f32 * 0.5, +); #[repr(C)] #[derive(Copy, Clone, Debug, bytemuck::Pod, bytemuck::Zeroable)] struct Vertex { position: [f32; 3], - color: [f32; 3], + tex_coords: [f32; 2], } impl Vertex { - const ATTRIBS: [wgpu::VertexAttribute; 2] = - wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3]; - fn desc<'a>() -> wgpu::VertexBufferLayout<'a> { use std::mem; - wgpu::VertexBufferLayout { - array_stride: mem::size_of::() as wgpu::BufferAddress, + array_stride: mem::size_of::() as wgpu::BufferAddress, step_mode: wgpu::VertexStepMode::Vertex, - attributes: &Self::ATTRIBS, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }, + wgpu::VertexAttribute { + offset: mem::size_of::<[f32; 3]>() as wgpu::BufferAddress, + shader_location: 1, + format: wgpu::VertexFormat::Float32x2, + }, + ], } } } const VERTICES: &[Vertex] = &[ Vertex { - position: [0.0, 0.5, 0.0], - color: [1.0, 0.0, 0.0], - }, + position: [-0.0868241, 0.49240386, 0.0], + tex_coords: [0.4131759, 0.00759614], + }, // A + Vertex { + position: [-0.49513406, 0.06958647, 0.0], + tex_coords: [0.0048659444, 0.43041354], + }, // B + Vertex { + position: [-0.21918549, -0.44939706, 0.0], + tex_coords: [0.28081453, 0.949397], + }, // C Vertex { - position: [-0.5, -0.5, 0.0], - color: [0.0, 1.0, 0.0], - }, + position: [0.35966998, -0.3473291, 0.0], + tex_coords: [0.85967, 0.84732914], + }, // D Vertex { - position: [0.5, -0.5, 0.0], - color: [0.0, 0.0, 1.0], - }, + position: [0.44147372, 0.2347359, 0.0], + tex_coords: [0.9414737, 0.2652641], + }, // E ]; +const INDICES: &[u16] = &[0, 1, 4, 1, 2, 4, 2, 3, 4, /* padding */ 0]; + +#[rustfmt::skip] +pub const OPENGL_TO_WGPU_MATRIX: cgmath::Matrix4 = cgmath::Matrix4::new( + 1.0, 0.0, 0.0, 0.0, + 0.0, 1.0, 0.0, 0.0, + 0.0, 0.0, 0.5, 0.0, + 0.0, 0.0, 0.5, 1.0, +); + +struct Camera { + eye: cgmath::Point3, + target: cgmath::Point3, + up: cgmath::Vector3, + aspect: f32, + fovy: f32, + znear: f32, + zfar: f32, +} + +impl Camera { + fn build_view_projection_matrix(&self) -> cgmath::Matrix4 { + let view = cgmath::Matrix4::look_at_rh(self.eye, self.target, self.up); + let proj = cgmath::perspective(cgmath::Deg(self.fovy), self.aspect, self.znear, self.zfar); + proj * view + } +} + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct CameraUniform { + view_proj: [[f32; 4]; 4], +} + +impl CameraUniform { + fn new() -> Self { + Self { + view_proj: cgmath::Matrix4::identity().into(), + } + } + + fn update_view_proj(&mut self, camera: &Camera) { + self.view_proj = (OPENGL_TO_WGPU_MATRIX * camera.build_view_projection_matrix()).into(); + } +} + +struct CameraController { + speed: f32, + is_up_pressed: bool, + is_down_pressed: bool, + is_forward_pressed: bool, + is_backward_pressed: bool, + is_left_pressed: bool, + is_right_pressed: bool, +} + +impl CameraController { + fn new(speed: f32) -> Self { + Self { + speed, + is_up_pressed: false, + is_down_pressed: false, + is_forward_pressed: false, + is_backward_pressed: false, + is_left_pressed: false, + is_right_pressed: false, + } + } + + fn process_events(&mut self, event: &WindowEvent) -> bool { + match event { + WindowEvent::KeyboardInput { + input: + KeyboardInput { + state, + virtual_keycode: Some(keycode), + .. + }, + .. + } => { + let is_pressed = *state == ElementState::Pressed; + match keycode { + VirtualKeyCode::Space => { + self.is_up_pressed = is_pressed; + true + } + VirtualKeyCode::LShift => { + self.is_down_pressed = is_pressed; + true + } + VirtualKeyCode::W | VirtualKeyCode::Up => { + self.is_forward_pressed = is_pressed; + true + } + VirtualKeyCode::A | VirtualKeyCode::Left => { + self.is_left_pressed = is_pressed; + true + } + VirtualKeyCode::S | VirtualKeyCode::Down => { + self.is_backward_pressed = is_pressed; + true + } + VirtualKeyCode::D | VirtualKeyCode::Right => { + self.is_right_pressed = is_pressed; + true + } + _ => false, + } + } + _ => false, + } + } + + fn update_camera(&self, camera: &mut Camera) { + let forward = (camera.target - camera.eye).normalize(); + + if self.is_forward_pressed { + camera.eye += forward * self.speed; + } + if self.is_backward_pressed { + camera.eye -= forward * self.speed; + } + + let right = forward.cross(camera.up); + + if self.is_right_pressed { + camera.eye += right * self.speed; + } + if self.is_left_pressed { + camera.eye -= right * self.speed; + } + } +} + +// NEW! +struct Instance { + position: cgmath::Vector3, + rotation: cgmath::Quaternion, +} + +// NEW! +impl Instance { + fn to_raw(&self) -> InstanceRaw { + InstanceRaw { + model: (cgmath::Matrix4::from_translation(self.position) + * cgmath::Matrix4::from(self.rotation)) + .into(), + } + } +} + +// NEW! +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct InstanceRaw { + model: [[f32; 4]; 4], +} + +impl InstanceRaw { + fn desc<'a>() -> wgpu::VertexBufferLayout<'a> { + use std::mem; + wgpu::VertexBufferLayout { + array_stride: mem::size_of::() as wgpu::BufferAddress, + // We need to switch from using a step mode of Vertex to Instance + // This means that our shaders will only change to use the next + // instance when the shader starts processing a new instance + step_mode: wgpu::VertexStepMode::Instance, + attributes: &[ + wgpu::VertexAttribute { + offset: 0, + // While our vertex shader only uses locations 0, and 1 now, in later tutorials we'll + // be using 2, 3, and 4, for Vertex. We'll start at slot 5 not conflict with them later + shader_location: 5, + format: wgpu::VertexFormat::Float32x4, + }, + // A mat4 takes up 4 vertex slots as it is technically 4 vec4s. We need to define a slot + // for each vec4. We don't have to do this in code though. + wgpu::VertexAttribute { + offset: mem::size_of::<[f32; 4]>() as wgpu::BufferAddress, + shader_location: 6, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: mem::size_of::<[f32; 8]>() as wgpu::BufferAddress, + shader_location: 7, + format: wgpu::VertexFormat::Float32x4, + }, + wgpu::VertexAttribute { + offset: mem::size_of::<[f32; 12]>() as wgpu::BufferAddress, + shader_location: 8, + format: wgpu::VertexFormat::Float32x4, + }, + ], + } + } +} + +struct State { + surface: wgpu::Surface, + device: wgpu::Device, + queue: wgpu::Queue, + config: wgpu::SurfaceConfiguration, + size: winit::dpi::PhysicalSize, + render_pipeline: wgpu::RenderPipeline, + vertex_buffer: wgpu::Buffer, + index_buffer: wgpu::Buffer, + num_indices: u32, + #[allow(dead_code)] + diffuse_texture: texture::Texture, + diffuse_bind_group: wgpu::BindGroup, + camera: Camera, + camera_controller: CameraController, + camera_uniform: CameraUniform, + camera_buffer: wgpu::Buffer, + camera_bind_group: wgpu::BindGroup, + // NEW! + instances: Vec, + #[allow(dead_code)] + instance_buffer: wgpu::Buffer, + window: Window, +} + impl State { async fn new(window: Window) -> Self { let size = window.inner_size(); @@ -86,7 +320,6 @@ impl State { }) .await .unwrap(); - let (device, queue) = adapter .request_device( &wgpu::DeviceDescriptor { @@ -126,6 +359,124 @@ impl State { }; surface.configure(&device, &config); + let diffuse_bytes = include_bytes!("happy-tree.png"); + let diffuse_texture = + texture::Texture::from_bytes(&device, &queue, diffuse_bytes, "happy-tree.png").unwrap(); + + let texture_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + multisampled: false, + view_dimension: wgpu::TextureViewDimension::D2, + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + label: Some("texture_bind_group_layout"), + }); + + let diffuse_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + layout: &texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&diffuse_texture.view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&diffuse_texture.sampler), + }, + ], + label: Some("diffuse_bind_group"), + }); + + let camera = Camera { + eye: (0.0, 5.0, 10.0).into(), + target: (0.0, 0.0, 0.0).into(), + up: cgmath::Vector3::unit_y(), + aspect: config.width as f32 / config.height as f32, + fovy: 45.0, + znear: 0.1, + zfar: 100.0, + }; + let camera_controller = CameraController::new(0.2); + + let mut camera_uniform = CameraUniform::new(); + camera_uniform.update_view_proj(&camera); + + let camera_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Camera Buffer"), + contents: bytemuck::cast_slice(&[camera_uniform]), + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + }); + + let instances = (0..NUM_INSTANCES_PER_ROW) + .flat_map(|z| { + (0..NUM_INSTANCES_PER_ROW).map(move |x| { + let position = cgmath::Vector3 { + x: x as f32, + y: 0.0, + z: z as f32, + } - INSTANCE_DISPLACEMENT; + + let rotation = if position.is_zero() { + // this is needed so an object at (0, 0, 0) won't get scaled to zero + // as Quaternions can effect scale if they're not created correctly + cgmath::Quaternion::from_axis_angle( + cgmath::Vector3::unit_z(), + cgmath::Deg(0.0), + ) + } else { + cgmath::Quaternion::from_axis_angle(position.normalize(), cgmath::Deg(45.0)) + }; + + Instance { position, rotation } + }) + }) + .collect::>(); + + let instance_data = instances.iter().map(Instance::to_raw).collect::>(); + let instance_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Instance Buffer"), + contents: bytemuck::cast_slice(&instance_data), + usage: wgpu::BufferUsages::VERTEX, + }); + + let camera_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + entries: &[wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }], + label: Some("camera_bind_group_layout"), + }); + + let camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + layout: &camera_bind_group_layout, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: camera_buffer.as_entire_binding(), + }], + label: Some("camera_bind_group"), + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("Shader"), source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()), @@ -134,7 +485,7 @@ impl State { let render_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("Render Pipeline Layout"), - bind_group_layouts: &[], + bind_group_layouts: &[&texture_bind_group_layout, &camera_bind_group_layout], push_constant_ranges: &[], }); @@ -144,7 +495,7 @@ impl State { vertex: wgpu::VertexState { module: &shader, entry_point: "vs_main", - buffers: &[Vertex::desc()], + buffers: &[Vertex::desc(), InstanceRaw::desc()], }, fragment: Some(wgpu::FragmentState { module: &shader, @@ -187,19 +538,34 @@ impl State { contents: bytemuck::cast_slice(VERTICES), usage: wgpu::BufferUsages::VERTEX, }); - - let num_vertices = VERTICES.len() as u32; + let index_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("Index Buffer"), + contents: bytemuck::cast_slice(INDICES), + usage: wgpu::BufferUsages::INDEX, + }); + let num_indices = INDICES.len() as u32; Self { surface, device, queue, - size, config, + size, render_pipeline, vertex_buffer, - num_vertices, + index_buffer, + num_indices, + diffuse_texture, + diffuse_bind_group, + camera, + camera_controller, + camera_buffer, + camera_bind_group, + camera_uniform, window, + // NEW! + instances, + instance_buffer, } } @@ -207,21 +573,30 @@ impl State { &self.window } - pub fn resize(&mut self, new_size: winit::dpi::PhysicalSize) { + fn resize(&mut self, new_size: winit::dpi::PhysicalSize) { if new_size.width > 0 && new_size.height > 0 { self.size = new_size; self.config.width = new_size.width; self.config.height = new_size.height; self.surface.configure(&self.device, &self.config); + + self.camera.aspect = self.config.width as f32 / self.config.height as f32; } } - #[allow(unused_variables)] fn input(&mut self, event: &WindowEvent) -> bool { - false + self.camera_controller.process_events(event) } - fn update(&mut self) {} + fn update(&mut self) { + self.camera_controller.update_camera(&mut self.camera); + self.camera_uniform.update_view_proj(&self.camera); + self.queue.write_buffer( + &self.camera_buffer, + 0, + bytemuck::cast_slice(&[self.camera_uniform]), + ); + } fn render(&mut self) -> Result<(), wgpu::SurfaceError> { let output = self.surface.get_current_texture()?; @@ -255,8 +630,13 @@ impl State { }); render_pass.set_pipeline(&self.render_pipeline); + render_pass.set_bind_group(0, &self.diffuse_bind_group, &[]); + render_pass.set_bind_group(1, &self.camera_bind_group, &[]); render_pass.set_vertex_buffer(0, self.vertex_buffer.slice(..)); - render_pass.draw(0..self.num_vertices, 0..1); + render_pass.set_vertex_buffer(1, self.instance_buffer.slice(..)); + render_pass.set_index_buffer(self.index_buffer.slice(..), wgpu::IndexFormat::Uint16); + // UPDATED! + render_pass.draw_indexed(0..self.num_indices, 0, 0..self.instances.len() as _); } self.queue.submit(iter::once(encoder.finish())); @@ -291,7 +671,10 @@ pub async fn run() { web_sys::window() .and_then(|win| win.document()) .and_then(|doc| { - let dst = doc.query_selector("cubeway-simulation").unwrap()?; + let dst = doc + .query_selector("cubeway-simulation") + .expect("Couldn't find cubeway-simluation web component in document") + .expect("Couldn't unwrap query selector result"); let canvas = web_sys::Element::from(window.canvas()); dst.append_child(&canvas).ok()?; Some(()) diff --git a/cubeway/src/shader.wgsl b/cubeway/src/shader.wgsl index cc394ed..c182e66 100644 --- a/cubeway/src/shader.wgsl +++ b/cubeway/src/shader.wgsl @@ -1,28 +1,52 @@ // Vertex shader +struct Camera { + view_proj: mat4x4, +} +@group(1) @binding(0) +var camera: Camera; + struct VertexInput { @location(0) position: vec3, - @location(1) color: vec3, -}; + @location(1) tex_coords: vec2, +} +struct InstanceInput { + @location(5) model_matrix_0: vec4, + @location(6) model_matrix_1: vec4, + @location(7) model_matrix_2: vec4, + @location(8) model_matrix_3: vec4, +} struct VertexOutput { @builtin(position) clip_position: vec4, - @location(0) color: vec3, -}; + @location(0) tex_coords: vec2, +} @vertex fn vs_main( model: VertexInput, + instance: InstanceInput, ) -> VertexOutput { + let model_matrix = mat4x4( + instance.model_matrix_0, + instance.model_matrix_1, + instance.model_matrix_2, + instance.model_matrix_3, + ); var out: VertexOutput; - out.color = model.color; - out.clip_position = vec4(model.position, 1.0); + out.tex_coords = model.tex_coords; + out.clip_position = camera.view_proj * model_matrix * vec4(model.position, 1.0); return out; } // Fragment shader +@group(0) @binding(0) +var t_diffuse: texture_2d; +@group(0)@binding(1) +var s_diffuse: sampler; + @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4 { - return vec4(in.color, 1.0); -} + return textureSample(t_diffuse, s_diffuse, in.tex_coords); +} \ No newline at end of file diff --git a/cubeway/src/state.rs b/cubeway/src/state.rs index e69de29..8b13789 100644 --- a/cubeway/src/state.rs +++ b/cubeway/src/state.rs @@ -0,0 +1 @@ + diff --git a/cubeway/src/texture.rs b/cubeway/src/texture.rs new file mode 100644 index 0000000..ff38f20 --- /dev/null +++ b/cubeway/src/texture.rs @@ -0,0 +1,80 @@ +use anyhow::*; +use image::GenericImageView; + +pub struct Texture { + pub texture: wgpu::Texture, + pub view: wgpu::TextureView, + pub sampler: wgpu::Sampler, +} + +impl Texture { + pub fn from_bytes( + device: &wgpu::Device, + queue: &wgpu::Queue, + bytes: &[u8], + label: &str, + ) -> Result { + let img = image::load_from_memory(bytes)?; + Self::from_image(device, queue, &img, Some(label)) + } + + pub fn from_image( + device: &wgpu::Device, + queue: &wgpu::Queue, + img: &image::DynamicImage, + label: Option<&str>, + ) -> Result { + let rgba = img.to_rgba8(); + let dimensions = img.dimensions(); + + let size = wgpu::Extent3d { + width: dimensions.0, + height: dimensions.1, + depth_or_array_layers: 1, + }; + let format = wgpu::TextureFormat::Rgba8UnormSrgb; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label, + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + queue.write_texture( + wgpu::ImageCopyTexture { + aspect: wgpu::TextureAspect::All, + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + }, + &rgba, + wgpu::ImageDataLayout { + offset: 0, + bytes_per_row: Some(4 * dimensions.0), + rows_per_image: Some(dimensions.1), + }, + size, + ); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + 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::Nearest, + mipmap_filter: wgpu::FilterMode::Nearest, + ..Default::default() + }); + + Ok(Self { + texture, + view, + sampler, + }) + } +} diff --git a/package-lock.json b/package-lock.json index d41a266..4737a40 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,13 +1,13 @@ { - "name": "3d-conway", + "name": "web-cubeway", "version": "1.0.0", "lockfileVersion": 2, "requires": true, "packages": { "": { - "name": "3d-conway", + "name": "web-cubeway", "version": "1.0.0", - "license": "ISC", + "license": "MIT", "devDependencies": { "concurrently": "^8.0.1" } diff --git a/package.json b/package.json index 88c5e30..f0543e4 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "version": "1.0.0", "scripts": { "dev": "concurrently \"cd cubeway && cargo watch -- wasm-pack build --target web\" \"cd app && npm run dev\"", - "build": "concurrently \"cd cubeway && wasm-pack build --target web\" \"cd app && npm run build\"" + "build": "concurrently \"cd cubeway && wasm-pack build --target web\" \"cd app && npm i && npm run build\"" }, "keywords": [], "author": "",