diff --git a/Cargo.lock b/Cargo.lock index 21192f1e..835c284e 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1591,6 +1591,7 @@ dependencies = [ name = "neothesia" version = "0.4.0" dependencies = [ + "bytemuck", "bytes", "cosmic-text", "cpal", diff --git a/assets/sheet-music/notation-atlas.png b/assets/sheet-music/notation-atlas.png new file mode 100644 index 00000000..970de305 Binary files /dev/null and b/assets/sheet-music/notation-atlas.png differ diff --git a/neothesia-core/src/config/mod.rs b/neothesia-core/src/config/mod.rs index 449c6743..c41b34cb 100644 --- a/neothesia-core/src/config/mod.rs +++ b/neothesia-core/src/config/mod.rs @@ -235,6 +235,22 @@ impl Config { self.waterfall.note_labels } + pub fn sheet_music(&self) -> bool { + self.waterfall.sheet_music + } + + pub fn set_sheet_music(&mut self, show: bool) { + self.waterfall.sheet_music = show; + } + + pub fn sheet_music_height(&self) -> f32 { + self.waterfall.sheet_music_height + } + + pub fn set_sheet_music_height(&mut self, height: f32) { + self.waterfall.sheet_music_height = height.clamp(140.0, 600.0); + } + pub fn speed_multiplier(&self) -> f32 { self.playback.speed_multiplier } diff --git a/neothesia-core/src/config/model.rs b/neothesia-core/src/config/model.rs index 5726618d..cfc4e340 100644 --- a/neothesia-core/src/config/model.rs +++ b/neothesia-core/src/config/model.rs @@ -31,6 +31,12 @@ pub struct WaterfallConfigV1 { #[serde(default = "default_note_labels")] pub note_labels: bool, + + #[serde(default = "default_sheet_music")] + pub sheet_music: bool, + + #[serde(default = "default_sheet_music_height")] + pub sheet_music_height: f32, } #[derive(Serialize, Deserialize)] @@ -44,6 +50,8 @@ impl Default for WaterfallConfig { animation_speed: default_animation_speed(), animation_offset: default_animation_offset(), note_labels: default_note_labels(), + sheet_music: default_sheet_music(), + sheet_music_height: default_sheet_music_height(), }) } } @@ -211,6 +219,14 @@ fn default_note_labels() -> bool { false } +fn default_sheet_music() -> bool { + true +} + +fn default_sheet_music_height() -> f32 { + 260.0 +} + fn default_audio_gain() -> f32 { 0.2 } diff --git a/neothesia/Cargo.toml b/neothesia/Cargo.toml index 6b089da1..e462f0b3 100644 --- a/neothesia/Cargo.toml +++ b/neothesia/Cargo.toml @@ -28,6 +28,7 @@ futures-channel.workspace = true cosmic-text.workspace = true bytes.workspace = true +bytemuck.workspace = true lilt.workspace = true winit.workspace = true rfd.workspace = true diff --git a/neothesia/src/scene/menu_scene/settings.rs b/neothesia/src/scene/menu_scene/settings.rs index 8cdb67e7..58f885db 100644 --- a/neothesia/src/scene/menu_scene/settings.rs +++ b/neothesia/src/scene/menu_scene/settings.rs @@ -144,6 +144,28 @@ impl super::MenuScene { { ctx.config.set_note_labels(!ctx.config.note_labels()); } + + spacer(ui); + + if nuon::settings_row_toggler() + .title("Sheet Music") + .subtitle("Display a scrolling grand staff") + .value(ctx.config.sheet_music()) + .build(ui, rows) + { + ctx.config.set_sheet_music(!ctx.config.sheet_music()); + } + + spacer(ui); + + self::update_sheet_music_height( + ctx, + nuon::settings_row_spin() + .title("Sheet Music Height") + .subtitle(format!("{} px", ctx.config.sheet_music_height().round())) + .id("sheet-music-height") + .build(ui, rows), + ); }); }); } @@ -449,6 +471,15 @@ pub fn update_range_end(ctx: &mut Context, kind: nuon::SettingsRowSpinResult) { } } +pub fn update_sheet_music_height(ctx: &mut Context, kind: nuon::SettingsRowSpinResult) { + let height = ctx.config.sheet_music_height(); + match kind { + nuon::SettingsRowSpinResult::Plus => ctx.config.set_sheet_music_height(height + 20.0), + nuon::SettingsRowSpinResult::Minus => ctx.config.set_sheet_music_height(height - 20.0), + nuon::SettingsRowSpinResult::Idle => {} + } +} + pub fn open_soundfont_picker(data: &mut UiState) -> BoxFuture { data.is_loading = true; on_async(open_sondfont_picker_fut(), |res, data, ctx| { diff --git a/neothesia/src/scene/playing_scene/mod.rs b/neothesia/src/scene/playing_scene/mod.rs index 5aac36f7..f5ac4209 100644 --- a/neothesia/src/scene/playing_scene/mod.rs +++ b/neothesia/src/scene/playing_scene/mod.rs @@ -25,6 +25,9 @@ use midi_player::MidiPlayer; mod rewind_controller; use rewind_controller::RewindController; +mod sheet_music; +use sheet_music::SheetMusicRenderer; + mod toast_manager; use toast_manager::ToastManager; @@ -39,6 +42,7 @@ pub struct PlayingScene { nuon_renderer: NuonRenderer, note_labels: Option, + sheet_music: Option, player: MidiPlayer, rewind_controller: RewindController, @@ -92,6 +96,10 @@ impl PlayingScene { ctx.text_renderer_factory.new_renderer(), )); + let sheet_music = ctx.config.sheet_music().then(|| { + SheetMusicRenderer::new(ctx, &song.file.tracks, &hidden_tracks, &song.file.measures) + }); + let player = MidiPlayer::new( ctx.output_manager.connection().clone(), song, @@ -113,6 +121,7 @@ impl PlayingScene { keyboard, guidelines, note_labels, + sheet_music, text_renderer, nuon_renderer: NuonRenderer::new(ctx), @@ -200,6 +209,9 @@ impl Scene for PlayingScene { let time = self.update_midi_player(ctx, delta); self.waterfall.update(time); + if let Some(sheet_music) = self.sheet_music.as_mut() { + sheet_music.update(ctx, time); + } self.guidelines.update( &mut self.quad_renderer_bg, ctx.config.animation_speed(), @@ -258,6 +270,9 @@ impl Scene for PlayingScene { if let Some(note_labels) = self.note_labels.as_mut() { note_labels.render(rpass); } + if let Some(sheet_music) = self.sheet_music.as_ref() { + sheet_music.render(rpass); + } self.quad_renderer_fg.render(rpass); if let Some(glow) = &self.glow { glow.render(rpass); @@ -268,6 +283,10 @@ impl Scene for PlayingScene { } fn window_event(&mut self, ctx: &mut Context, event: &WindowEvent) { + if let Some(sheet_music) = self.sheet_music.as_mut() { + sheet_music.handle_window_event(ctx, event); + } + self.rewind_controller .handle_window_event(ctx, event, &mut self.player); diff --git a/neothesia/src/scene/playing_scene/sheet_music.rs b/neothesia/src/scene/playing_scene/sheet_music.rs new file mode 100644 index 00000000..24d4f269 --- /dev/null +++ b/neothesia/src/scene/playing_scene/sheet_music.rs @@ -0,0 +1,386 @@ +use std::time::Duration; + +use midi_file::{MidiNote, MidiTrack}; +use neothesia_core::{ + Rect, + render::{QuadInstance, QuadRenderer}, +}; +use wgpu_jumpstart::Color; +use winit::event::WindowEvent; + +use crate::{context::Context, utils::window::WinitEvent}; + +mod sprite; +use sprite::{SpriteKind, SpriteRenderer}; + +const PANEL_TOP: f32 = 82.0; +const MIN_HEIGHT: f32 = 140.0; +const PIXELS_PER_SECOND: f32 = 180.0; + +#[derive(Clone, Copy)] +struct ScoreNote { + start: f32, + duration: f32, + key: u8, + color_id: usize, +} + +impl From<&MidiNote> for ScoreNote { + fn from(note: &MidiNote) -> Self { + Self { + start: note.start.as_secs_f32(), + duration: note.duration.as_secs_f32(), + key: note.note, + color_id: note.track_color_id, + } + } +} + +pub struct SheetMusicRenderer { + notes: Vec, + measures: Box<[f32]>, + quarter_note_duration: f32, + quads: QuadRenderer, + sprites: SpriteRenderer, + overlay: QuadRenderer, + resizing: bool, +} + +impl SheetMusicRenderer { + pub fn new( + ctx: &Context, + tracks: &[MidiTrack], + hidden_tracks: &[usize], + measures: &[Duration], + ) -> Self { + let mut notes: Vec<_> = tracks + .iter() + .filter(|track| { + !hidden_tracks.contains(&track.track_id) + && !(track.has_drums && !track.has_other_than_drums) + }) + .flat_map(|track| track.notes.iter().map(ScoreNote::from)) + .collect(); + notes.sort_by(|a, b| a.start.total_cmp(&b.start)); + + let measures: Box<[f32]> = measures + .iter() + .map(Duration::as_secs_f32) + .collect::>() + .into(); + let quarter_note_duration = measures + .windows(2) + .find_map(|pair| { + let duration = pair[1] - pair[0]; + (duration > 0.0).then_some(duration / 4.0) + }) + .unwrap_or(0.5); + + Self { + notes, + measures, + quarter_note_duration, + quads: ctx.quad_renderer_facotry.new_renderer(), + sprites: SpriteRenderer::new(ctx), + overlay: ctx.quad_renderer_facotry.new_renderer(), + resizing: false, + } + } + + pub fn update(&mut self, ctx: &Context, current_time: f32) { + self.quads.clear(); + self.sprites.clear(); + self.overlay.clear(); + + let width = ctx.window_state.logical_size.width; + let height = self.height(ctx); + let scale = ctx.window_state.scale_factor as f32; + let panel_rect = Rect::new( + ((0.0 * scale) as u32, (PANEL_TOP * scale) as u32).into(), + ((width * scale) as u32, (height * scale) as u32).into(), + ); + self.quads.set_scissor_rect(panel_rect); + self.sprites.set_scissor_rect(panel_rect); + self.overlay.set_scissor_rect(panel_rect); + + self.push_quad(0.0, PANEL_TOP, width, height, rgb(8, 8, 11, 0.96), 0.0); + + let gap = ((height - 42.0) / 13.0).clamp(7.0, 17.0); + let grand_staff_height = gap * 12.0; + let treble_top = PANEL_TOP + (height - grand_staff_height) / 2.0; + let bass_top = treble_top + gap * 8.0; + let staff_color = rgb(205, 205, 215, 0.72); + + for line in 0..5 { + let offset = line as f32 * gap; + self.push_quad(0.0, treble_top + offset, width, 1.0, staff_color, 0.0); + self.push_quad(0.0, bass_top + offset, width, 1.0, staff_color, 0.0); + } + + let min_time = current_time - width / (2.0 * PIXELS_PER_SECOND) - 0.1; + let max_time = current_time + width / (2.0 * PIXELS_PER_SECOND) + 0.1; + + let first_measure = self.measures.partition_point(|&time| time < min_time); + let mut measure_index = first_measure; + while measure_index < self.measures.len() { + let measure = self.measures[measure_index]; + if measure > max_time { + break; + } + let x = time_to_x(measure, current_time, width, PIXELS_PER_SECOND); + self.push_quad( + x, + treble_top, + 1.0, + bass_top + gap * 4.0 - treble_top, + rgb(155, 155, 165, 0.55), + 0.0, + ); + measure_index += 1; + } + + let first_note = self.notes.partition_point(|note| note.start < min_time); + let visible_notes = self.notes[first_note..] + .iter() + .take_while(|note| note.start <= max_time) + .copied() + .collect::>(); + + for note in visible_notes { + let x = time_to_x(note.start, current_time, width, PIXELS_PER_SECOND); + let (bottom_line, staff_step) = if note.key >= 60 { + ( + treble_top + gap * 4.0, + diatonic_index(note.key) - diatonic_index(64), + ) + } else { + ( + bass_top + gap * 4.0, + diatonic_index(note.key) - diatonic_index(43), + ) + }; + let y = bottom_line - staff_step as f32 * gap / 2.0; + + self.draw_ledger_lines(x, bottom_line, staff_step, gap); + self.draw_note(ctx, note, x, y, gap); + } + + self.draw_clefs(treble_top, bass_top, gap); + + // The playback cursor never moves; the score moves around it. + self.push_overlay( + width / 2.0 - 1.0, + PANEL_TOP, + 2.0, + height, + rgb(255, 82, 105, 0.95), + 0.0, + ); + + let handle_color = if self.resizing { + rgb(255, 255, 255, 0.95) + } else { + rgb(125, 125, 138, 0.9) + }; + self.push_overlay( + width / 2.0 - 28.0, + PANEL_TOP + height - 5.0, + 56.0, + 3.0, + handle_color, + 2.0, + ); + + self.quads.prepare(); + self.sprites.prepare(); + self.overlay.prepare(); + } + + pub fn render<'pass>(&'pass self, rpass: &mut wgpu_jumpstart::RenderPass<'pass>) { + self.quads.render(rpass); + self.sprites.render(rpass); + self.overlay.render(rpass); + } + + pub fn handle_window_event(&mut self, ctx: &mut Context, event: &WindowEvent) { + let cursor_y = ctx.window_state.cursor_logical_position.y; + let bottom = PANEL_TOP + self.height(ctx); + + if event.left_mouse_pressed() && (cursor_y - bottom).abs() <= 10.0 { + self.resizing = true; + } + + if event.left_mouse_released() { + self.resizing = false; + } + + if self.resizing && (event.cursor_moved() || event.left_mouse_pressed()) { + let max_height = self.max_height(ctx); + ctx.config + .set_sheet_music_height((cursor_y - PANEL_TOP).clamp(MIN_HEIGHT, max_height)); + } + } + + fn height(&self, ctx: &Context) -> f32 { + ctx.config + .sheet_music_height() + .clamp(MIN_HEIGHT, self.max_height(ctx)) + } + + fn max_height(&self, ctx: &Context) -> f32 { + // The keyboard occupies the bottom 20% of the window. + (ctx.window_state.logical_size.height * 0.8 - PANEL_TOP - 8.0).max(MIN_HEIGHT) + } + + fn draw_note(&mut self, ctx: &Context, note: ScoreNote, x: f32, y: f32, gap: f32) { + let color = ctx + .config + .color_schema() + .get(note.color_id % ctx.config.color_schema().len().max(1)) + .map(|color| rgb(color.base.0, color.base.1, color.base.2, 1.0)) + .unwrap_or_else(|| rgb(235, 235, 240, 1.0)); + + let is_half = note.duration >= self.quarter_note_duration * 1.75; + let is_whole = note.duration >= self.quarter_note_duration * 3.5; + + let (kind, sprite_height, anchor_y) = if is_whole { + (SpriteKind::WholeNote, gap * 5.0, 0.5) + } else if is_half { + (SpriteKind::HalfNote, gap * 7.0, 0.62) + } else { + (SpriteKind::QuarterNote, gap * 7.0, 0.70) + }; + let sprite_width = sprite_height * (2.0 / 3.0); + self.sprites.push( + kind, + [x - sprite_width * 0.5, y - sprite_height * anchor_y], + [sprite_width, sprite_height], + color, + ); + + if is_accidental(note.key) { + let sharp_height = gap * 4.0; + let sharp_width = sharp_height * (2.0 / 3.0); + self.sprites.push( + SpriteKind::Sharp, + [x - gap * 1.6 - sharp_width * 0.5, y - sharp_height * 0.5], + [sharp_width, sharp_height], + color, + ); + } + } + + fn draw_ledger_lines(&mut self, x: f32, bottom_line: f32, staff_step: i32, gap: f32) { + let line_w = gap * 2.1; + let color = rgb(205, 205, 215, 0.72); + if staff_step < 0 { + let mut step = -2; + while step >= staff_step { + let y = bottom_line - step as f32 * gap / 2.0; + self.push_quad(x - line_w / 2.0, y, line_w, 1.0, color, 0.0); + step -= 2; + } + } else if staff_step > 8 { + let mut step = 10; + while step <= staff_step { + let y = bottom_line - step as f32 * gap / 2.0; + self.push_quad(x - line_w / 2.0, y, line_w, 1.0, color, 0.0); + step += 2; + } + } + } + + fn draw_clefs(&mut self, treble_top: f32, bass_top: f32, gap: f32) { + let color = rgb(245, 245, 250, 1.0); + let treble_height = gap * 7.2; + let treble_width = treble_height * (2.0 / 3.0); + self.sprites.push( + SpriteKind::TrebleClef, + [gap * 0.2, treble_top + gap * 2.0 - treble_height * 0.48], + [treble_width, treble_height], + color, + ); + + let bass_height = gap * 6.0; + let bass_width = bass_height * (2.0 / 3.0); + self.sprites.push( + SpriteKind::BassClef, + [gap * 0.25, bass_top + gap * 2.0 - bass_height * 0.44], + [bass_width, bass_height], + color, + ); + } + + fn push_quad(&mut self, x: f32, y: f32, width: f32, height: f32, color: [f32; 4], radius: f32) { + self.quads.push(QuadInstance { + position: [x, y], + size: [width.max(0.0), height.max(0.0)], + color, + border_radius: [radius; 4], + }); + } + + fn push_overlay( + &mut self, + x: f32, + y: f32, + width: f32, + height: f32, + color: [f32; 4], + radius: f32, + ) { + self.overlay.push(QuadInstance { + position: [x, y], + size: [width.max(0.0), height.max(0.0)], + color, + border_radius: [radius; 4], + }); + } +} + +fn rgb(r: u8, g: u8, b: u8, alpha: f32) -> [f32; 4] { + Color::from_rgba8(r, g, b, alpha).into_linear_rgba() +} + +fn time_to_x(note_time: f32, current_time: f32, width: f32, pixels_per_second: f32) -> f32 { + width / 2.0 + (note_time - current_time) * pixels_per_second +} + +fn diatonic_index(midi_note: u8) -> i32 { + let octave = midi_note as i32 / 12 - 1; + let degree = match midi_note % 12 { + 0 | 1 => 0, // C / C sharp + 2 | 3 => 1, // D / D sharp + 4 => 2, // E + 5 | 6 => 3, // F / F sharp + 7 | 8 => 4, // G / G sharp + 9 | 10 => 5, // A / A sharp + 11 => 6, // B + _ => unreachable!(), + }; + octave * 7 + degree +} + +fn is_accidental(midi_note: u8) -> bool { + matches!(midi_note % 12, 1 | 3 | 6 | 8 | 10) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn current_playback_position_is_centered() { + assert_eq!(time_to_x(12.5, 12.5, 1000.0, PIXELS_PER_SECOND), 500.0); + assert!(time_to_x(13.0, 12.5, 1000.0, PIXELS_PER_SECOND) > 500.0); + assert!(time_to_x(12.0, 12.5, 1000.0, PIXELS_PER_SECOND) < 500.0); + } + + #[test] + fn pitch_mapping_advances_by_staff_steps() { + assert_eq!(diatonic_index(60), 28); // C4 + assert_eq!(diatonic_index(62), 29); // D4 + assert_eq!(diatonic_index(64), 30); // E4 + assert_eq!(diatonic_index(61), 28); // C sharp shares C's staff position + } +} diff --git a/neothesia/src/scene/playing_scene/sheet_music/sprite.rs b/neothesia/src/scene/playing_scene/sheet_music/sprite.rs new file mode 100644 index 00000000..11eb52e7 --- /dev/null +++ b/neothesia/src/scene/playing_scene/sheet_music/sprite.rs @@ -0,0 +1,226 @@ +use std::io::Cursor; + +use bytemuck::{Pod, Zeroable}; +use neothesia_core::Rect; +use wgpu_jumpstart::{Instances, Shape, wgpu}; + +use crate::context::Context; + +#[derive(Clone, Copy)] +pub enum SpriteKind { + TrebleClef, + BassClef, + QuarterNote, + HalfNote, + WholeNote, + Sharp, +} + +impl SpriteKind { + fn uv(self) -> ([f32; 2], [f32; 2]) { + let cell = [1.0 / 3.0, 1.0 / 2.0]; + let origin = match self { + Self::TrebleClef => [0.0, 0.0], + Self::BassClef => [cell[0], 0.0], + Self::QuarterNote => [cell[0] * 2.0, 0.0], + Self::HalfNote => [0.0, cell[1]], + Self::WholeNote => [cell[0], cell[1]], + Self::Sharp => [cell[0] * 2.0, cell[1]], + }; + (origin, cell) + } +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct SpriteInstance { + position: [f32; 2], + size: [f32; 2], + uv_origin: [f32; 2], + uv_size: [f32; 2], + tint: [f32; 4], +} + +impl SpriteInstance { + fn layout() -> wgpu::VertexBufferLayout<'static> { + const ATTRIBUTES: &[wgpu::VertexAttribute] = &wgpu::vertex_attr_array![ + 1 => Float32x2, + 2 => Float32x2, + 3 => Float32x2, + 4 => Float32x2, + 5 => Float32x4, + ]; + + wgpu::VertexBufferLayout { + array_stride: std::mem::size_of::() as wgpu::BufferAddress, + step_mode: wgpu::VertexStepMode::Instance, + attributes: ATTRIBUTES, + } + } +} + +pub struct SpriteRenderer { + pipeline: wgpu::RenderPipeline, + transform_bind_group: wgpu::BindGroup, + atlas_bind_group: wgpu::BindGroup, + shape: Shape, + instances: Instances, + device: wgpu::Device, + queue: wgpu::Queue, + scissor_rect: Rect, +} + +impl SpriteRenderer { + pub fn new(ctx: &Context) -> Self { + let device = &ctx.gpu.device; + let queue = &ctx.gpu.queue; + + let atlas_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("sheet music atlas layout"), + 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, + }, + ], + }); + + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("sheet music sprite shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("sprite.wgsl").into()), + }); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("sheet music sprite pipeline layout"), + bind_group_layouts: &[&ctx.transform.bind_group_layout, &atlas_layout], + immediate_size: 0, + }); + let target = wgpu_jumpstart::default_color_target_state(ctx.gpu.texture_format); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("sheet music sprite pipeline"), + layout: Some(&pipeline_layout), + fragment: Some(wgpu_jumpstart::default_fragment(&shader, &[Some(target)])), + ..wgpu_jumpstart::default_render_pipeline(wgpu_jumpstart::default_vertex( + &shader, + &[Shape::layout(), SpriteInstance::layout()], + )) + }); + + let atlas_bytes = include_bytes!("../../../../../assets/sheet-music/notation-atlas.png"); + let (rgba, width, height) = + neothesia_image::load_png(Cursor::new(atlas_bytes.as_slice())).unwrap(); + let size = wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("sheet music notation atlas"), + size, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba8UnormSrgb, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &rgba, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 4), + rows_per_image: Some(height), + }, + size, + ); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("sheet music atlas sampler"), + address_mode_u: wgpu::AddressMode::ClampToEdge, + address_mode_v: wgpu::AddressMode::ClampToEdge, + address_mode_w: wgpu::AddressMode::ClampToEdge, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let atlas_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("sheet music atlas bind group"), + layout: &atlas_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + + Self { + pipeline, + transform_bind_group: ctx.transform.bind_group.clone(), + atlas_bind_group, + shape: Shape::new_quad(device), + instances: Instances::new(device, 256), + device: device.clone(), + queue: queue.clone(), + scissor_rect: Rect::zero(), + } + } + + pub fn clear(&mut self) { + self.instances.data.clear(); + } + + pub fn set_scissor_rect(&mut self, rect: Rect) { + self.scissor_rect = rect; + } + + pub fn push(&mut self, kind: SpriteKind, position: [f32; 2], size: [f32; 2], tint: [f32; 4]) { + let (uv_origin, uv_size) = kind.uv(); + self.instances.data.push(SpriteInstance { + position, + size, + uv_origin, + uv_size, + tint, + }); + } + + pub fn prepare(&mut self) { + self.instances.update(&self.device, &self.queue); + } + + pub fn render<'pass>(&'pass self, rpass: &mut wgpu_jumpstart::RenderPass<'pass>) { + let pass_size = rpass.size(); + let rect = self.scissor_rect; + rpass.set_scissor_rect(rect.origin.x, rect.origin.y, rect.width(), rect.height()); + rpass.set_pipeline(&self.pipeline); + rpass.set_bind_group(0, &self.transform_bind_group, &[]); + rpass.set_bind_group(1, &self.atlas_bind_group, &[]); + rpass.set_vertex_buffer(0, self.shape.vertex_buffer.slice(..)); + rpass.set_vertex_buffer(1, self.instances.buffer.slice(..)); + rpass.set_index_buffer(self.shape.index_buffer.slice(..), wgpu::IndexFormat::Uint16); + rpass.draw_indexed(0..self.shape.indices_len, 0, 0..self.instances.len()); + rpass.set_scissor_rect(0, 0, pass_size.width, pass_size.height); + } +} diff --git a/neothesia/src/scene/playing_scene/sheet_music/sprite.wgsl b/neothesia/src/scene/playing_scene/sheet_music/sprite.wgsl new file mode 100644 index 00000000..8fef7592 --- /dev/null +++ b/neothesia/src/scene/playing_scene/sheet_music/sprite.wgsl @@ -0,0 +1,49 @@ +struct ViewUniform { + transform: mat4x4, + size: vec2, + scale: f32, +} + +@group(0) @binding(0) +var view_uniform: ViewUniform; + +@group(1) @binding(0) +var atlas: texture_2d; + +@group(1) @binding(1) +var atlas_sampler: sampler; + +struct VertexInput { + @location(0) position: vec2, + @location(1) sprite_position: vec2, + @location(2) sprite_size: vec2, + @location(3) uv_origin: vec2, + @location(4) uv_size: vec2, + @location(5) tint: vec4, +} + +struct VertexOutput { + @builtin(position) position: vec4, + @location(0) uv: vec2, + @location(1) tint: vec4, +} + +@vertex +fn vs_main(input: VertexInput) -> VertexOutput { + let logical_position = input.sprite_position + input.position * input.sprite_size; + + var out: VertexOutput; + out.position = view_uniform.transform + * vec4(logical_position * view_uniform.scale, 0.0, 1.0); + out.uv = input.uv_origin + input.position * input.uv_size; + out.tint = input.tint; + return out; +} + +@fragment +fn fs_main(input: VertexOutput) -> @location(0) vec4 { + let sample = textureSample(atlas, atlas_sampler, input.uv); + // The atlas is a white mask. Using only its alpha allows every sprite to + // inherit the MIDI track color without retaining chroma-key remnants. + return vec4(input.tint.rgb, input.tint.a * sample.a); +}