onedrop_renderer/renderer.rs
1//! Main renderer assembling the per-frame Milkdrop pipeline.
2//!
3//! Current pipeline (MVP):
4//! 1. **Warp pass** — sample `prev_texture` at the per-vertex warped UV
5//! (computed CPU-side by [`onedrop-engine::warp_eval`]) and write to
6//! `render_texture`, applying decay + feedback filters in the fragment
7//! shader.
8//! 2. **Copy-to-prev** — `render_texture` → `prev_texture` so next frame's
9//! warp pass can sample the previous frame's content.
10//! 3. **Comp pass** — sample `render_texture`, apply display-only filters
11//! (currently just `gamma_adj`; user comp shaders later in sprint B2),
12//! write to `final_texture`. Filters here intentionally don't feed back
13//! into the warp loop.
14//!
15//! `final_texture` is what `render_texture()` exposes to the GUI/CLI.
16//!
17//! Future passes (waveforms, shapes, motion vectors, sprites, blur)
18//! compose on top of this foundation.
19
20use onedrop_codegen::{ShaderCompiler, ShaderUniforms, USER_TEXTURE_SLOTS};
21use onedrop_hlsl::{TextureBindingPlan, UserSamplerRef, scan_user_samplers};
22
23use crate::config::{RenderConfig, RenderState};
24use crate::custom_shape::{CustomShapeBatch, CustomShapeInstance};
25use crate::custom_wave::{CustomWaveBatch, CustomWaveVertex};
26use crate::error::Result;
27use crate::final_blend::FinalBlendPipeline;
28use crate::gpu_context::GpuContext;
29use crate::render_chain::RenderChain;
30use crate::sprite_pipeline::{SpriteFrame, SpritePipeline, SpritePool};
31use crate::text_pipeline::{TextFrame, TextPipeline};
32use crate::texture_pool::TexturePool;
33use crate::warp_mesh::WarpMesh;
34use crate::warp_pipeline::WarpVertex;
35
36use std::collections::hash_map::DefaultHasher;
37use std::hash::{Hash, Hasher};
38
39/// Fallback warp mesh resolution. Used by call-sites that bypass
40/// `RenderConfig` (tests, benches). The real default comes from
41/// `RenderConfig::mesh_cols`/`mesh_rows`, which `MeshQuality::Medium`
42/// resolves to 48×36. MD2 ships 32×24 → 192×96.
43pub const DEFAULT_MESH_COLS: u32 = 48;
44pub const DEFAULT_MESH_ROWS: u32 = 36;
45
46/// Main Milkdrop renderer.
47///
48/// Orchestrates one or two [`RenderChain`]s plus the final-blend pipeline
49/// that composites their `final_texture` outputs into a single display
50/// target. During a preset transition, the secondary chain is `Some` and
51/// holds the outgoing preset's full pipeline; outside transitions it is
52/// `None` and the blend pass passes the primary chain straight through.
53pub struct MilkRenderer {
54 gpu: GpuContext,
55 /// Primary rendering chain — the currently active preset.
56 chain: RenderChain,
57 /// Secondary chain, active only during a preset transition. Holds the
58 /// **outgoing** preset's state so it can keep evolving while the new
59 /// preset takes over the primary slot.
60 secondary: Option<RenderChain>,
61 /// Final blend pass: lerps primary↔secondary `final_texture` into the
62 /// display target by the transition progress. Passthrough of primary
63 /// when no secondary chain is active.
64 final_blend: FinalBlendPipeline,
65 warp_mesh: WarpMesh,
66 /// Primary chain's per-frame state (motion, wave, decay, gamma, audio,
67 /// q1..q32, …). Pushed by the engine via `update_state`.
68 state: RenderState,
69 /// Secondary chain's per-frame state. Populated by the engine during
70 /// preset transitions; `None` outside transitions. Each chain has its own
71 /// `gamma_adj` / `echo_alpha` / audio / time — they advance independently
72 /// while the blend pass crossfades their `final_texture` outputs.
73 secondary_state: Option<RenderState>,
74 /// Caches translated/validated user comp shaders by HLSL source. Letting
75 /// the renderer own it keeps the codegen surface inside the crate that
76 /// already depends on it; the engine just hands HLSL bytes over.
77 shader_compiler: ShaderCompiler,
78 /// User textures loaded from disk. Empty by default; the engine layer
79 /// (GUI / CLI) populates it via `set_texture_pool`. The fallback 1×1
80 /// white inside the pool is bound into any unfilled user-texture slot
81 /// so the comp pass produces correct output for presets whose textures
82 /// we don't have on disk.
83 texture_pool: TexturePool,
84 /// Sprite GPU pipeline (`MILK_IMG.INI` overlay). Shared by both
85 /// chains during transitions — the pipeline is stateless past
86 /// init, and the sprite frames the engine pushes are global, not
87 /// per-preset.
88 sprite_pipeline: SpritePipeline,
89 /// Sprite texture pool: one texture per loaded `MILK_IMG.INI`
90 /// entry, indexed by [`SpriteFrame::texture_index`].
91 sprite_pool: SpritePool,
92 /// Sprite render instances the engine pushed via
93 /// [`Self::update_sprites`] for the next frame. Cleared on every
94 /// `render` so a stale list doesn't outlive the engine tick.
95 sprite_frames: Vec<SpriteFrame>,
96 /// Text overlay (`MILK_MSG.INI` + MPRIS auto-title) pipeline.
97 /// Renders on top of the final-blend output so messages never
98 /// participate in the warp feedback loop.
99 text_pipeline: TextPipeline,
100 /// Text frames the engine pushed via [`Self::update_text_frames`]
101 /// for the next render call.
102 text_frames: Vec<TextFrame>,
103}
104
105impl MilkRenderer {
106 /// Create a new renderer that owns its GPU device.
107 pub async fn new(config: RenderConfig) -> Result<Self> {
108 let gpu = GpuContext::new(config).await?;
109 Self::from_gpu_context(gpu)
110 }
111
112 /// Create a renderer atop a shared GPU context.
113 pub fn from_gpu_context(gpu: GpuContext) -> Result<Self> {
114 let aspect = gpu.aspect_ratio();
115 let cols = gpu.config.mesh_cols.max(2);
116 let rows = gpu.config.mesh_rows.max(2);
117 let warp_mesh = WarpMesh::new(cols, rows, aspect);
118 let chain = RenderChain::new(&gpu, &warp_mesh)?;
119 let final_blend = FinalBlendPipeline::new(
120 &gpu.device,
121 &gpu.config,
122 &chain.textures().final_texture_view,
123 )?;
124 let texture_pool = TexturePool::new(&gpu.device, &gpu.queue);
125 let sprite_pipeline = SpritePipeline::new(&gpu.device, gpu.config.texture_format.to_wgpu());
126 let sprite_pool = SpritePool::new(&gpu.device, &gpu.queue);
127 let text_pipeline =
128 TextPipeline::new(&gpu.device, &gpu.queue, gpu.config.texture_format.to_wgpu());
129 Ok(Self {
130 gpu,
131 chain,
132 secondary: None,
133 final_blend,
134 warp_mesh,
135 state: RenderState::default(),
136 secondary_state: None,
137 shader_compiler: ShaderCompiler::new(),
138 texture_pool,
139 sprite_pipeline,
140 sprite_pool,
141 sprite_frames: Vec::new(),
142 text_pipeline,
143 text_frames: Vec::new(),
144 })
145 }
146
147 /// Swap in a populated [`TexturePool`]. The engine layer (GUI / CLI)
148 /// calls this after constructing the renderer if the user has a
149 /// texture directory configured. Pool population is best-effort
150 /// (missing dirs are silently empty); see [`TexturePool::from_dirs`].
151 pub fn set_texture_pool(&mut self, pool: TexturePool) {
152 self.texture_pool = pool;
153 }
154
155 /// Borrow the texture pool. Used by the GUI's HUD to display "X user
156 /// textures loaded" and by tests to assert pool wiring.
157 pub fn texture_pool(&self) -> &TexturePool {
158 &self.texture_pool
159 }
160
161 /// Borrow the underlying GPU device. Exposed so the engine layer can
162 /// build a [`TexturePool`] before swapping it into the renderer.
163 pub fn gpu_device(&self) -> std::sync::Arc<wgpu::Device> {
164 self.gpu.device.clone()
165 }
166
167 /// Borrow the underlying GPU queue. Mirror of `gpu_device` for the
168 /// pool-construction path.
169 pub fn gpu_queue(&self) -> std::sync::Arc<wgpu::Queue> {
170 self.gpu.queue.clone()
171 }
172
173 /// Format of the display texture that [`read_render_texture`] returns.
174 /// Offline callers (CLI render-to-PNG, snapshot tests) use this to
175 /// decide whether to swizzle BGRA → RGBA before encoding.
176 pub fn render_format(&self) -> wgpu::TextureFormat {
177 self.gpu.config.texture_format.to_wgpu()
178 }
179
180 /// Render-target width × height in pixels. Mirrors `RenderConfig`.
181 pub fn render_size(&self) -> (u32, u32) {
182 (self.gpu.config.width, self.gpu.config.height)
183 }
184
185 /// Install (or remove) the user comp shader for the active preset.
186 ///
187 /// Pass `Some(hlsl)` from the preset's `comp_shader` block to swap the
188 /// comp pass into the user-authored pipeline. Pass `None` (or an empty
189 /// `&str` after trimming) to revert to the engine's gamma-only default
190 /// — the fallback for presets without a custom composite or for
191 /// preset loads where the translation/validation chain failed.
192 ///
193 /// Return value:
194 /// - `Ok(true)` — user shader compiled and is now active.
195 /// - `Ok(false)` — translation or validation failed; the comp pipeline
196 /// is now on the default fallback. **Not** an error from the
197 /// renderer's perspective; the caller can surface a log line.
198 /// - `Err(_)` — the wgpu device rejected the validated shader. Rare;
199 /// indicates either a wgpu/naga version skew or a
200 /// pipeline-construction bug.
201 pub fn set_user_comp_shader(&mut self, hlsl: Option<&str>) -> Result<bool> {
202 self.set_user_comp_shader_for_preset(hlsl, "")
203 }
204
205 /// Same as [`Self::set_user_comp_shader`], but lets the engine pass a
206 /// `preset_id` that seeds the deterministic `sampler_rand0X` pick for
207 /// this preset. Use the preset filename (`"Geiss - Aerial.milk"`) or
208 /// any string stable for the preset's lifetime — same string → same
209 /// rand selection.
210 pub fn set_user_comp_shader_for_preset(
211 &mut self,
212 hlsl: Option<&str>,
213 preset_id: &str,
214 ) -> Result<bool> {
215 let hlsl = match hlsl.map(str::trim).filter(|s| !s.is_empty()) {
216 Some(s) => s,
217 None => {
218 self.chain
219 .comp_pipeline_mut()
220 .reset_to_default(&self.gpu.device);
221 return Ok(true);
222 }
223 };
224
225 // Scan for `sampler sampler_X;` declarations, resolve each through
226 // the texture pool (literal names + `sampler_rand0X`), and build the
227 // per-preset TextureBindingPlan before translating.
228 let user_refs = scan_user_samplers(hlsl);
229 let (plan, slot_views) = self.build_texture_plan(&user_refs, preset_id);
230
231 match self
232 .shader_compiler
233 .compile_user_comp_shader_with_plan(hlsl, &plan)
234 {
235 Ok(compiled) => {
236 self.chain.comp_pipeline_mut().set_user_shader_with_plan(
237 &self.gpu.device,
238 &compiled.source,
239 slot_views,
240 )?;
241 Ok(true)
242 }
243 Err(e) => {
244 log::warn!("user comp shader failed to compile, falling back to default: {e}");
245 self.chain
246 .comp_pipeline_mut()
247 .reset_to_default(&self.gpu.device);
248 Ok(false)
249 }
250 }
251 }
252
253 /// Resolve a list of `sampler sampler_X;` references against the
254 /// texture pool into a [`TextureBindingPlan`] + a parallel array of
255 /// `wgpu::TextureView` slot bindings for the comp pipeline.
256 ///
257 /// - Literal logical names (`"clouds"`, `"worms"`) are looked up by
258 /// exact pool match.
259 /// - `sampler_rand0X` (with optional name-prefix suffix) is resolved
260 /// to a deterministic-per-preset pick from the pool, optionally
261 /// filtered by the suffix.
262 /// - Unresolvable names fall back to the 1×1 white texture.
263 fn build_texture_plan(
264 &self,
265 refs: &[UserSamplerRef],
266 preset_id: &str,
267 ) -> (
268 TextureBindingPlan,
269 [Option<wgpu::TextureView>; USER_TEXTURE_SLOTS],
270 ) {
271 let mut plan = TextureBindingPlan::empty();
272 let mut views: [Option<wgpu::TextureView>; USER_TEXTURE_SLOTS] = Default::default();
273
274 // Group references by logical name so multi-alias references
275 // (`sampler_clouds` + `sampler_fw_clouds`) collapse to one slot.
276 let mut by_logical: std::collections::BTreeMap<&str, Vec<&UserSamplerRef>> =
277 std::collections::BTreeMap::new();
278 for r in refs {
279 by_logical
280 .entry(r.logical_name.as_str())
281 .or_default()
282 .push(r);
283 }
284
285 for (logical, aliases) in by_logical {
286 let resolved = self.resolve_logical_texture(logical, preset_id);
287 let pool_name = resolved.as_ref().map(|t| t.name.clone());
288 let texsize = resolved
289 .as_ref()
290 .map(|t| t.texsize())
291 .unwrap_or([1.0, 1.0, 1.0, 1.0]);
292
293 let alias_specs: Vec<(String, &'static str)> = aliases
294 .iter()
295 .map(|a| (a.full_name.clone(), a.sampler_kind))
296 .collect();
297 let Some(slot) = plan.add_slot(pool_name, texsize, &alias_specs) else {
298 continue; // cap exceeded — logged by add_slot
299 };
300
301 views[slot] = resolved.map(|t| t.view.clone());
302 }
303
304 (plan, views)
305 }
306
307 /// Resolve one logical texture name (e.g. `"clouds"`, `"rand02_smalltiled"`)
308 /// to a concrete [`crate::UserTexture`] in the pool. `sampler_rand0X[_prefix]`
309 /// patterns become deterministic picks seeded by the preset id; literal
310 /// names are exact pool lookups.
311 fn resolve_logical_texture(
312 &self,
313 logical: &str,
314 preset_id: &str,
315 ) -> Option<std::sync::Arc<crate::UserTexture>> {
316 // `rand00..rand15` — optionally followed by `_<prefix>` to filter
317 // the pool's candidate set.
318 if let Some(rest) = logical.strip_prefix("rand") {
319 // Two-digit slot suffix; treat any leading digits as the slot
320 // index even when fewer than 2 are written.
321 let mut slot_str = String::new();
322 let mut prefix_part = "";
323 for (i, c) in rest.char_indices() {
324 if c.is_ascii_digit() {
325 slot_str.push(c);
326 } else {
327 prefix_part = &rest[i..];
328 break;
329 }
330 }
331 if !slot_str.is_empty() {
332 let slot_index: u64 = slot_str.parse().unwrap_or(0);
333 let prefix = prefix_part.trim_start_matches('_');
334 let mut hasher = DefaultHasher::new();
335 preset_id.hash(&mut hasher);
336 slot_index.hash(&mut hasher);
337 let seed = hasher.finish();
338 return self
339 .texture_pool
340 .pick_rand(
341 seed,
342 if prefix.is_empty() {
343 None
344 } else {
345 Some(prefix)
346 },
347 )
348 .cloned();
349 }
350 }
351 self.texture_pool.get(logical).cloned()
352 }
353
354 /// Whether the comp pass is currently driven by a user-authored
355 /// shader (vs. the engine's gamma-only fallback). Surfaced for tests
356 /// and the GUI's debug overlay.
357 pub fn has_user_comp_shader(&self) -> bool {
358 self.chain.comp_pipeline().has_user_shader()
359 }
360
361 /// Swap in a user-translated warp shader. Mirror of
362 /// [`Self::set_user_comp_shader_for_preset`] but for the warp pass.
363 /// The translation goes through the same `TextureBindingPlan`
364 /// scan + texture-pool resolution as the comp side.
365 pub fn set_user_warp_shader(&mut self, hlsl: Option<&str>) -> Result<bool> {
366 self.set_user_warp_shader_for_preset(hlsl, "")
367 }
368
369 /// Same as [`Self::set_user_warp_shader`], with a `preset_id` that
370 /// seeds the deterministic `sampler_rand0X` picks (filename works).
371 pub fn set_user_warp_shader_for_preset(
372 &mut self,
373 hlsl: Option<&str>,
374 preset_id: &str,
375 ) -> Result<bool> {
376 let hlsl = match hlsl.map(str::trim).filter(|s| !s.is_empty()) {
377 Some(s) => s,
378 None => {
379 self.chain.warp_pipeline_mut().reset_to_default();
380 return Ok(true);
381 }
382 };
383
384 let user_refs = scan_user_samplers(hlsl);
385 let (plan, slot_views) = self.build_texture_plan(&user_refs, preset_id);
386
387 match self
388 .shader_compiler
389 .compile_user_warp_shader_with_plan(hlsl, &plan)
390 {
391 Ok(compiled) => {
392 // Clone every view we need into owned handles *before*
393 // borrowing `self.chain` mutably — the comp pipeline's
394 // aux view ref would otherwise hold an immutable borrow
395 // of `self.chain` for the whole call. Warp uses the
396 // *previous* frame's blur pyramid (this frame's blur
397 // doesn't exist yet at warp time); the noise pack is
398 // shared with the comp pipeline.
399 let textures = self.chain.textures();
400 let prev_view = textures.prev_texture_view.clone();
401 let prev_blur1 = textures.prev_blur1_texture_view.clone();
402 let prev_blur2 = textures.prev_blur2_texture_view.clone();
403 let prev_blur3 = textures.prev_blur3_texture_view.clone();
404 let aux_owned = {
405 let aux = self.chain.comp_pipeline().comp_aux_views();
406 [
407 aux.noise_lq.clone(),
408 aux.noise_mq.clone(),
409 aux.noise_hq.clone(),
410 aux.noisevol_lq.clone(),
411 aux.noisevol_hq.clone(),
412 ]
413 };
414 let aux = crate::user_warp_pipeline::WarpAuxViews {
415 prev_blur1: &prev_blur1,
416 prev_blur2: &prev_blur2,
417 prev_blur3: &prev_blur3,
418 noise_lq: &aux_owned[0],
419 noise_mq: &aux_owned[1],
420 noise_hq: &aux_owned[2],
421 noisevol_lq: &aux_owned[3],
422 noisevol_hq: &aux_owned[4],
423 };
424 self.chain.warp_pipeline_mut().set_user_shader_with_plan(
425 &self.gpu.device,
426 &self.gpu.queue,
427 &compiled.source,
428 &prev_view,
429 &aux,
430 slot_views,
431 )?;
432 Ok(true)
433 }
434 Err(e) => {
435 log::warn!("user warp shader failed to compile, falling back to default: {e}");
436 self.chain.warp_pipeline_mut().reset_to_default();
437 Ok(false)
438 }
439 }
440 }
441
442 /// Whether the warp pass is currently driven by a user-authored
443 /// shader.
444 pub fn has_user_warp_shader(&self) -> bool {
445 self.chain.warp_pipeline().has_user_shader()
446 }
447
448 /// Update render state.
449 pub fn update_state(&mut self, state: RenderState) {
450 self.state = state;
451 }
452
453 /// Borrow the warp mesh for per-vertex equation evaluation.
454 pub fn warp_mesh(&self) -> &WarpMesh {
455 &self.warp_mesh
456 }
457
458 /// Rebuild the warp mesh at a different `cols × rows`. Reallocates
459 /// the warp pipeline's vertex/index buffers on both the primary and
460 /// (if active) secondary chains. The per-vertex equation executor in
461 /// the engine reads `warp_mesh()` every frame and allocates its
462 /// output vector fresh, so it adapts without further plumbing.
463 ///
464 /// `cols` and `rows` are clamped to `>= 2` to match `WarpMesh::new`.
465 /// MD2 caps `nMeshSize` at 192×96; we mirror that ceiling so a
466 /// runaway slider can't allocate gigabytes of index buffer.
467 pub fn set_mesh_size(&mut self, cols: u32, rows: u32) {
468 let cols = cols.clamp(2, 192);
469 let rows = rows.clamp(2, 96);
470 if cols == self.warp_mesh.cols && rows == self.warp_mesh.rows {
471 return;
472 }
473 let aspect = self.gpu.aspect_ratio();
474 self.warp_mesh = WarpMesh::new(cols, rows, aspect);
475 self.chain
476 .rebuild_warp_mesh(&self.gpu.device, &self.warp_mesh);
477 if let Some(secondary) = self.secondary.as_mut() {
478 secondary.rebuild_warp_mesh(&self.gpu.device, &self.warp_mesh);
479 }
480 self.gpu.config.mesh_cols = cols;
481 self.gpu.config.mesh_rows = rows;
482 }
483
484 /// Current warp mesh size as `(cols, rows)`. Used by the GUI's
485 /// options panel to seed its widget state.
486 pub fn mesh_size(&self) -> (u32, u32) {
487 (self.warp_mesh.cols, self.warp_mesh.rows)
488 }
489
490 /// Push freshly computed warp UVs to the GPU. Also caches them on
491 /// the chain so the motion-vector pass can build anisotropic
492 /// segments aligned with the local warp displacement.
493 pub fn update_warp_vertices(&mut self, vertices: &[WarpVertex]) {
494 let cols = self.warp_mesh.cols;
495 let rows = self.warp_mesh.rows;
496 self.chain
497 .update_warp_vertices(&self.gpu.queue, cols, rows, vertices);
498 }
499
500 /// Push a fresh mono audio sample window for the waveform pass.
501 ///
502 /// The renderer applies `wave_smoothing` (single-pole IIR) on the
503 /// CPU side before upload — pass the raw samples; smoothing follows
504 /// `RenderState::wave.smoothing`. The buffer is clamped to
505 /// `NUM_WAVE_SAMPLES`; shorter inputs are zero-padded.
506 ///
507 /// `instantaneous_volume` is the frame-mean abs value used by
508 /// `b_mod_wave_alpha_by_volume` to scale the wave alpha — pass
509 /// `(|bass| + |mid| + |treb|) / 3` or any equivalent volume proxy
510 /// in `[0, 1]`.
511 pub fn update_waveform_samples(&mut self, samples: &[f32], instantaneous_volume: f32) {
512 self.chain.update_waveform_samples(
513 &self.gpu.queue,
514 samples,
515 instantaneous_volume,
516 self.state.wave,
517 );
518 }
519
520 /// Stereo variant of [`Self::update_waveform_samples`] — uploads
521 /// distinct left and right channels so the waveform pass can lay
522 /// them on the upper and lower screen halves when
523 /// `WaveParams::split_lr` is `true`.
524 pub fn update_waveform_samples_lr(
525 &mut self,
526 left: &[f32],
527 right: &[f32],
528 instantaneous_volume: f32,
529 ) {
530 self.chain.update_waveform_samples_lr(
531 &self.gpu.queue,
532 left,
533 right,
534 instantaneous_volume,
535 self.state.wave,
536 );
537 }
538
539 /// Borrow the current waveform sample buffer. Mostly useful for
540 /// tests that want to assert the smoothing applied or the latest
541 /// upload's length.
542 pub fn wave_samples(&self) -> &[f32] {
543 self.chain.wave_samples()
544 }
545
546 /// Upload the per-frame custom-wave vertex stream.
547 pub fn update_custom_waves(
548 &mut self,
549 vertices: &[CustomWaveVertex],
550 batches: &[CustomWaveBatch],
551 ) {
552 self.chain
553 .update_custom_waves(&self.gpu.queue, vertices, batches);
554 }
555
556 pub fn custom_wave_vertex_count(&self) -> u32 {
557 self.chain.custom_wave_vertex_count()
558 }
559
560 pub fn custom_wave_batch_count(&self) -> usize {
561 self.chain.custom_wave_batch_count()
562 }
563
564 /// Upload the per-frame custom-shape instance stream.
565 pub fn update_custom_shapes(
566 &mut self,
567 instances: &[CustomShapeInstance],
568 batches: &[CustomShapeBatch],
569 ) {
570 let aspect = self.gpu.aspect_ratio();
571 self.chain
572 .update_custom_shapes(&self.gpu.queue, instances, batches, aspect);
573 }
574
575 pub fn custom_shape_instance_count(&self) -> u32 {
576 self.chain.custom_shape_instance_count()
577 }
578
579 pub fn custom_shape_batch_count(&self) -> usize {
580 self.chain.custom_shape_batch_count()
581 }
582
583 pub fn border_batch_count(&self) -> usize {
584 self.chain.border_batch_count()
585 }
586
587 pub fn motion_vector_segment_count(&self) -> u32 {
588 self.chain.motion_vector_segment_count()
589 }
590
591 /// Push the sprite frame list for the next [`Self::render`] call.
592 /// The engine ticks its `SpriteManager` and feeds the resulting
593 /// [`SpriteFrame`] list here. Empty slice = no sprite pass recorded
594 /// next frame.
595 pub fn update_sprites(&mut self, frames: &[SpriteFrame]) {
596 self.sprite_frames.clear();
597 self.sprite_frames.extend_from_slice(frames);
598 }
599
600 /// Number of sprite frames queued for the next render call.
601 /// Exposed for tests + the GUI's HUD.
602 pub fn sprite_frame_count(&self) -> usize {
603 self.sprite_frames.len()
604 }
605
606 /// Reload sprite textures from a directory, in the order of the
607 /// parsed `MILK_IMG.INI` entries. `def_imgs` is the parallel list
608 /// of `img=` filenames the engine pulled from the parser. Missing
609 /// files are silently substituted with a 1×1 transparent fallback;
610 /// the engine's `texture_index` mapping stays dense.
611 pub fn load_sprite_defs(&mut self, dir: &std::path::Path, def_imgs: &[String]) {
612 self.sprite_pool
613 .load_from_defs(&self.gpu.device, &self.gpu.queue, dir, def_imgs);
614 }
615
616 /// Borrow the sprite pool. Used by tests + GUI HUD.
617 pub fn sprite_pool(&self) -> &SpritePool {
618 &self.sprite_pool
619 }
620
621 /// Push the text-overlay frame list for the next [`Self::render`]
622 /// call. The engine ticks its `MessageManager` (+ optional MPRIS
623 /// auto-title) and forwards the resulting [`TextFrame`] list.
624 /// Empty slice = no text pass.
625 pub fn update_text_frames(&mut self, frames: &[TextFrame]) {
626 self.text_frames.clear();
627 self.text_frames.extend_from_slice(frames);
628 }
629
630 /// Number of text frames queued for next render.
631 pub fn text_frame_count(&self) -> usize {
632 self.text_frames.len()
633 }
634
635 /// Number of glyphs currently cached in the text atlas. Test
636 /// surface — grows on the first frame each glyph is requested.
637 pub fn text_atlas_glyph_count(&self) -> usize {
638 self.text_pipeline.atlas().glyph_count()
639 }
640
641 /// Render a frame: record every per-chain pass (primary + optional
642 /// secondary) plus the final blend, copy warp outputs into their
643 /// feedback buffers, and submit. When no secondary chain is active the
644 /// blend pass acts as a passthrough of the primary chain's
645 /// `final_texture`.
646 pub fn render(&mut self) -> Result<()> {
647 let primary_uniforms = build_shader_uniforms_from(&self.state, &self.gpu);
648
649 let mut encoder = self
650 .gpu
651 .device
652 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
653 label: Some("MilkRenderer Encoder"),
654 });
655
656 self.chain.record_passes(
657 &self.gpu,
658 &mut encoder,
659 &self.state,
660 &primary_uniforms,
661 &self.sprite_pipeline,
662 &self.sprite_pool,
663 &self.sprite_frames,
664 );
665
666 // V.3 — drive the secondary chain with its own state (outgoing
667 // preset). Falls back to the primary state when the engine hasn't
668 // pushed a secondary state yet (transition just started).
669 // Sprites are global (not per-preset), so the secondary chain
670 // sees the *same* frames as the primary; the blend pass merges
671 // them at the comp output.
672 if let Some(secondary) = self.secondary.as_mut() {
673 let sec_state = self.secondary_state.as_ref().unwrap_or(&self.state);
674 let sec_uniforms = build_shader_uniforms_from(sec_state, &self.gpu);
675 secondary.record_passes(
676 &self.gpu,
677 &mut encoder,
678 sec_state,
679 &sec_uniforms,
680 &self.sprite_pipeline,
681 &self.sprite_pool,
682 &self.sprite_frames,
683 );
684 }
685
686 // Final blend: lerp primary↔secondary into the display texture.
687 // Passthrough of primary when `secondary` is `None`.
688 self.final_blend.record_pass(&mut encoder);
689
690 // Text overlay: draws messages on top of the blended output.
691 // Lives AFTER `final_blend` so it never feeds back through
692 // the warp loop (MD2's message overlay sits at the same
693 // stage — display-only, no smearing).
694 if !self.text_frames.is_empty() {
695 self.text_pipeline.record(
696 &mut encoder,
697 &self.gpu.queue,
698 &self.gpu.device,
699 self.final_blend.display_texture_view(),
700 &self.text_frames,
701 self.gpu.config.width,
702 self.gpu.config.height,
703 );
704 }
705
706 // Preserve each chain's warp output for next frame's feedback AFTER
707 // the comp pass has consumed `prev_texture`. The comp pass sees
708 // frame N-1's warp output for echo; gamma still stays out of the
709 // feedback loop because copy_to_prev sources `render_texture`
710 // (untouched by comp).
711 self.chain.copy_to_prev(&mut encoder, &self.gpu);
712 if let Some(secondary) = self.secondary.as_ref() {
713 secondary.copy_to_prev(&mut encoder, &self.gpu);
714 }
715
716 self.gpu.queue.submit(std::iter::once(encoder.finish()));
717
718 self.state.frame += 1;
719 if let Some(s) = self.secondary_state.as_mut() {
720 s.frame += 1;
721 }
722 Ok(())
723 }
724
725 /// Begin a preset transition: swap the current primary chain into the
726 /// secondary slot (where it keeps fading out the outgoing preset) and
727 /// allocate a fresh primary chain for the incoming preset.
728 ///
729 /// The blend pass is rebound so the **secondary** view points at the
730 /// outgoing preset (the old primary) and the **primary** view at the
731 /// new chain. `progress` starts at `0` (full secondary visible) and
732 /// the engine ramps it to `1` over the transition duration.
733 ///
734 /// Returns `Err` only if the new primary chain fails to allocate (rare
735 /// — a wgpu error).
736 pub fn start_transition(&mut self) -> Result<()> {
737 // If a transition is already in flight, drop the previous outgoing
738 // chain. The engine should normally not call this twice without
739 // letting the first transition finish; this is a safety net.
740 self.secondary = None;
741
742 let new_primary = RenderChain::new(&self.gpu, &self.warp_mesh)?;
743 let outgoing = std::mem::replace(&mut self.chain, new_primary);
744 self.install_secondary_chain(outgoing);
745 Ok(())
746 }
747
748 /// Install a caller-built secondary chain at the start of a preset
749 /// transition. The renderer's blend pass starts sourcing from it
750 /// immediately; the caller drives `set_transition_progress` each
751 /// frame until completion. Most callers want
752 /// [`Self::start_transition`] instead — this overload exists for
753 /// tests and for reclaiming a previous chain's textures.
754 pub fn install_secondary_chain(&mut self, chain: RenderChain) {
755 self.final_blend.set_inputs(
756 &self.gpu.device,
757 &self.chain.textures().final_texture_view,
758 Some(&chain.textures().final_texture_view),
759 );
760 self.final_blend.update_progress(&self.gpu.queue, 0.0, true);
761 self.secondary = Some(chain);
762 }
763
764 /// Drop the secondary chain at transition end and rebind the blend
765 /// pass to passthrough of the primary chain.
766 pub fn clear_secondary_chain(&mut self) -> Option<RenderChain> {
767 self.final_blend.set_inputs(
768 &self.gpu.device,
769 &self.chain.textures().final_texture_view,
770 None,
771 );
772 self.final_blend
773 .update_progress(&self.gpu.queue, 0.0, false);
774 self.secondary.take()
775 }
776
777 /// Push the current transition progress (in `[0, 1]`) into the blend
778 /// pass uniform. `0` = full secondary (outgoing preset); `1` = full
779 /// primary (incoming preset). Caller is expected to feed the eased
780 /// value from the engine's `TransitionManager`.
781 pub fn set_transition_progress(&self, progress: f32) {
782 self.final_blend
783 .update_progress(&self.gpu.queue, progress, self.secondary.is_some());
784 }
785
786 /// Borrow the secondary chain mutably — used by the engine to push
787 /// the outgoing preset's warp vertices / audio samples / etc. while
788 /// the transition is in flight. `None` outside transitions.
789 pub fn secondary_chain_mut(&mut self) -> Option<&mut RenderChain> {
790 self.secondary.as_mut()
791 }
792
793 /// Whether a secondary chain is currently active (a transition is in
794 /// flight).
795 pub fn is_transitioning(&self) -> bool {
796 self.secondary.is_some()
797 }
798
799 /// Get the displayable texture: the final-blend pass output that
800 /// composites primary + optional secondary chains. The GUI/CLI sample
801 /// from this for presentation.
802 ///
803 /// The name is kept for backwards compatibility with the pre-comp-pass
804 /// API (the GUI's render path imports it as
805 /// `engine.renderer().render_texture()`); renaming would be a breaking
806 /// change for consumers.
807 #[allow(clippy::misnamed_getters)]
808 pub fn render_texture(&self) -> &wgpu::Texture {
809 self.final_blend.display_texture()
810 }
811
812 /// Get the warp pass output before comp filtering. Useful for tests that
813 /// want to assert on the feedback chain content directly, bypassing
814 /// `gamma_adj` and any future display-only adjustments.
815 pub fn warp_output_texture(&self) -> &wgpu::Texture {
816 &self.chain.textures().render_texture
817 }
818
819 /// Read the current `final_texture` back as tightly-packed **RGBA8**
820 /// bytes (`width * height * 4`). Reflects what the screen shows
821 /// (post-comp), matching `render_texture()`. The byte order is
822 /// always R, G, B, A regardless of the surface format — BGRA
823 /// surfaces (the desktop default) are swizzled on the CPU before
824 /// the buffer returns so callers never have to branch on format.
825 ///
826 /// This is a synchronous CPU stall — intended for tests and offline
827 /// snapshots, not the hot rendering path. Wgpu requires the per-row
828 /// staging stride to be 256-byte aligned, so the helper copies into a
829 /// padded staging buffer and unpads it before returning.
830 pub fn read_render_texture(&self) -> Result<Vec<u8>> {
831 let width = self.gpu.config.width;
832 let height = self.gpu.config.height;
833 let bytes_per_pixel: u32 = 4;
834 let unpadded_row = width * bytes_per_pixel;
835 let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
836 let padded_row = unpadded_row.div_ceil(align) * align;
837
838 let staging = self.gpu.device.create_buffer(&wgpu::BufferDescriptor {
839 label: Some("Render Texture Readback Staging"),
840 size: (padded_row * height) as u64,
841 usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
842 mapped_at_creation: false,
843 });
844
845 let mut encoder = self
846 .gpu
847 .device
848 .create_command_encoder(&wgpu::CommandEncoderDescriptor {
849 label: Some("Readback Encoder"),
850 });
851 encoder.copy_texture_to_buffer(
852 wgpu::TexelCopyTextureInfo {
853 texture: self.final_blend.display_texture(),
854 mip_level: 0,
855 origin: wgpu::Origin3d::ZERO,
856 aspect: wgpu::TextureAspect::All,
857 },
858 wgpu::TexelCopyBufferInfo {
859 buffer: &staging,
860 layout: wgpu::TexelCopyBufferLayout {
861 offset: 0,
862 bytes_per_row: Some(padded_row),
863 rows_per_image: Some(height),
864 },
865 },
866 wgpu::Extent3d {
867 width,
868 height,
869 depth_or_array_layers: 1,
870 },
871 );
872 self.gpu.queue.submit(std::iter::once(encoder.finish()));
873
874 let slice = staging.slice(..);
875 let (tx, rx) = std::sync::mpsc::channel();
876 slice.map_async(wgpu::MapMode::Read, move |res| {
877 let _ = tx.send(res);
878 });
879 self.gpu
880 .device
881 .poll(wgpu::PollType::wait_indefinitely())
882 .ok();
883 rx.recv()
884 .map_err(|e| crate::error::RenderError::RenderFailed(e.to_string()))?
885 .map_err(|e| crate::error::RenderError::RenderFailed(format!("{e:?}")))?;
886
887 let view = slice.get_mapped_range();
888 let mut out = Vec::with_capacity((unpadded_row * height) as usize);
889 for row in 0..height {
890 let start = (row * padded_row) as usize;
891 let end = start + unpadded_row as usize;
892 out.extend_from_slice(&view[start..end]);
893 }
894 drop(view);
895 staging.unmap();
896
897 // Normalise to RGBA8 byte order. BGRA8 (desktop default on most
898 // backends) gets its R/B channels swapped in place.
899 let fmt = self.gpu.config.texture_format.to_wgpu();
900 if matches!(
901 fmt,
902 wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb
903 ) {
904 for px in out.chunks_exact_mut(4) {
905 px.swap(0, 2);
906 }
907 }
908
909 Ok(out)
910 }
911
912 pub fn state(&self) -> &RenderState {
913 &self.state
914 }
915
916 /// Push the outgoing preset's `RenderState` for the secondary chain.
917 /// Called by the engine each frame during a transition; cleared by
918 /// passing `None` once the transition completes.
919 pub fn update_secondary_state(&mut self, state: Option<RenderState>) {
920 self.secondary_state = state;
921 }
922
923 /// Push the outgoing preset's per-vertex warp UVs to the secondary
924 /// chain. No-op when no secondary chain is active. Mirrors
925 /// `update_warp_vertices` for the primary chain.
926 pub fn update_secondary_warp_vertices(&mut self, vertices: &[WarpVertex]) {
927 let cols = self.warp_mesh.cols;
928 let rows = self.warp_mesh.rows;
929 if let Some(sec) = self.secondary.as_mut() {
930 sec.update_warp_vertices(&self.gpu.queue, cols, rows, vertices);
931 }
932 }
933
934 /// Push the outgoing preset's audio samples to the secondary chain's
935 /// waveform pass. No-op when no secondary chain is active. The
936 /// `wave_params` argument carries the smoothing factor for the IIR.
937 pub fn update_secondary_waveform_samples(
938 &mut self,
939 samples: &[f32],
940 instantaneous_volume: f32,
941 wave_params: crate::config::WaveParams,
942 ) {
943 if let Some(sec) = self.secondary.as_mut() {
944 sec.update_waveform_samples(
945 &self.gpu.queue,
946 samples,
947 instantaneous_volume,
948 wave_params,
949 );
950 }
951 }
952
953 /// Stereo variant of `update_secondary_waveform_samples` for the
954 /// outgoing preset during a transition. Mirrors the primary
955 /// chain's `update_waveform_samples_lr`.
956 pub fn update_secondary_waveform_samples_lr(
957 &mut self,
958 left: &[f32],
959 right: &[f32],
960 instantaneous_volume: f32,
961 wave_params: crate::config::WaveParams,
962 ) {
963 if let Some(sec) = self.secondary.as_mut() {
964 sec.update_waveform_samples_lr(
965 &self.gpu.queue,
966 left,
967 right,
968 instantaneous_volume,
969 wave_params,
970 );
971 }
972 }
973
974 /// Push the outgoing preset's custom-wave vertex stream to the secondary
975 /// chain. No-op when no secondary chain is active.
976 pub fn update_secondary_custom_waves(
977 &mut self,
978 vertices: &[CustomWaveVertex],
979 batches: &[CustomWaveBatch],
980 ) {
981 if let Some(sec) = self.secondary.as_mut() {
982 sec.update_custom_waves(&self.gpu.queue, vertices, batches);
983 }
984 }
985
986 /// Push the outgoing preset's custom-shape instance stream to the
987 /// secondary chain. No-op when no secondary chain is active.
988 pub fn update_secondary_custom_shapes(
989 &mut self,
990 instances: &[CustomShapeInstance],
991 batches: &[CustomShapeBatch],
992 ) {
993 let aspect = self.gpu.aspect_ratio();
994 if let Some(sec) = self.secondary.as_mut() {
995 sec.update_custom_shapes(&self.gpu.queue, instances, batches, aspect);
996 }
997 }
998
999 /// Resize render targets. Re-allocates per-chain textures, the blend
1000 /// display target, and rebinds the blend pass to the new primary view.
1001 pub fn resize(&mut self, width: u32, height: u32) {
1002 self.gpu.set_resolution(width, height);
1003 self.warp_mesh = WarpMesh::new(
1004 self.warp_mesh.cols,
1005 self.warp_mesh.rows,
1006 self.gpu.aspect_ratio(),
1007 );
1008 self.chain.resize(&self.gpu);
1009 if let Some(secondary) = self.secondary.as_mut() {
1010 secondary.resize(&self.gpu);
1011 }
1012 self.final_blend.resize(&self.gpu.device, &self.gpu.config);
1013 let secondary_view = self
1014 .secondary
1015 .as_ref()
1016 .map(|s| s.textures().final_texture_view.clone());
1017 self.final_blend.set_inputs(
1018 &self.gpu.device,
1019 &self.chain.textures().final_texture_view,
1020 secondary_view.as_ref(),
1021 );
1022 }
1023}
1024
1025/// Build the `ShaderUniforms` payload from any `RenderState` + GPU context.
1026/// Shared between the primary and secondary render paths so each chain can
1027/// run with its own `gamma_adj` / `echo_alpha` / audio / q snapshot.
1028fn build_shader_uniforms_from(state: &RenderState, gpu: &GpuContext) -> ShaderUniforms {
1029 let aspect = gpu.aspect_ratio();
1030 let aspect_x = if aspect > 1.0 { 1.0 / aspect } else { 1.0 };
1031 let aspect_y = if aspect < 1.0 { aspect } else { 1.0 };
1032 let w = gpu.config.width as f32;
1033 let h = gpu.config.height as f32;
1034 let audio = &state.audio;
1035 let mut u = ShaderUniforms {
1036 time: state.time,
1037 frame: state.frame as f32,
1038 progress: state.progress,
1039 bass: audio.bass,
1040 mid: audio.mid,
1041 treb: audio.treb,
1042 vol: (audio.bass + audio.mid + audio.treb) / 3.0,
1043 bass_att: audio.bass_att,
1044 mid_att: audio.mid_att,
1045 treb_att: audio.treb_att,
1046 vol_att: (audio.bass_att + audio.mid_att + audio.treb_att) / 3.0,
1047 gamma_adj: state.gamma_adj,
1048 echo_zoom: state.echo_zoom,
1049 echo_alpha: state.echo_alpha,
1050 echo_orient: state.echo_orient as f32,
1051 red_blue_stereo: if state.red_blue_stereo { 1.0 } else { 0.0 },
1052 aspect: [aspect_x, aspect_y, 1.0 / aspect_x, 1.0 / aspect_y],
1053 texsize: [w, h, 1.0 / w, 1.0 / h],
1054 hue_shader: hue_shader_for(state.time),
1055 ..ShaderUniforms::default()
1056 };
1057 u.set_q_channels(&state.q_snapshot);
1058 u
1059}
1060
1061/// Time-driven RGB tint MD2 cycles through each frame. Three oscillators
1062/// with mutually-prime periods produce a slow rainbow rotation that
1063/// never repeats (rationals on the unit circle); presets that multiply
1064/// `ret * hue_shader` get the rainbow rotation MD2 is tuned for, while
1065/// presets that ignore it keep their authored colour palette because
1066/// the per-channel output stays in `[0, 1]` and floats around `~0.5`.
1067/// `.w` is reserved for a future per-corner mix factor.
1068fn hue_shader_for(time: f32) -> [f32; 4] {
1069 let r = 0.5 + 0.5 * (time * 0.34 + 1.0).cos();
1070 let g = 0.5 + 0.5 * (time * 0.47 + 2.0).cos();
1071 let b = 0.5 + 0.5 * (time * 0.27 + 3.0).cos();
1072 [r, g, b, 0.0]
1073}
1074
1075#[cfg(test)]
1076mod tests {
1077 use super::*;
1078 use crate::waveform::NUM_WAVE_SAMPLES;
1079
1080 #[test]
1081 fn test_renderer_creation() {
1082 let config = RenderConfig::default();
1083 let renderer = pollster::block_on(MilkRenderer::new(config));
1084 assert!(renderer.is_ok());
1085 }
1086
1087 #[test]
1088 fn test_render_frame() {
1089 let config = RenderConfig::default();
1090 let mut renderer = pollster::block_on(MilkRenderer::new(config)).unwrap();
1091 let result = renderer.render();
1092 assert!(result.is_ok());
1093 }
1094
1095 #[test]
1096 fn test_render_texture() {
1097 let config = RenderConfig::default();
1098 let width = config.width;
1099 let height = config.height;
1100 let renderer = pollster::block_on(MilkRenderer::new(config)).unwrap();
1101 let texture = renderer.render_texture();
1102 assert_eq!(texture.width(), width);
1103 assert_eq!(texture.height(), height);
1104 }
1105
1106 #[test]
1107 fn test_multiple_renders() {
1108 let config = RenderConfig::default();
1109 let mut renderer = pollster::block_on(MilkRenderer::new(config)).unwrap();
1110 for _ in 0..10 {
1111 assert!(renderer.render().is_ok());
1112 }
1113 assert_eq!(renderer.state().frame, 10);
1114 }
1115
1116 /// `set_user_comp_shader` end-to-end: a real, simple MD2-style HLSL body
1117 /// must translate, validate, and end up driving the comp pass. The
1118 /// `has_user_comp_shader()` flag is the visible test surface; the
1119 /// pipeline-level swap is exercised by the comp_pipeline tests.
1120 #[test]
1121 fn user_comp_shader_swap_via_renderer_api() {
1122 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1123 // The single-line MD2-style body the translator handles cleanly:
1124 // brace-stripped wrapper, `tex2D`/`saturate` already covered, no
1125 // implicit-broadcast traps.
1126 let hlsl = "shader_body { ret = tex2D(sampler_main, uv).xyz; ret *= 1.5; }";
1127 let ok = r
1128 .set_user_comp_shader(Some(hlsl))
1129 .expect("compile path must not error on valid HLSL");
1130 assert!(ok, "expected compile success, got fallback");
1131 assert!(r.has_user_comp_shader());
1132
1133 // None reverts to default.
1134 let _ = r.set_user_comp_shader(None);
1135 assert!(!r.has_user_comp_shader());
1136 }
1137
1138 /// Mirror of `user_comp_shader_swap_via_renderer_api` for the warp
1139 /// pipeline. Same MD2-style body shape; the renderer's API flips the
1140 /// warp pass from the engine's hand-written `warp.wgsl` to a
1141 /// translated user pipeline. The reset path (`None`) returns to the
1142 /// default warp.
1143 #[test]
1144 fn user_warp_shader_swap_via_renderer_api() {
1145 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1146 let hlsl = "shader_body { ret = tex2D(sampler_main, uv).xyz * 0.9; ret -= 0.01; }";
1147 let ok = r
1148 .set_user_warp_shader(Some(hlsl))
1149 .expect("compile path must not error on valid HLSL");
1150 assert!(ok, "expected warp compile success, got fallback");
1151 assert!(r.has_user_warp_shader());
1152
1153 // The render loop must keep working — a frame draws with the
1154 // user pipeline active.
1155 assert!(r.render().is_ok());
1156
1157 // Reset reverts to the default warp.
1158 let _ = r.set_user_warp_shader(None);
1159 assert!(!r.has_user_warp_shader());
1160 assert!(r.render().is_ok());
1161 }
1162
1163 /// Invalid warp HLSL must NOT propagate as an error — same contract
1164 /// the comp side honours. The pass falls back to the default
1165 /// `warp.wgsl`, the renderer reports `Ok(false)`, and the frame
1166 /// still draws.
1167 #[test]
1168 fn user_warp_shader_invalid_hlsl_falls_back_gracefully() {
1169 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1170 let ok = r
1171 .set_user_warp_shader(Some("not HLSL @@@ {{{"))
1172 .expect("invalid input must not bubble up as renderer error");
1173 assert!(!ok);
1174 assert!(!r.has_user_warp_shader());
1175 assert!(r.render().is_ok());
1176 }
1177
1178 /// Garbage HLSL must NOT propagate as an error from the renderer — the
1179 /// comp pass falls back to the default and reports `Ok(false)`. This is
1180 /// the contract the engine relies on to keep navigation usable when a
1181 /// preset's shader can't be translated.
1182 #[test]
1183 fn user_comp_shader_invalid_hlsl_falls_back_gracefully() {
1184 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1185 let ok = r
1186 .set_user_comp_shader(Some(
1187 "this is not even close to HLSL — squiggly braces { everywhere",
1188 ))
1189 .expect("invalid input must not bubble up as renderer error");
1190 assert!(!ok, "expected fallback, got success");
1191 assert!(!r.has_user_comp_shader());
1192 // Renderer still renders cleanly with the default shader.
1193 assert!(r.render().is_ok());
1194 }
1195
1196 /// Noise sampling end-to-end: a user comp shader that samples the
1197 /// procedural noise pack must translate, validate, and drive the comp
1198 /// pass. This covers the noise sampler bindings (6–10) and the
1199 /// sampler variants (`sampler_pw_main` → `sampler_pw`): the translator
1200 /// emits distinct bindings, the wrapper declares them, and the
1201 /// renderer binds them.
1202 #[test]
1203 fn noise_sampling_user_shader_compiles_through_renderer() {
1204 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1205 let hlsl = "shader_body {\n\
1206 ret = tex2D(sampler_noise_lq, uv).xyz;\n\
1207 ret += tex2D(sampler_pw_noise_hq, uv * 4).xyz * 0.25;\n\
1208 ret += tex3D(sampler_noisevol_hq, vec3<f32>(uv, time)).xyz * 0.5;\n\
1209 ret *= tex2D(sampler_pw_main, uv).xyz + 0.25;\n\
1210 }";
1211 let ok = r
1212 .set_user_comp_shader(Some(hlsl))
1213 .expect("compile path must not error");
1214 assert!(
1215 ok,
1216 "noise-sampling shader should compile through noise bindings"
1217 );
1218 assert!(r.has_user_comp_shader());
1219 // Renderer can drive a frame with this shader bound — covers
1220 // pipeline draw with the full 15-binding bind group.
1221 assert!(r.render().is_ok());
1222 }
1223
1224 /// Empty / whitespace-only HLSL is treated as "no user shader" — same
1225 /// path as `None`. Avoids a needless compile attempt for presets that
1226 /// declare `comp_shader=` with no content.
1227 #[test]
1228 fn user_comp_shader_empty_input_resets_to_default() {
1229 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1230 // Install a real one first…
1231 let _ = r
1232 .set_user_comp_shader(Some("shader_body { ret = vec3<f32>(1, 0, 0); }"))
1233 .unwrap();
1234 assert!(r.has_user_comp_shader());
1235 // …then reset via empty.
1236 let ok = r.set_user_comp_shader(Some(" \n\t ")).unwrap();
1237 assert!(ok);
1238 assert!(!r.has_user_comp_shader());
1239 }
1240
1241 #[test]
1242 fn warp_mesh_exposed() {
1243 let cfg = RenderConfig::default();
1244 let r = pollster::block_on(MilkRenderer::new(cfg)).unwrap();
1245 let m = r.warp_mesh();
1246 assert_eq!(m.cols, DEFAULT_MESH_COLS);
1247 assert_eq!(m.rows, DEFAULT_MESH_ROWS);
1248 assert_eq!(
1249 m.vertex_count(),
1250 (DEFAULT_MESH_COLS * DEFAULT_MESH_ROWS) as usize
1251 );
1252 }
1253
1254 // -----------------------------------------------------------------
1255 // User-loaded textures + sampler_rand0X end-to-end
1256 // -----------------------------------------------------------------
1257
1258 /// Helper: write a 4×4 solid-colour PNG so a test can synthesise a pool
1259 /// without checking fixtures into the repo. Returns the tempdir handle
1260 /// so the file lives as long as the binding.
1261 fn make_texture_pool(
1262 gpu_device: &wgpu::Device,
1263 gpu_queue: &wgpu::Queue,
1264 names: &[&str],
1265 ) -> (tempfile::TempDir, crate::TexturePool) {
1266 let dir = tempfile::tempdir().unwrap();
1267 for n in names {
1268 let path = dir.path().join(format!("{n}.png"));
1269 let pixels: Vec<u8> = (0..16).flat_map(|_| [10u8, 20, 30, 255]).collect();
1270 let img = image::RgbaImage::from_raw(4, 4, pixels).unwrap();
1271 img.save_with_format(&path, image::ImageFormat::Png)
1272 .unwrap();
1273 }
1274 let pool =
1275 crate::TexturePool::from_dirs(gpu_device, gpu_queue, &[dir.path().to_path_buf()]);
1276 (dir, pool)
1277 }
1278
1279 /// A preset that samples a disk-loaded `sampler_clouds` must compile
1280 /// successfully when the renderer's pool has that texture loaded.
1281 /// The translator lands the sampler on its user-texture binding
1282 /// instead of falling back to `sampler_main` with a
1283 /// `/*was: sampler_clouds*/` debug comment.
1284 #[test]
1285 fn user_texture_drives_user_shader_compile() {
1286 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1287 let (_keepalive, pool) =
1288 make_texture_pool(r.gpu.device.as_ref(), r.gpu.queue.as_ref(), &["clouds"]);
1289 r.set_texture_pool(pool);
1290 assert_eq!(r.texture_pool().len(), 1);
1291
1292 let hlsl = "sampler sampler_clouds;\n\
1293 shader_body { ret = tex2D(sampler_clouds, uv).xyz; }";
1294 let ok = r
1295 .set_user_comp_shader(Some(hlsl))
1296 .expect("plan-driven compile must not error");
1297 assert!(ok, "expected user-texture compile to succeed");
1298 assert!(r.has_user_comp_shader());
1299 assert!(r.render().is_ok());
1300 }
1301
1302 /// `sampler_rand02` resolves to a pool texture deterministically per
1303 /// preset id. Same id → same texture binding; different ids → may
1304 /// differ. The test asserts the deterministic-per-id contract.
1305 #[test]
1306 fn sampler_rand0x_resolves_deterministically_per_preset() {
1307 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1308 let (_keepalive, pool) = make_texture_pool(
1309 r.gpu.device.as_ref(),
1310 r.gpu.queue.as_ref(),
1311 &["alpha", "beta", "gamma"],
1312 );
1313 r.set_texture_pool(pool);
1314
1315 let hlsl = "sampler sampler_rand02;\n\
1316 shader_body { ret = tex2D(sampler_rand02, uv).xyz; }";
1317
1318 // Same preset id twice → same compilation result.
1319 let ok_a = r
1320 .set_user_comp_shader_for_preset(Some(hlsl), "Geiss - Aerial.milk")
1321 .unwrap();
1322 assert!(ok_a);
1323 let ok_b = r
1324 .set_user_comp_shader_for_preset(Some(hlsl), "Geiss - Aerial.milk")
1325 .unwrap();
1326 assert!(ok_b);
1327 // Different preset id is *allowed* to pick the same texture
1328 // (small pools have collisions) — we only test the no-error and
1329 // no-fallback path, not that the choice differs.
1330 let ok_c = r
1331 .set_user_comp_shader_for_preset(Some(hlsl), "Krash - Mandala.milk")
1332 .unwrap();
1333 assert!(ok_c);
1334 }
1335
1336 /// A user shader referencing a missing pool texture must still compile
1337 /// (binding routed through the user slot, but bound to the fallback
1338 /// 1×1 white texture). Sampling produces `vec4(1, 1, 1, 1)` so the
1339 /// preset's math still terminates in a valid output.
1340 #[test]
1341 fn missing_user_texture_uses_fallback_not_error() {
1342 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1343 // No pool populated — only the fallback.
1344 let hlsl = "sampler sampler_clouds;\n\
1345 shader_body { ret = tex2D(sampler_clouds, uv).xyz; }";
1346 let ok = r
1347 .set_user_comp_shader(Some(hlsl))
1348 .expect("missing texture must not surface as renderer error");
1349 assert!(ok);
1350 assert!(r.has_user_comp_shader());
1351 assert!(r.render().is_ok());
1352 }
1353
1354 // -----------------------------------------------------------------
1355 // Waveform pass dispatched end-to-end
1356 // -----------------------------------------------------------------
1357
1358 /// Every MD2 wave mode (0..7) must render without panicking through
1359 /// the dispatched wave pass. We seed deterministic sample data, set
1360 /// the mode, and read back the final framebuffer — no specific
1361 /// pattern assertion (mode geometry would tie the test to the
1362 /// shader), just "ran clean and produced non-zero output".
1363 #[test]
1364 fn waveform_dispatch_runs_for_every_md2_mode() {
1365 for mode in 0..=7 {
1366 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1367 // Set a recognisable wave colour so the pass actually writes
1368 // something we can detect in the framebuffer.
1369 let mut s = RenderState::default();
1370 s.wave.mode = mode;
1371 s.wave.r = 1.0;
1372 s.wave.g = 0.5;
1373 s.wave.b = 0.0;
1374 s.wave.a = 1.0;
1375 s.wave.scale = 0.5;
1376 s.wave.thick = mode % 2 == 0;
1377 s.wave.additive = mode == 3;
1378 s.wave.dots = mode == 5;
1379 r.update_state(s);
1380 // Deterministic sine wave so the renderer has signal to draw.
1381 let samples: Vec<f32> = (0..NUM_WAVE_SAMPLES)
1382 .map(|i| (i as f32 * 0.05).sin() * 0.6)
1383 .collect();
1384 r.update_waveform_samples(&samples, 0.5);
1385 r.render().unwrap();
1386 let frame = r.read_render_texture().unwrap();
1387 assert!(
1388 frame.iter().any(|&b| b != 0),
1389 "mode {} produced an all-zero framebuffer",
1390 mode
1391 );
1392 }
1393 }
1394
1395 /// `update_waveform_samples` must always end up with exactly
1396 /// `NUM_WAVE_SAMPLES` entries (zero-padded if shorter) so the GPU
1397 /// storage upload size matches the pipeline expectation.
1398 #[test]
1399 fn waveform_samples_buffer_is_canonicalised() {
1400 use crate::waveform::NUM_WAVE_SAMPLES;
1401 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1402 r.update_waveform_samples(&[0.5, -0.5, 0.25], 0.1);
1403 assert_eq!(r.wave_samples().len(), NUM_WAVE_SAMPLES);
1404 // Padding tail is zero.
1405 assert!(r.wave_samples().iter().skip(3).all(|&v| v.abs() < 1e-6));
1406 }
1407
1408 /// Smoothing applied to the sample buffer attenuates spike-y input.
1409 /// Drives the smoothing path that runs before each GPU upload.
1410 #[test]
1411 fn waveform_smoothing_path_attenuates() {
1412 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1413 let mut s = RenderState::default();
1414 s.wave.smoothing = 0.9;
1415 r.update_state(s);
1416 let spiky: Vec<f32> = (0..NUM_WAVE_SAMPLES)
1417 .map(|i| if i % 2 == 0 { 1.0 } else { -1.0 })
1418 .collect();
1419 r.update_waveform_samples(&spiky, 0.0);
1420 let max = r.wave_samples().iter().cloned().fold(f32::MIN, f32::max);
1421 let min = r.wave_samples().iter().cloned().fold(f32::MAX, f32::min);
1422 assert!(
1423 (max - min) < 1.5,
1424 "smoothing did not attenuate spiky input (peak-to-peak {})",
1425 max - min
1426 );
1427 }
1428
1429 /// `additive` and `dots` flags propagate through `RenderState` and
1430 /// drive the pipeline-selection branch in `render`. The visible
1431 /// test surface is "render() succeeds for every combination" — the
1432 /// exact pixel output is the shader's job, covered by other tests.
1433 #[test]
1434 fn waveform_blend_and_dots_flags_dispatch() {
1435 for &(thick, dots, additive) in &[
1436 (false, false, false),
1437 (true, false, false),
1438 (false, true, false),
1439 (false, false, true),
1440 (true, true, true),
1441 ] {
1442 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1443 let mut s = RenderState::default();
1444 s.wave.mode = 0;
1445 s.wave.thick = thick;
1446 s.wave.dots = dots;
1447 s.wave.additive = additive;
1448 r.update_state(s);
1449 r.update_waveform_samples(&[0.3_f32; NUM_WAVE_SAMPLES], 0.4);
1450 r.render().expect("render must succeed for any flag combo");
1451 }
1452 }
1453
1454 /// `b_mod_wave_alpha_by_volume` should make the wave dim at silence
1455 /// and brighter at high volume. We exercise both ends and check the
1456 /// renderer doesn't reject either — the geometry/colour test for the
1457 /// envelope itself is the `build_uniforms_mod_alpha_*` unit test.
1458 #[test]
1459 fn waveform_mod_alpha_by_volume_drives_dispatch() {
1460 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1461 let mut s = RenderState::default();
1462 s.wave.mod_alpha_by_volume = true;
1463 s.wave.mod_alpha_start = 0.0;
1464 s.wave.mod_alpha_end = 1.0;
1465 r.update_state(s);
1466
1467 r.update_waveform_samples(&[0.2_f32; NUM_WAVE_SAMPLES], 0.0);
1468 r.render().unwrap();
1469 r.update_waveform_samples(&[0.2_f32; NUM_WAVE_SAMPLES], 1.0);
1470 r.render().unwrap();
1471 }
1472
1473 #[test]
1474 fn rendering_is_deterministic_within_session() {
1475 // Two independent renderer instances driven through the same warp UV
1476 // sequence must produce the same final framebuffer. We seed the warp
1477 // UVs by hand (no audio, no presets) so the test does not depend on
1478 // any other engine state.
1479 let make = || {
1480 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1481 for frame in 0..5 {
1482 let mesh = r.warp_mesh();
1483 // Identity warp + slight time-dependent shift so consecutive
1484 // frames diverge from a single "all-zeroes" output.
1485 let verts: Vec<crate::warp_pipeline::WarpVertex> = mesh
1486 .vertices
1487 .iter()
1488 .map(|v| {
1489 let shift = (frame as f32) * 0.01;
1490 crate::warp_pipeline::WarpVertex {
1491 pos_clip: v.pos_clip,
1492 uv_warp: [v.uv_orig[0] + shift, v.uv_orig[1] + shift],
1493 }
1494 })
1495 .collect();
1496 r.update_warp_vertices(&verts);
1497 r.render().unwrap();
1498 }
1499 r.read_render_texture().unwrap()
1500 };
1501
1502 let a = make();
1503 let b = make();
1504 assert_eq!(a.len(), b.len(), "readback length mismatch");
1505 assert_eq!(a, b, "non-deterministic rendering: byte-level mismatch");
1506 // Sanity: not all zeroes — confirms the test actually exercised the
1507 // pipeline rather than reading an untouched texture.
1508 assert!(
1509 a.iter().any(|&b| b != 0),
1510 "framebuffer is all-zero, test did not actually render"
1511 );
1512 }
1513
1514 // -----------------------------------------------------------------
1515 // Borders + motion vectors dispatched end-to-end
1516 // -----------------------------------------------------------------
1517
1518 /// `RenderState::default` has zero alpha on both border rings and
1519 /// the motion-vector grid, so the renderer must take the no-op
1520 /// path: zero draw calls, zero segments.
1521 #[test]
1522 fn borders_and_motion_vectors_default_to_noop() {
1523 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1524 r.update_state(RenderState::default());
1525 r.render().unwrap();
1526 assert_eq!(r.border_batch_count(), 0);
1527 assert_eq!(r.motion_vector_segment_count(), 0);
1528 }
1529
1530 /// Enabling either ring should produce exactly one batch; enabling
1531 /// both must produce two. Mirrors the typical preset case of "outer
1532 /// border off, inner on" → one ring drawn.
1533 #[test]
1534 fn borders_batch_count_tracks_visibility() {
1535 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1536 let mut s = RenderState::default();
1537 s.borders.outer_size = 0.02;
1538 s.borders.outer_color = [1.0, 0.5, 0.0, 1.0];
1539 r.update_state(s);
1540 r.render().unwrap();
1541 assert_eq!(r.border_batch_count(), 1);
1542
1543 s.borders.inner_size = 0.01;
1544 s.borders.inner_color = [0.2, 0.4, 0.6, 0.8];
1545 r.update_state(s);
1546 r.render().unwrap();
1547 assert_eq!(r.border_batch_count(), 2);
1548 }
1549
1550 /// Motion-vector grid produces `nx * ny` segments when enabled and
1551 /// clamps at the MD2 cap of 64×48 regardless of the requested size.
1552 #[test]
1553 fn motion_vector_segments_grid_and_cap() {
1554 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1555 let mut s = RenderState::default();
1556 s.motion_vectors.grid_x = 8;
1557 s.motion_vectors.grid_y = 6;
1558 s.motion_vectors.color = [1.0, 1.0, 1.0, 0.7];
1559 r.update_state(s);
1560 r.render().unwrap();
1561 assert_eq!(r.motion_vector_segment_count(), 8 * 6);
1562
1563 // Way past the MD2 cap: must clamp to 64 × 48.
1564 s.motion_vectors.grid_x = 200;
1565 s.motion_vectors.grid_y = 100;
1566 r.update_state(s);
1567 r.render().unwrap();
1568 assert_eq!(r.motion_vector_segment_count(), 64 * 48);
1569 }
1570
1571 /// Setting either alpha to zero on a previously visible pass must
1572 /// flip the renderer back to the no-op path (no batches / no
1573 /// segments). Per-frame eqs in MD2 can toggle these mid-preset.
1574 #[test]
1575 fn alpha_zero_disables_pass_again() {
1576 let mut r = pollster::block_on(MilkRenderer::new(RenderConfig::default())).unwrap();
1577 let mut s = RenderState::default();
1578 s.borders.outer_size = 0.02;
1579 s.borders.outer_color = [1.0, 0.0, 0.0, 1.0];
1580 s.motion_vectors.grid_x = 4;
1581 s.motion_vectors.grid_y = 3;
1582 s.motion_vectors.color = [1.0, 1.0, 1.0, 1.0];
1583 r.update_state(s);
1584 r.render().unwrap();
1585 assert_eq!(r.border_batch_count(), 1);
1586 assert_eq!(r.motion_vector_segment_count(), 12);
1587
1588 s.borders.outer_color[3] = 0.0;
1589 s.motion_vectors.color[3] = 0.0;
1590 r.update_state(s);
1591 r.render().unwrap();
1592 assert_eq!(r.border_batch_count(), 0);
1593 assert_eq!(r.motion_vector_segment_count(), 0);
1594 }
1595}