Add spectrogram
This commit is contained in:
parent
ad81cce0c6
commit
c11dab928c
|
|
@ -24,6 +24,7 @@ pub mod create_folder;
|
||||||
pub mod rename_folder;
|
pub mod rename_folder;
|
||||||
pub mod delete_folder;
|
pub mod delete_folder;
|
||||||
pub mod move_asset_to_folder;
|
pub mod move_asset_to_folder;
|
||||||
|
pub mod update_midi_notes;
|
||||||
|
|
||||||
pub use add_clip_instance::AddClipInstanceAction;
|
pub use add_clip_instance::AddClipInstanceAction;
|
||||||
pub use add_effect::AddEffectAction;
|
pub use add_effect::AddEffectAction;
|
||||||
|
|
@ -46,3 +47,4 @@ pub use create_folder::CreateFolderAction;
|
||||||
pub use rename_folder::RenameFolderAction;
|
pub use rename_folder::RenameFolderAction;
|
||||||
pub use delete_folder::{DeleteFolderAction, DeleteStrategy};
|
pub use delete_folder::{DeleteFolderAction, DeleteStrategy};
|
||||||
pub use move_asset_to_folder::MoveAssetToFolderAction;
|
pub use move_asset_to_folder::MoveAssetToFolderAction;
|
||||||
|
pub use update_midi_notes::UpdateMidiNotesAction;
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
use crate::action::Action;
|
||||||
|
use crate::document::Document;
|
||||||
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
/// Action to update MIDI notes in a clip (supports undo/redo)
|
||||||
|
///
|
||||||
|
/// Stores the before and after note states. MIDI note data lives in the backend,
|
||||||
|
/// so execute/rollback are no-ops on the document — all changes go through
|
||||||
|
/// execute_backend/rollback_backend.
|
||||||
|
pub struct UpdateMidiNotesAction {
|
||||||
|
/// Layer containing the MIDI clip
|
||||||
|
pub layer_id: Uuid,
|
||||||
|
/// Backend MIDI clip ID
|
||||||
|
pub midi_clip_id: u32,
|
||||||
|
/// Notes before the edit: (start_time, note, velocity, duration)
|
||||||
|
pub old_notes: Vec<(f64, u8, u8, f64)>,
|
||||||
|
/// Notes after the edit: (start_time, note, velocity, duration)
|
||||||
|
pub new_notes: Vec<(f64, u8, u8, f64)>,
|
||||||
|
/// Human-readable description
|
||||||
|
pub description_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Action for UpdateMidiNotesAction {
|
||||||
|
fn execute(&mut self, _document: &mut Document) -> Result<(), String> {
|
||||||
|
// MIDI note data lives in the backend, not the document
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback(&mut self, _document: &mut Document) -> Result<(), String> {
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn description(&self) -> String {
|
||||||
|
self.description_text.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_backend(
|
||||||
|
&mut self,
|
||||||
|
backend: &mut crate::action::BackendContext,
|
||||||
|
_document: &Document,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let track_id = backend
|
||||||
|
.layer_to_track_map
|
||||||
|
.get(&self.layer_id)
|
||||||
|
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?;
|
||||||
|
|
||||||
|
controller.update_midi_clip_notes(*track_id, self.midi_clip_id, self.new_notes.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn rollback_backend(
|
||||||
|
&mut self,
|
||||||
|
backend: &mut crate::action::BackendContext,
|
||||||
|
_document: &Document,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
let controller = match backend.audio_controller.as_mut() {
|
||||||
|
Some(c) => c,
|
||||||
|
None => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let track_id = backend
|
||||||
|
.layer_to_track_map
|
||||||
|
.get(&self.layer_id)
|
||||||
|
.ok_or_else(|| format!("Layer {} not mapped to backend track", self.layer_id))?;
|
||||||
|
|
||||||
|
controller.update_midi_clip_notes(*track_id, self.midi_clip_id, self.old_notes.clone());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -20,6 +20,8 @@ mod theme;
|
||||||
use theme::{Theme, ThemeMode};
|
use theme::{Theme, ThemeMode};
|
||||||
|
|
||||||
mod waveform_gpu;
|
mod waveform_gpu;
|
||||||
|
mod spectrogram_gpu;
|
||||||
|
mod spectrogram_compute;
|
||||||
|
|
||||||
mod config;
|
mod config;
|
||||||
use config::AppConfig;
|
use config::AppConfig;
|
||||||
|
|
@ -687,8 +689,8 @@ struct EditorApp {
|
||||||
|
|
||||||
/// Cache for MIDI event data (keyed by backend midi_clip_id)
|
/// Cache for MIDI event data (keyed by backend midi_clip_id)
|
||||||
/// Prevents repeated backend queries for the same MIDI clip
|
/// Prevents repeated backend queries for the same MIDI clip
|
||||||
/// Format: (timestamp, note_number, is_note_on)
|
/// Format: (timestamp, note_number, velocity, is_note_on)
|
||||||
midi_event_cache: HashMap<u32, Vec<(f64, u8, bool)>>,
|
midi_event_cache: HashMap<u32, Vec<(f64, u8, u8, bool)>>,
|
||||||
/// Cache for audio file durations to avoid repeated queries
|
/// Cache for audio file durations to avoid repeated queries
|
||||||
/// Format: pool_index -> duration in seconds
|
/// Format: pool_index -> duration in seconds
|
||||||
audio_duration_cache: HashMap<usize, f64>,
|
audio_duration_cache: HashMap<usize, f64>,
|
||||||
|
|
@ -2395,15 +2397,15 @@ impl EditorApp {
|
||||||
let duration = midi_clip.duration;
|
let duration = midi_clip.duration;
|
||||||
let event_count = midi_clip.events.len();
|
let event_count = midi_clip.events.len();
|
||||||
|
|
||||||
// Process MIDI events to cache format: (timestamp, note_number, is_note_on)
|
// Process MIDI events to cache format: (timestamp, note_number, velocity, is_note_on)
|
||||||
// Filter to note events only (status 0x90 = note-on, 0x80 = note-off)
|
// Filter to note events only (status 0x90 = note-on, 0x80 = note-off)
|
||||||
let processed_events: Vec<(f64, u8, bool)> = midi_clip.events.iter()
|
let processed_events: Vec<(f64, u8, u8, bool)> = midi_clip.events.iter()
|
||||||
.filter_map(|event| {
|
.filter_map(|event| {
|
||||||
let status_type = event.status & 0xF0;
|
let status_type = event.status & 0xF0;
|
||||||
if status_type == 0x90 || status_type == 0x80 {
|
if status_type == 0x90 || status_type == 0x80 {
|
||||||
// Note-on is 0x90 with velocity > 0, Note-off is 0x80 or velocity = 0
|
// Note-on is 0x90 with velocity > 0, Note-off is 0x80 or velocity = 0
|
||||||
let is_note_on = status_type == 0x90 && event.data2 > 0;
|
let is_note_on = status_type == 0x90 && event.data2 > 0;
|
||||||
Some((event.timestamp, event.data1, is_note_on))
|
Some((event.timestamp, event.data1, event.data2, is_note_on))
|
||||||
} else {
|
} else {
|
||||||
None // Ignore non-note events (CC, pitch bend, etc.)
|
None // Ignore non-note events (CC, pitch bend, etc.)
|
||||||
}
|
}
|
||||||
|
|
@ -4026,7 +4028,7 @@ struct RenderContext<'a> {
|
||||||
/// Mapping from Document layer UUIDs to daw-backend TrackIds
|
/// Mapping from Document layer UUIDs to daw-backend TrackIds
|
||||||
layer_to_track_map: &'a std::collections::HashMap<Uuid, daw_backend::TrackId>,
|
layer_to_track_map: &'a std::collections::HashMap<Uuid, daw_backend::TrackId>,
|
||||||
/// Cache of MIDI events for rendering (keyed by backend midi_clip_id)
|
/// Cache of MIDI events for rendering (keyed by backend midi_clip_id)
|
||||||
midi_event_cache: &'a HashMap<u32, Vec<(f64, u8, bool)>>,
|
midi_event_cache: &'a HashMap<u32, Vec<(f64, u8, u8, bool)>>,
|
||||||
/// Audio pool indices with new raw audio data this frame (for thumbnail invalidation)
|
/// Audio pool indices with new raw audio data this frame (for thumbnail invalidation)
|
||||||
audio_pools_with_new_waveforms: &'a HashSet<usize>,
|
audio_pools_with_new_waveforms: &'a HashSet<usize>,
|
||||||
/// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels))
|
/// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels))
|
||||||
|
|
|
||||||
|
|
@ -376,7 +376,7 @@ fn generate_video_thumbnail(
|
||||||
/// Generate a piano roll thumbnail for MIDI clips
|
/// Generate a piano roll thumbnail for MIDI clips
|
||||||
/// Shows notes as horizontal bars with Y position = note % 12 (one octave)
|
/// Shows notes as horizontal bars with Y position = note % 12 (one octave)
|
||||||
fn generate_midi_thumbnail(
|
fn generate_midi_thumbnail(
|
||||||
events: &[(f64, u8, bool)], // (timestamp, note_number, is_note_on)
|
events: &[(f64, u8, u8, bool)], // (timestamp, note_number, velocity, is_note_on)
|
||||||
duration: f64,
|
duration: f64,
|
||||||
bg_color: egui::Color32,
|
bg_color: egui::Color32,
|
||||||
note_color: egui::Color32,
|
note_color: egui::Color32,
|
||||||
|
|
@ -394,7 +394,7 @@ fn generate_midi_thumbnail(
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw note events
|
// Draw note events
|
||||||
for &(timestamp, note_number, is_note_on) in events {
|
for &(timestamp, note_number, _velocity, is_note_on) in events {
|
||||||
if !is_note_on || timestamp > preview_duration {
|
if !is_note_on || timestamp > preview_duration {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -188,7 +188,7 @@ pub struct SharedPaneState<'a> {
|
||||||
/// Number of sides for polygon tool
|
/// Number of sides for polygon tool
|
||||||
pub polygon_sides: &'a mut u32,
|
pub polygon_sides: &'a mut u32,
|
||||||
/// Cache of MIDI events for rendering (keyed by backend midi_clip_id)
|
/// Cache of MIDI events for rendering (keyed by backend midi_clip_id)
|
||||||
pub midi_event_cache: &'a std::collections::HashMap<u32, Vec<(f64, u8, bool)>>,
|
pub midi_event_cache: &'a std::collections::HashMap<u32, Vec<(f64, u8, u8, bool)>>,
|
||||||
/// Audio pool indices that got new raw audio data this frame (for thumbnail invalidation)
|
/// Audio pool indices that got new raw audio data this frame (for thumbnail invalidation)
|
||||||
pub audio_pools_with_new_waveforms: &'a std::collections::HashSet<usize>,
|
pub audio_pools_with_new_waveforms: &'a std::collections::HashSet<usize>,
|
||||||
/// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels))
|
/// Raw audio samples for GPU waveform rendering (pool_index -> (samples, sample_rate, channels))
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,153 @@
|
||||||
|
// Spectrogram rendering shader for FFT magnitude data.
|
||||||
|
// Texture layout: X = frequency bin, Y = time bin
|
||||||
|
// Values: normalized magnitude (0.0 = silence, 1.0 = peak)
|
||||||
|
// Vertical axis maps MIDI notes to frequency bins (matching piano roll)
|
||||||
|
|
||||||
|
struct Params {
|
||||||
|
clip_rect: vec4<f32>,
|
||||||
|
viewport_start_time: f32,
|
||||||
|
pixels_per_second: f32,
|
||||||
|
audio_duration: f32,
|
||||||
|
sample_rate: f32,
|
||||||
|
clip_start_time: f32,
|
||||||
|
trim_start: f32,
|
||||||
|
time_bins: f32,
|
||||||
|
freq_bins: f32,
|
||||||
|
hop_size: f32,
|
||||||
|
fft_size: f32,
|
||||||
|
scroll_y: f32,
|
||||||
|
note_height: f32,
|
||||||
|
screen_size: vec2<f32>,
|
||||||
|
min_note: f32,
|
||||||
|
max_note: f32,
|
||||||
|
gamma: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
@group(0) @binding(0) var spec_tex: texture_2d<f32>;
|
||||||
|
@group(0) @binding(1) var spec_sampler: sampler;
|
||||||
|
@group(0) @binding(2) var<uniform> params: Params;
|
||||||
|
|
||||||
|
struct VertexOutput {
|
||||||
|
@builtin(position) position: vec4<f32>,
|
||||||
|
@location(0) uv: vec2<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
@vertex
|
||||||
|
fn vs_main(@builtin(vertex_index) vi: u32) -> VertexOutput {
|
||||||
|
var out: VertexOutput;
|
||||||
|
let x = f32(i32(vi) / 2) * 4.0 - 1.0;
|
||||||
|
let y = f32(i32(vi) % 2) * 4.0 - 1.0;
|
||||||
|
out.position = vec4(x, y, 0.0, 1.0);
|
||||||
|
out.uv = vec2((x + 1.0) * 0.5, (1.0 - y) * 0.5);
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Signed distance from point to rounded rectangle boundary
|
||||||
|
fn rounded_rect_sdf(pos: vec2<f32>, rect_min: vec2<f32>, rect_max: vec2<f32>, r: f32) -> f32 {
|
||||||
|
let center = (rect_min + rect_max) * 0.5;
|
||||||
|
let half_size = (rect_max - rect_min) * 0.5;
|
||||||
|
let q = abs(pos - center) - half_size + vec2(r);
|
||||||
|
return length(max(q, vec2(0.0))) - r;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colormap: black -> blue -> purple -> red -> orange -> yellow -> white
|
||||||
|
fn colormap(v: f32, gamma: f32) -> vec4<f32> {
|
||||||
|
let t = pow(clamp(v, 0.0, 1.0), gamma);
|
||||||
|
|
||||||
|
if t < 1.0 / 6.0 {
|
||||||
|
// Black -> blue
|
||||||
|
let s = t * 6.0;
|
||||||
|
return vec4(0.0, 0.0, s, 1.0);
|
||||||
|
} else if t < 2.0 / 6.0 {
|
||||||
|
// Blue -> purple
|
||||||
|
let s = (t - 1.0 / 6.0) * 6.0;
|
||||||
|
return vec4(s * 0.6, 0.0, 1.0 - s * 0.2, 1.0);
|
||||||
|
} else if t < 3.0 / 6.0 {
|
||||||
|
// Purple -> red
|
||||||
|
let s = (t - 2.0 / 6.0) * 6.0;
|
||||||
|
return vec4(0.6 + s * 0.4, 0.0, 0.8 - s * 0.8, 1.0);
|
||||||
|
} else if t < 4.0 / 6.0 {
|
||||||
|
// Red -> orange
|
||||||
|
let s = (t - 3.0 / 6.0) * 6.0;
|
||||||
|
return vec4(1.0, s * 0.5, 0.0, 1.0);
|
||||||
|
} else if t < 5.0 / 6.0 {
|
||||||
|
// Orange -> yellow
|
||||||
|
let s = (t - 4.0 / 6.0) * 6.0;
|
||||||
|
return vec4(1.0, 0.5 + s * 0.5, 0.0, 1.0);
|
||||||
|
} else {
|
||||||
|
// Yellow -> white
|
||||||
|
let s = (t - 5.0 / 6.0) * 6.0;
|
||||||
|
return vec4(1.0, 1.0, s, 1.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@fragment
|
||||||
|
fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> {
|
||||||
|
let frag_x = in.position.x;
|
||||||
|
let frag_y = in.position.y;
|
||||||
|
|
||||||
|
// Clip to view rectangle
|
||||||
|
if frag_x < params.clip_rect.x || frag_x > params.clip_rect.z ||
|
||||||
|
frag_y < params.clip_rect.y || frag_y > params.clip_rect.w {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Compute the content rect in screen space
|
||||||
|
let content_left = params.clip_rect.x + (params.clip_start_time - params.trim_start - params.viewport_start_time) * params.pixels_per_second;
|
||||||
|
let content_right = content_left + params.audio_duration * params.pixels_per_second;
|
||||||
|
let content_top = params.clip_rect.y - params.scroll_y;
|
||||||
|
let content_bottom = params.clip_rect.y + (params.max_note - params.min_note + 1.0) * params.note_height - params.scroll_y;
|
||||||
|
|
||||||
|
// Rounded corners: content edges on X, visible viewport edges on Y.
|
||||||
|
// This rounds left/right where the clip starts/ends, and top/bottom at the view boundary.
|
||||||
|
let vis_top = max(content_top, params.clip_rect.y);
|
||||||
|
let vis_bottom = min(content_bottom, params.clip_rect.w);
|
||||||
|
|
||||||
|
let corner_radius = 6.0;
|
||||||
|
let dist = rounded_rect_sdf(
|
||||||
|
vec2(frag_x, frag_y),
|
||||||
|
vec2(content_left, vis_top),
|
||||||
|
vec2(content_right, vis_bottom),
|
||||||
|
corner_radius
|
||||||
|
);
|
||||||
|
if dist > 0.0 {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fragment X -> audio time -> time bin
|
||||||
|
let timeline_time = params.viewport_start_time + (frag_x - params.clip_rect.x) / params.pixels_per_second;
|
||||||
|
let audio_time = timeline_time - params.clip_start_time + params.trim_start;
|
||||||
|
|
||||||
|
if audio_time < 0.0 || audio_time > params.audio_duration {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_bin = audio_time * params.sample_rate / params.hop_size;
|
||||||
|
if time_bin < 0.0 || time_bin >= params.time_bins {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fragment Y -> MIDI note -> frequency -> frequency bin
|
||||||
|
let note = params.max_note - ((frag_y - params.clip_rect.y + params.scroll_y) / params.note_height);
|
||||||
|
|
||||||
|
if note < params.min_note || note > params.max_note {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// MIDI note -> frequency: freq = 440 * 2^((note - 69) / 12)
|
||||||
|
let freq = 440.0 * pow(2.0, (note - 69.0) / 12.0);
|
||||||
|
|
||||||
|
// Frequency -> FFT bin index
|
||||||
|
let freq_bin = freq * params.fft_size / params.sample_rate;
|
||||||
|
|
||||||
|
if freq_bin < 0.0 || freq_bin >= params.freq_bins {
|
||||||
|
discard;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sample texture with bilinear filtering
|
||||||
|
let u = freq_bin / params.freq_bins;
|
||||||
|
let v = time_bin / params.time_bins;
|
||||||
|
let magnitude = textureSampleLevel(spec_tex, spec_sampler, vec2(u, v), 0.0).r;
|
||||||
|
|
||||||
|
return colormap(magnitude, params.gamma);
|
||||||
|
}
|
||||||
|
|
@ -505,7 +505,7 @@ impl TimelinePane {
|
||||||
painter: &egui::Painter,
|
painter: &egui::Painter,
|
||||||
clip_rect: egui::Rect,
|
clip_rect: egui::Rect,
|
||||||
rect_min_x: f32, // Timeline panel left edge (for proper viewport-relative positioning)
|
rect_min_x: f32, // Timeline panel left edge (for proper viewport-relative positioning)
|
||||||
events: &[(f64, u8, bool)], // (timestamp, note_number, is_note_on)
|
events: &[(f64, u8, u8, bool)], // (timestamp, note_number, velocity, is_note_on)
|
||||||
trim_start: f64,
|
trim_start: f64,
|
||||||
visible_duration: f64,
|
visible_duration: f64,
|
||||||
timeline_start: f64,
|
timeline_start: f64,
|
||||||
|
|
@ -527,7 +527,7 @@ impl TimelinePane {
|
||||||
let mut note_rectangles: Vec<(egui::Rect, u8)> = Vec::new();
|
let mut note_rectangles: Vec<(egui::Rect, u8)> = Vec::new();
|
||||||
|
|
||||||
// First pass: pair note-ons with note-offs to calculate durations
|
// First pass: pair note-ons with note-offs to calculate durations
|
||||||
for &(timestamp, note_number, is_note_on) in events {
|
for &(timestamp, note_number, _velocity, is_note_on) in events {
|
||||||
if is_note_on {
|
if is_note_on {
|
||||||
// Store note-on timestamp
|
// Store note-on timestamp
|
||||||
active_notes.insert(note_number, timestamp);
|
active_notes.insert(note_number, timestamp);
|
||||||
|
|
@ -892,7 +892,7 @@ impl TimelinePane {
|
||||||
document: &lightningbeam_core::document::Document,
|
document: &lightningbeam_core::document::Document,
|
||||||
active_layer_id: &Option<uuid::Uuid>,
|
active_layer_id: &Option<uuid::Uuid>,
|
||||||
selection: &lightningbeam_core::selection::Selection,
|
selection: &lightningbeam_core::selection::Selection,
|
||||||
midi_event_cache: &std::collections::HashMap<u32, Vec<(f64, u8, bool)>>,
|
midi_event_cache: &std::collections::HashMap<u32, Vec<(f64, u8, u8, bool)>>,
|
||||||
raw_audio_cache: &std::collections::HashMap<usize, (Vec<f32>, u32, u32)>,
|
raw_audio_cache: &std::collections::HashMap<usize, (Vec<f32>, u32, u32)>,
|
||||||
waveform_gpu_dirty: &mut std::collections::HashSet<usize>,
|
waveform_gpu_dirty: &mut std::collections::HashSet<usize>,
|
||||||
target_format: wgpu::TextureFormat,
|
target_format: wgpu::TextureFormat,
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,144 @@
|
||||||
|
/// CPU-side FFT computation for spectrogram visualization.
|
||||||
|
///
|
||||||
|
/// Uses rayon to parallelize FFT across time slices on all CPU cores.
|
||||||
|
/// Produces a 2D magnitude grid (time bins x frequency bins) for GPU texture upload.
|
||||||
|
|
||||||
|
use rayon::prelude::*;
|
||||||
|
use std::f32::consts::PI;
|
||||||
|
|
||||||
|
/// Pre-computed spectrogram data ready for GPU upload
|
||||||
|
pub struct SpectrogramData {
|
||||||
|
/// Flattened 2D array of normalized magnitudes [time_bins * freq_bins], row-major
|
||||||
|
/// Each value is 0.0 (silence) to 1.0 (peak), log-scale normalized
|
||||||
|
pub magnitudes: Vec<f32>,
|
||||||
|
pub time_bins: usize,
|
||||||
|
pub freq_bins: usize,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub hop_size: usize,
|
||||||
|
pub fft_size: usize,
|
||||||
|
pub duration: f64,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Compute a spectrogram from raw audio samples using parallel FFT.
|
||||||
|
///
|
||||||
|
/// Each time slice is processed independently via rayon, making this
|
||||||
|
/// scale well across all CPU cores.
|
||||||
|
pub fn compute_spectrogram(
|
||||||
|
samples: &[f32],
|
||||||
|
sample_rate: u32,
|
||||||
|
channels: u32,
|
||||||
|
fft_size: usize,
|
||||||
|
hop_size: usize,
|
||||||
|
) -> SpectrogramData {
|
||||||
|
// Mix to mono
|
||||||
|
let mono: Vec<f32> = if channels >= 2 {
|
||||||
|
samples
|
||||||
|
.chunks(channels as usize)
|
||||||
|
.map(|frame| frame.iter().sum::<f32>() / channels as f32)
|
||||||
|
.collect()
|
||||||
|
} else {
|
||||||
|
samples.to_vec()
|
||||||
|
};
|
||||||
|
|
||||||
|
let freq_bins = fft_size / 2 + 1;
|
||||||
|
let duration = mono.len() as f64 / sample_rate as f64;
|
||||||
|
|
||||||
|
if mono.len() < fft_size {
|
||||||
|
return SpectrogramData {
|
||||||
|
magnitudes: Vec::new(),
|
||||||
|
time_bins: 0,
|
||||||
|
freq_bins,
|
||||||
|
sample_rate,
|
||||||
|
hop_size,
|
||||||
|
fft_size,
|
||||||
|
duration,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
let time_bins = (mono.len().saturating_sub(fft_size)) / hop_size + 1;
|
||||||
|
|
||||||
|
// Precompute Hann window
|
||||||
|
let window: Vec<f32> = (0..fft_size)
|
||||||
|
.map(|i| 0.5 * (1.0 - (2.0 * PI * i as f32 / fft_size as f32).cos()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Precompute twiddle factors for Cooley-Tukey radix-2 FFT
|
||||||
|
let twiddles: Vec<(f32, f32)> = (0..fft_size / 2)
|
||||||
|
.map(|k| {
|
||||||
|
let angle = -2.0 * PI * k as f32 / fft_size as f32;
|
||||||
|
(angle.cos(), angle.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Bit-reversal permutation table
|
||||||
|
let bits = (fft_size as f32).log2() as u32;
|
||||||
|
let bit_rev: Vec<usize> = (0..fft_size)
|
||||||
|
.map(|i| (i as u32).reverse_bits().wrapping_shr(32 - bits) as usize)
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Process all time slices in parallel
|
||||||
|
let magnitudes: Vec<f32> = (0..time_bins)
|
||||||
|
.into_par_iter()
|
||||||
|
.flat_map(|t| {
|
||||||
|
let offset = t * hop_size;
|
||||||
|
let mut re = vec![0.0f32; fft_size];
|
||||||
|
let mut im = vec![0.0f32; fft_size];
|
||||||
|
|
||||||
|
// Load windowed samples in bit-reversed order
|
||||||
|
for i in 0..fft_size {
|
||||||
|
let sample = if offset + i < mono.len() {
|
||||||
|
mono[offset + i]
|
||||||
|
} else {
|
||||||
|
0.0
|
||||||
|
};
|
||||||
|
re[bit_rev[i]] = sample * window[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cooley-Tukey radix-2 DIT FFT
|
||||||
|
let mut half_size = 1;
|
||||||
|
while half_size < fft_size {
|
||||||
|
let step = half_size * 2;
|
||||||
|
let twiddle_step = fft_size / step;
|
||||||
|
|
||||||
|
for k in (0..fft_size).step_by(step) {
|
||||||
|
for j in 0..half_size {
|
||||||
|
let tw_idx = j * twiddle_step;
|
||||||
|
let (tw_re, tw_im) = twiddles[tw_idx];
|
||||||
|
|
||||||
|
let a = k + j;
|
||||||
|
let b = a + half_size;
|
||||||
|
|
||||||
|
let t_re = tw_re * re[b] - tw_im * im[b];
|
||||||
|
let t_im = tw_re * im[b] + tw_im * re[b];
|
||||||
|
|
||||||
|
re[b] = re[a] - t_re;
|
||||||
|
im[b] = im[a] - t_im;
|
||||||
|
re[a] += t_re;
|
||||||
|
im[a] += t_im;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
half_size = step;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract magnitudes for positive frequencies
|
||||||
|
let mut mags = Vec::with_capacity(freq_bins);
|
||||||
|
for f in 0..freq_bins {
|
||||||
|
let mag = (re[f] * re[f] + im[f] * im[f]).sqrt();
|
||||||
|
// dB normalization: -80dB floor to 0dB ceiling → 0.0 to 1.0
|
||||||
|
let db = 20.0 * (mag + 1e-10).log10();
|
||||||
|
mags.push(((db + 80.0) / 80.0).clamp(0.0, 1.0));
|
||||||
|
}
|
||||||
|
mags
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
SpectrogramData {
|
||||||
|
magnitudes,
|
||||||
|
time_bins,
|
||||||
|
freq_bins,
|
||||||
|
sample_rate,
|
||||||
|
hop_size,
|
||||||
|
fft_size,
|
||||||
|
duration,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,350 @@
|
||||||
|
/// GPU resources for spectrogram rendering.
|
||||||
|
///
|
||||||
|
/// Follows the same pattern as waveform_gpu.rs:
|
||||||
|
/// - SpectrogramGpuResources stored in CallbackResources (long-lived)
|
||||||
|
/// - SpectrogramCallback implements egui_wgpu::CallbackTrait (per-frame)
|
||||||
|
/// - R32Float texture holds magnitude data (time bins × freq bins)
|
||||||
|
/// - Fragment shader applies colormap and frequency mapping
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
/// GPU resources for all spectrograms (stored in egui_wgpu::CallbackResources)
|
||||||
|
pub struct SpectrogramGpuResources {
|
||||||
|
pub entries: HashMap<usize, SpectrogramGpuEntry>,
|
||||||
|
render_pipeline: wgpu::RenderPipeline,
|
||||||
|
render_bind_group_layout: wgpu::BindGroupLayout,
|
||||||
|
sampler: wgpu::Sampler,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-audio-pool GPU data for one spectrogram
|
||||||
|
#[allow(dead_code)]
|
||||||
|
pub struct SpectrogramGpuEntry {
|
||||||
|
pub texture: wgpu::Texture,
|
||||||
|
pub texture_view: wgpu::TextureView,
|
||||||
|
pub render_bind_group: wgpu::BindGroup,
|
||||||
|
pub uniform_buffer: wgpu::Buffer,
|
||||||
|
pub time_bins: u32,
|
||||||
|
pub freq_bins: u32,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub hop_size: u32,
|
||||||
|
pub fft_size: u32,
|
||||||
|
pub duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Uniform buffer struct — must match spectrogram.wgsl Params exactly
|
||||||
|
#[repr(C)]
|
||||||
|
#[derive(Debug, Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
||||||
|
pub struct SpectrogramParams {
|
||||||
|
pub clip_rect: [f32; 4], // 16 bytes @ offset 0
|
||||||
|
pub viewport_start_time: f32, // 4 bytes @ offset 16
|
||||||
|
pub pixels_per_second: f32, // 4 bytes @ offset 20
|
||||||
|
pub audio_duration: f32, // 4 bytes @ offset 24
|
||||||
|
pub sample_rate: f32, // 4 bytes @ offset 28
|
||||||
|
pub clip_start_time: f32, // 4 bytes @ offset 32
|
||||||
|
pub trim_start: f32, // 4 bytes @ offset 36
|
||||||
|
pub time_bins: f32, // 4 bytes @ offset 40
|
||||||
|
pub freq_bins: f32, // 4 bytes @ offset 44
|
||||||
|
pub hop_size: f32, // 4 bytes @ offset 48
|
||||||
|
pub fft_size: f32, // 4 bytes @ offset 52
|
||||||
|
pub scroll_y: f32, // 4 bytes @ offset 56
|
||||||
|
pub note_height: f32, // 4 bytes @ offset 60
|
||||||
|
pub screen_size: [f32; 2], // 8 bytes @ offset 64
|
||||||
|
pub min_note: f32, // 4 bytes @ offset 72
|
||||||
|
pub max_note: f32, // 4 bytes @ offset 76
|
||||||
|
pub gamma: f32, // 4 bytes @ offset 80
|
||||||
|
pub _pad: [f32; 3], // 12 bytes @ offset 84 (pad to 96 for WGSL struct alignment)
|
||||||
|
}
|
||||||
|
// Total: 96 bytes (multiple of 16 for vec4 alignment)
|
||||||
|
|
||||||
|
/// Data for a pending spectrogram texture upload
|
||||||
|
pub struct SpectrogramUpload {
|
||||||
|
pub magnitudes: Vec<f32>,
|
||||||
|
pub time_bins: u32,
|
||||||
|
pub freq_bins: u32,
|
||||||
|
pub sample_rate: u32,
|
||||||
|
pub hop_size: u32,
|
||||||
|
pub fft_size: u32,
|
||||||
|
pub duration: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Per-frame callback for rendering one spectrogram instance
|
||||||
|
pub struct SpectrogramCallback {
|
||||||
|
pub pool_index: usize,
|
||||||
|
pub params: SpectrogramParams,
|
||||||
|
pub target_format: wgpu::TextureFormat,
|
||||||
|
pub pending_upload: Option<SpectrogramUpload>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SpectrogramGpuResources {
|
||||||
|
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
|
||||||
|
// Shader
|
||||||
|
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
|
||||||
|
label: Some("spectrogram_render_shader"),
|
||||||
|
source: wgpu::ShaderSource::Wgsl(
|
||||||
|
include_str!("panes/shaders/spectrogram.wgsl").into(),
|
||||||
|
),
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bind group layout: texture + sampler + uniforms
|
||||||
|
let render_bind_group_layout =
|
||||||
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
||||||
|
label: Some("spectrogram_render_bgl"),
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 0,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Texture {
|
||||||
|
sample_type: wgpu::TextureSampleType::Float { filterable: true },
|
||||||
|
view_dimension: wgpu::TextureViewDimension::D2,
|
||||||
|
multisampled: false,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 1,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
wgpu::BindGroupLayoutEntry {
|
||||||
|
binding: 2,
|
||||||
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
||||||
|
ty: wgpu::BindingType::Buffer {
|
||||||
|
ty: wgpu::BufferBindingType::Uniform,
|
||||||
|
has_dynamic_offset: false,
|
||||||
|
min_binding_size: None,
|
||||||
|
},
|
||||||
|
count: None,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Render pipeline
|
||||||
|
let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
|
||||||
|
label: Some("spectrogram_pipeline_layout"),
|
||||||
|
bind_group_layouts: &[&render_bind_group_layout],
|
||||||
|
push_constant_ranges: &[],
|
||||||
|
});
|
||||||
|
|
||||||
|
let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
|
||||||
|
label: Some("spectrogram_render_pipeline"),
|
||||||
|
layout: Some(&pipeline_layout),
|
||||||
|
vertex: wgpu::VertexState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("vs_main"),
|
||||||
|
buffers: &[],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
},
|
||||||
|
fragment: Some(wgpu::FragmentState {
|
||||||
|
module: &shader,
|
||||||
|
entry_point: Some("fs_main"),
|
||||||
|
targets: &[Some(wgpu::ColorTargetState {
|
||||||
|
format: target_format,
|
||||||
|
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
|
||||||
|
write_mask: wgpu::ColorWrites::ALL,
|
||||||
|
})],
|
||||||
|
compilation_options: Default::default(),
|
||||||
|
}),
|
||||||
|
primitive: wgpu::PrimitiveState {
|
||||||
|
topology: wgpu::PrimitiveTopology::TriangleList,
|
||||||
|
..Default::default()
|
||||||
|
},
|
||||||
|
depth_stencil: None,
|
||||||
|
multisample: wgpu::MultisampleState::default(),
|
||||||
|
multiview: None,
|
||||||
|
cache: None,
|
||||||
|
});
|
||||||
|
|
||||||
|
// Bilinear sampler for smooth frequency interpolation
|
||||||
|
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
|
||||||
|
label: Some("spectrogram_sampler"),
|
||||||
|
mag_filter: wgpu::FilterMode::Linear,
|
||||||
|
min_filter: wgpu::FilterMode::Linear,
|
||||||
|
mipmap_filter: wgpu::FilterMode::Nearest,
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
Self {
|
||||||
|
entries: HashMap::new(),
|
||||||
|
render_pipeline,
|
||||||
|
render_bind_group_layout,
|
||||||
|
sampler,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Upload pre-computed spectrogram magnitude data as a GPU texture
|
||||||
|
pub fn upload_spectrogram(
|
||||||
|
&mut self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
pool_index: usize,
|
||||||
|
upload: &SpectrogramUpload,
|
||||||
|
) {
|
||||||
|
// Remove old entry
|
||||||
|
self.entries.remove(&pool_index);
|
||||||
|
|
||||||
|
if upload.time_bins == 0 || upload.freq_bins == 0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Data layout: magnitudes[t * freq_bins + f] — each row is one time slice
|
||||||
|
// with freq_bins values. So texture width = freq_bins, height = time_bins.
|
||||||
|
// R8Unorm is filterable (unlike R32Float) for bilinear interpolation.
|
||||||
|
let tex_width = upload.freq_bins;
|
||||||
|
let tex_height = upload.time_bins;
|
||||||
|
|
||||||
|
let texture = device.create_texture(&wgpu::TextureDescriptor {
|
||||||
|
label: Some(&format!("spectrogram_{}", pool_index)),
|
||||||
|
size: wgpu::Extent3d {
|
||||||
|
width: tex_width,
|
||||||
|
height: tex_height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
mip_level_count: 1,
|
||||||
|
sample_count: 1,
|
||||||
|
dimension: wgpu::TextureDimension::D2,
|
||||||
|
format: wgpu::TextureFormat::R8Unorm,
|
||||||
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
||||||
|
view_formats: &[],
|
||||||
|
});
|
||||||
|
|
||||||
|
// Convert f32 magnitudes to u8 for R8Unorm, with row padding for alignment.
|
||||||
|
// wgpu requires bytes_per_row to be a multiple of COPY_BYTES_PER_ROW_ALIGNMENT (256).
|
||||||
|
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
|
||||||
|
let unpadded_row = tex_width; // 1 byte per texel for R8Unorm
|
||||||
|
let padded_row = (unpadded_row + align - 1) / align * align;
|
||||||
|
|
||||||
|
let mut texel_data = vec![0u8; padded_row as usize * tex_height as usize];
|
||||||
|
for row in 0..tex_height as usize {
|
||||||
|
let src_offset = row * tex_width as usize;
|
||||||
|
let dst_offset = row * padded_row as usize;
|
||||||
|
for col in 0..tex_width as usize {
|
||||||
|
let m = upload.magnitudes[src_offset + col];
|
||||||
|
texel_data[dst_offset + col] = (m.clamp(0.0, 1.0) * 255.0) as u8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Upload magnitude data
|
||||||
|
queue.write_texture(
|
||||||
|
wgpu::TexelCopyTextureInfo {
|
||||||
|
texture: &texture,
|
||||||
|
mip_level: 0,
|
||||||
|
origin: wgpu::Origin3d::ZERO,
|
||||||
|
aspect: wgpu::TextureAspect::All,
|
||||||
|
},
|
||||||
|
&texel_data,
|
||||||
|
wgpu::TexelCopyBufferLayout {
|
||||||
|
offset: 0,
|
||||||
|
bytes_per_row: Some(padded_row),
|
||||||
|
rows_per_image: Some(tex_height),
|
||||||
|
},
|
||||||
|
wgpu::Extent3d {
|
||||||
|
width: tex_width,
|
||||||
|
height: tex_height,
|
||||||
|
depth_or_array_layers: 1,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
let texture_view = texture.create_view(&wgpu::TextureViewDescriptor {
|
||||||
|
label: Some(&format!("spectrogram_{}_view", pool_index)),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
|
||||||
|
label: Some(&format!("spectrogram_{}_uniforms", pool_index)),
|
||||||
|
size: std::mem::size_of::<SpectrogramParams>() as u64,
|
||||||
|
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
|
||||||
|
mapped_at_creation: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
let render_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
||||||
|
label: Some(&format!("spectrogram_{}_bg", pool_index)),
|
||||||
|
layout: &self.render_bind_group_layout,
|
||||||
|
entries: &[
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 0,
|
||||||
|
resource: wgpu::BindingResource::TextureView(&texture_view),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 1,
|
||||||
|
resource: wgpu::BindingResource::Sampler(&self.sampler),
|
||||||
|
},
|
||||||
|
wgpu::BindGroupEntry {
|
||||||
|
binding: 2,
|
||||||
|
resource: uniform_buffer.as_entire_binding(),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
|
self.entries.insert(
|
||||||
|
pool_index,
|
||||||
|
SpectrogramGpuEntry {
|
||||||
|
texture,
|
||||||
|
texture_view,
|
||||||
|
render_bind_group,
|
||||||
|
uniform_buffer,
|
||||||
|
time_bins: upload.time_bins,
|
||||||
|
freq_bins: upload.freq_bins,
|
||||||
|
sample_rate: upload.sample_rate,
|
||||||
|
hop_size: upload.hop_size,
|
||||||
|
fft_size: upload.fft_size,
|
||||||
|
duration: upload.duration,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl egui_wgpu::CallbackTrait for SpectrogramCallback {
|
||||||
|
fn prepare(
|
||||||
|
&self,
|
||||||
|
device: &wgpu::Device,
|
||||||
|
queue: &wgpu::Queue,
|
||||||
|
_screen_descriptor: &egui_wgpu::ScreenDescriptor,
|
||||||
|
_egui_encoder: &mut wgpu::CommandEncoder,
|
||||||
|
resources: &mut egui_wgpu::CallbackResources,
|
||||||
|
) -> Vec<wgpu::CommandBuffer> {
|
||||||
|
// Initialize global resources on first use
|
||||||
|
if !resources.contains::<SpectrogramGpuResources>() {
|
||||||
|
resources.insert(SpectrogramGpuResources::new(device, self.target_format));
|
||||||
|
}
|
||||||
|
|
||||||
|
let gpu: &mut SpectrogramGpuResources = resources.get_mut().unwrap();
|
||||||
|
|
||||||
|
// Handle pending upload
|
||||||
|
if let Some(ref upload) = self.pending_upload {
|
||||||
|
gpu.upload_spectrogram(device, queue, self.pool_index, upload);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update uniform buffer
|
||||||
|
if let Some(entry) = gpu.entries.get(&self.pool_index) {
|
||||||
|
queue.write_buffer(
|
||||||
|
&entry.uniform_buffer,
|
||||||
|
0,
|
||||||
|
bytemuck::cast_slice(&[self.params]),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Vec::new()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn paint(
|
||||||
|
&self,
|
||||||
|
_info: eframe::egui::PaintCallbackInfo,
|
||||||
|
render_pass: &mut wgpu::RenderPass<'static>,
|
||||||
|
resources: &egui_wgpu::CallbackResources,
|
||||||
|
) {
|
||||||
|
let gpu: &SpectrogramGpuResources = match resources.get() {
|
||||||
|
Some(r) => r,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
let entry = match gpu.entries.get(&self.pool_index) {
|
||||||
|
Some(e) => e,
|
||||||
|
None => return,
|
||||||
|
};
|
||||||
|
|
||||||
|
render_pass.set_pipeline(&gpu.render_pipeline);
|
||||||
|
render_pass.set_bind_group(0, &entry.render_bind_group, &[]);
|
||||||
|
render_pass.draw(0..3, 0..1); // Fullscreen triangle
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue