Skip to main content

mandelbulb/
mandelbulb.rs

1use cuneus::compute::ComputeShader;
2use cuneus::{
3    Core, ExportManager, RenderKit, ShaderControls, ShaderManager,
4};
5use log::debug;
6use cuneus::WindowEvent;
7
8cuneus::uniform_params! {
9    struct MandelbulbParams {
10    power: f32,
11    max_bounces: u32,
12    samples_per_pixel: u32,
13    accumulate: u32,
14
15    animation_speed: f32,
16    hold_duration: f32,
17    transition_duration: f32,
18
19    exposure: f32,
20    focal_length: f32,
21    dof_strength: f32,
22
23    palette_a_r: f32,
24    palette_a_g: f32,
25    palette_a_b: f32,
26    palette_b_r: f32,
27    palette_b_g: f32,
28    palette_b_b: f32,
29    palette_c_r: f32,
30    palette_c_g: f32,
31    palette_c_b: f32,
32    palette_d_r: f32,
33    palette_d_g: f32,
34    palette_d_b: f32,
35
36    gamma: f32,
37    zoom: f32,
38
39    background_r: f32,
40    background_g: f32,
41    background_b: f32,
42    sun_color_r: f32,
43    sun_color_g: f32,
44    sun_color_b: f32,
45    fog_color_r: f32,
46    fog_color_g: f32,
47    fog_color_b: f32,
48    glow_color_r: f32,
49    glow_color_g: f32,
50    glow_color_b: f32,
51
52    rotation_x: f32,
53    rotation_y: f32,
54    rotation_z: f32,
55    _pad: f32,
56}
57}
58
59struct MandelbulbShader {
60    base: RenderKit,
61    compute_shader: ComputeShader,
62    frame_count: u32,
63    should_reset_accumulation: bool,
64    current_params: MandelbulbParams,
65    // Mouse tracking for delta-based rotation
66    previous_mouse_pos: [f32; 2],
67    mouse_enabled: bool,
68    mouse_initialized: bool,
69    // Accumulated rotation (persists across frames)
70    accumulated_rotation: [f32; 3],
71    // Accumulated zoom from mouse wheel
72    accumulated_zoom: f32,
73}
74
75impl MandelbulbShader {
76    fn reset_accumulation(&mut self) {
77        self.compute_shader.current_frame = 0;
78        self.should_reset_accumulation = false;
79        self.frame_count = 0;
80    }
81}
82
83impl ShaderManager for MandelbulbShader {
84    fn init(core: &Core) -> Self {
85        let initial_params = MandelbulbParams {
86            power: 4.0,
87            max_bounces: 2,
88            samples_per_pixel: 1,
89            accumulate: 1,
90
91            animation_speed: 1.0,
92            hold_duration: 3.0,
93            transition_duration: 3.0,
94
95            exposure: 1.0,
96            focal_length: 2.5,
97            dof_strength: 0.04,
98
99            palette_a_r: 0.5,
100            palette_a_g: 0.7,
101            palette_a_b: 0.5,
102            palette_b_r: 0.9,
103            palette_b_g: 0.8,
104            palette_b_b: 0.1,
105            palette_c_r: 1.0,
106            palette_c_g: 1.0,
107            palette_c_b: 1.0,
108            palette_d_r: 1.0,
109            palette_d_g: 1.15,
110            palette_d_b: 0.20,
111
112            gamma: 1.1,
113            zoom: 1.0,
114
115            background_r: 0.05,
116            background_g: 0.1,
117            background_b: 0.15,
118            sun_color_r: 8.10,
119            sun_color_g: 6.00,
120            sun_color_b: 4.20,
121            fog_color_r: 0.05,
122            fog_color_g: 0.1,
123            fog_color_b: 0.15,
124            glow_color_r: 0.5,
125            glow_color_g: 0.7,
126            glow_color_b: 1.0,
127
128            rotation_x: 0.0,
129            rotation_y: 0.0,
130            rotation_z: 0.0,
131            _pad: 0.0,
132        };
133        let base = RenderKit::new(core);
134
135        // multipass system: accumulate (self-feedback) -> main_image
136        // accumulate: self-feedback for path tracing accumulation
137        // main_image: reads accumulate for tonemapping
138        let passes = vec![
139            cuneus::compute::PassDescription::new("accumulate", &["accumulate"]),
140            cuneus::compute::PassDescription::new("main_image", &["accumulate"]),
141        ];
142
143        let config = ComputeShader::builder()
144            .with_entry_point("accumulate")
145            .with_multi_pass(&passes)
146            .with_custom_uniforms::<MandelbulbParams>()
147            .with_mouse() // Enable mouse backend integration
148            .with_workgroup_size([16, 16, 1])
149            .with_texture_format(cuneus::compute::COMPUTE_TEXTURE_FORMAT_RGBA16)
150            .with_label("Mandelbulb Unified")
151            .build();
152
153        let compute_shader = cuneus::compute_shader!(core, "shaders/mandelbulb.wgsl", config);
154
155        // Initialize custom uniform with initial parameters
156        compute_shader.set_custom_params(initial_params, &core.queue);
157
158        Self {
159            base,
160            compute_shader,
161            frame_count: 0,
162            should_reset_accumulation: true,
163            current_params: initial_params,
164            previous_mouse_pos: [0.5, 0.5],
165            mouse_enabled: false,
166            mouse_initialized: false,
167            accumulated_rotation: [0.0, 0.0, 0.0],
168            accumulated_zoom: 1.0,
169        }
170    }
171
172    fn update(&mut self, core: &Core) {
173
174        // Handle export
175        self.compute_shader.handle_export(core, &mut self.base);
176    }
177
178    fn resize(&mut self, core: &Core) {
179        self.base.default_resize(core, &mut self.compute_shader);
180        debug!("Resizing to {:?}", core.size);
181    }
182
183    fn render(&mut self, core: &Core) -> Result<(), cuneus::SurfaceError> {
184        let mut frame = self.base.begin_frame(core)?;
185
186        let current_mouse_pos = self.base.mouse_tracker.uniform.position;
187        let mouse_wheel = self.base.mouse_tracker.uniform.wheel;
188
189        if mouse_wheel[1].abs() > 0.001 {
190            let zoom_sensitivity = 0.1;
191            self.accumulated_zoom *= 1.0 - mouse_wheel[1] * zoom_sensitivity;
192            self.accumulated_zoom = self.accumulated_zoom.clamp(0.2, 5.0);
193            self.should_reset_accumulation = true;
194        }
195
196        if self.mouse_enabled {
197            if !self.mouse_initialized {
198                self.previous_mouse_pos = current_mouse_pos;
199                self.mouse_initialized = true;
200            } else {
201                let delta_x: f32 = current_mouse_pos[0] - self.previous_mouse_pos[0];
202                let delta_y = current_mouse_pos[1] - self.previous_mouse_pos[1];
203                if delta_x.abs() > 0.0001 || delta_y.abs() > 0.0001 {
204                    let base_sensitivity = 5.0;
205                    let aspect = core.size.width as f32 / core.size.height as f32;
206                    self.accumulated_rotation[0] += delta_x * base_sensitivity;
207                    self.accumulated_rotation[1] += delta_y * base_sensitivity * aspect;
208                    self.should_reset_accumulation = true;
209                    self.previous_mouse_pos = current_mouse_pos;
210                }
211            }
212        }
213
214        self.base.mouse_tracker.reset_wheel();
215
216        let mut params = self.current_params;
217        let mut changed = false;
218        let mut should_start_export = false;
219        let mut export_request = self.base.export_manager.get_ui_request();
220        let mut controls_request = self
221            .base
222            .controls
223            .get_ui_request(&self.base.start_time, &core.size, self.base.fps_tracker.fps());
224
225        let current_fps = self.base.fps_tracker.fps();
226
227        let full_output = if self.base.key_handler.show_ui {
228            self.base.render_ui(core, |ctx| {
229                RenderKit::apply_default_style(ctx);
230
231                egui::Window::new("Mandelbulb PathTracer")
232                    .collapsible(true)
233                    .resizable(true)
234                    .default_width(350.0)
235                    .show(ctx, |ui| {
236                        ui.label("WASD: Rotate | QE: Roll | Scroll: Zoom");
237                        ui.separator();
238
239                        egui::CollapsingHeader::new("Camera&View")
240                            .default_open(false)
241                            .show(ui, |ui| {
242                                if ui.add(egui::Slider::new(&mut self.accumulated_zoom, 0.2..=5.0).text("Zoom")).changed() {
243                                    self.should_reset_accumulation = true;
244                                }
245                                changed |= ui
246                                    .add(
247                                        egui::Slider::new(&mut params.focal_length, 2.0..=20.0)
248                                            .text("Focal Length"),
249                                    )
250                                    .changed();
251                                changed |= ui
252                                    .add(
253                                        egui::Slider::new(&mut params.dof_strength, 0.0..=1.0)
254                                            .text("DoF"),
255                                    )
256                                    .changed();
257
258                                ui.separator();
259                                let old_mouse_enabled = self.mouse_enabled;
260                                ui.checkbox(&mut self.mouse_enabled, "Mouse Camera Control (M key)");
261                                if self.mouse_enabled != old_mouse_enabled {
262                                    self.mouse_initialized = false;
263                                }
264                                if !self.mouse_enabled {
265                                    ui.colored_label(
266                                        egui::Color32::GRAY,
267                                        "Mouse disabled - camera locked",
268                                    );
269                                } else {
270                                    ui.colored_label(egui::Color32::GREEN, "Mouse active");
271                                }
272                                ui.horizontal(|ui| {
273                                    if ui.button("Reset Rotation").clicked() {
274                                        self.accumulated_rotation = [0.0, 0.0, 0.0];
275                                        self.should_reset_accumulation = true;
276                                    }
277                                    if ui.button("Reset Zoom").clicked() {
278                                        self.accumulated_zoom = 1.0;
279                                        self.should_reset_accumulation = true;
280                                    }
281                                });
282                            });
283
284                        egui::CollapsingHeader::new("Mandelbulb")
285                            .default_open(false)
286                            .show(ui, |ui| {
287                                let old_power = params.power;
288                                changed |= ui
289                                    .add(
290                                        egui::Slider::new(&mut params.power, 2.0..=12.0)
291                                            .text("Power"),
292                                    )
293                                    .changed();
294                                if params.power != old_power {
295                                    self.should_reset_accumulation = true;
296                                }
297                            });
298
299                        egui::CollapsingHeader::new("Render")
300                            .default_open(false)
301                            .show(ui, |ui| {
302                                let old_samples = params.samples_per_pixel;
303                                changed |= ui
304                                    .add(
305                                        egui::Slider::new(&mut params.samples_per_pixel, 1..=8)
306                                            .text("Samples/pixel"),
307                                    )
308                                    .changed();
309                                if params.samples_per_pixel != old_samples {
310                                    self.should_reset_accumulation = true;
311                                }
312
313                                let old_bounces = params.max_bounces;
314                                changed |= ui
315                                    .add(
316                                        egui::Slider::new(&mut params.max_bounces, 1..=12)
317                                            .text("Max Bounces"),
318                                    )
319                                    .changed();
320                                if params.max_bounces != old_bounces {
321                                    self.should_reset_accumulation = true;
322                                }
323
324                                let old_accumulate = params.accumulate;
325                                let mut accumulate_bool = params.accumulate > 0;
326                                changed |= ui
327                                    .checkbox(&mut accumulate_bool, "Progressive Rendering")
328                                    .changed();
329                                params.accumulate = if accumulate_bool { 1 } else { 0 };
330                                if params.accumulate != old_accumulate {
331                                    self.should_reset_accumulation = true;
332                                }
333
334                                changed |= ui
335                                    .add(
336                                        egui::Slider::new(&mut params.exposure, 0.1..=5.0)
337                                            .text("Exposure"),
338                                    )
339                                    .changed();
340                                changed |= ui
341                                    .add(
342                                        egui::Slider::new(&mut params.gamma, 0.1..=2.0)
343                                            .text("Gamma"),
344                                    )
345                                    .changed();
346
347                                if ui.button("Reset Accumulation").clicked() {
348                                    self.should_reset_accumulation = true;
349                                    changed = true;
350                                }
351                            });
352
353                        egui::CollapsingHeader::new("env")
354                            .default_open(false)
355                            .show(ui, |ui| {
356                                ui.horizontal(|ui| {
357                                    ui.label("bg:");
358                                    let mut bg_color = [
359                                        params.background_r,
360                                        params.background_g,
361                                        params.background_b,
362                                    ];
363                                    if ui.color_edit_button_rgb(&mut bg_color).changed() {
364                                        params.background_r = bg_color[0];
365                                        params.background_g = bg_color[1];
366                                        params.background_b = bg_color[2];
367                                        changed = true;
368                                    }
369                                });
370
371                                ui.horizontal(|ui| {
372                                    ui.label("Sun:");
373                                    let mut sun_color = [
374                                        params.sun_color_r,
375                                        params.sun_color_g,
376                                        params.sun_color_b,
377                                    ];
378                                    if ui.color_edit_button_rgb(&mut sun_color).changed() {
379                                        params.sun_color_r = sun_color[0];
380                                        params.sun_color_g = sun_color[1];
381                                        params.sun_color_b = sun_color[2];
382                                        changed = true;
383                                    }
384                                });
385
386                                ui.horizontal(|ui| {
387                                    ui.label("Fog:");
388                                    let mut fog_color = [
389                                        params.fog_color_r,
390                                        params.fog_color_g,
391                                        params.fog_color_b,
392                                    ];
393                                    if ui.color_edit_button_rgb(&mut fog_color).changed() {
394                                        params.fog_color_r = fog_color[0];
395                                        params.fog_color_g = fog_color[1];
396                                        params.fog_color_b = fog_color[2];
397                                        changed = true;
398                                    }
399                                });
400
401                                ui.horizontal(|ui| {
402                                    ui.label("Sky Glow:");
403                                    let mut glow_color = [
404                                        params.glow_color_r,
405                                        params.glow_color_g,
406                                        params.glow_color_b,
407                                    ];
408                                    if ui.color_edit_button_rgb(&mut glow_color).changed() {
409                                        params.glow_color_r = glow_color[0];
410                                        params.glow_color_g = glow_color[1];
411                                        params.glow_color_b = glow_color[2];
412                                        changed = true;
413                                    }
414                                });
415
416                                if ui.button("Reset env cols").clicked() {
417                                    params.background_r = 0.1;
418                                    params.background_g = 0.1;
419                                    params.background_b = 0.15;
420                                    params.sun_color_r = 8.10;
421                                    params.sun_color_g = 6.00;
422                                    params.sun_color_b = 4.20;
423                                    params.fog_color_r = 0.1;
424                                    params.fog_color_g = 0.1;
425                                    params.fog_color_b = 0.15;
426                                    params.glow_color_r = 0.5;
427                                    params.glow_color_g = 0.7;
428                                    params.glow_color_b = 1.0;
429                                    changed = true;
430                                }
431                            });
432
433                        egui::CollapsingHeader::new("Color Palette")
434                            .default_open(false)
435                            .show(ui, |ui| {
436                                ui.horizontal(|ui| {
437                                    ui.label("Base Color:");
438                                    let mut color_a = [
439                                        params.palette_a_r,
440                                        params.palette_a_g,
441                                        params.palette_a_b,
442                                    ];
443                                    if ui.color_edit_button_rgb(&mut color_a).changed() {
444                                        params.palette_a_r = color_a[0];
445                                        params.palette_a_g = color_a[1];
446                                        params.palette_a_b = color_a[2];
447                                        changed = true;
448                                    }
449                                });
450
451                                ui.horizontal(|ui| {
452                                    ui.label("Amplitude:");
453                                    let mut color_b = [
454                                        params.palette_b_r,
455                                        params.palette_b_g,
456                                        params.palette_b_b,
457                                    ];
458                                    if ui.color_edit_button_rgb(&mut color_b).changed() {
459                                        params.palette_b_r = color_b[0];
460                                        params.palette_b_g = color_b[1];
461                                        params.palette_b_b = color_b[2];
462                                        changed = true;
463                                    }
464                                });
465
466                                ui.horizontal(|ui| {
467                                    ui.label("Frequency:");
468                                    let mut color_c = [
469                                        params.palette_c_r,
470                                        params.palette_c_g,
471                                        params.palette_c_b,
472                                    ];
473                                    if ui.color_edit_button_rgb(&mut color_c).changed() {
474                                        params.palette_c_r = color_c[0];
475                                        params.palette_c_g = color_c[1];
476                                        params.palette_c_b = color_c[2];
477                                        changed = true;
478                                    }
479                                });
480
481                                ui.horizontal(|ui| {
482                                    ui.label("Phase:");
483                                    let mut color_d = [
484                                        params.palette_d_r,
485                                        params.palette_d_g,
486                                        params.palette_d_b,
487                                    ];
488                                    if ui.color_edit_button_rgb(&mut color_d).changed() {
489                                        params.palette_d_r = color_d[0];
490                                        params.palette_d_g = color_d[1];
491                                        params.palette_d_b = color_d[2];
492                                        changed = true;
493                                    }
494                                });
495                                if ui.button("Reset to Default Palette").clicked() {
496                                    params.palette_a_r = 0.5;
497                                    params.palette_a_g = 0.5;
498                                    params.palette_a_b = 0.5;
499                                    params.palette_b_r = 0.5;
500                                    params.palette_b_g = 0.1;
501                                    params.palette_b_b = 0.1;
502                                    params.palette_c_r = 1.0;
503                                    params.palette_c_g = 1.0;
504                                    params.palette_c_b = 1.0;
505                                    params.palette_d_r = 0.0;
506                                    params.palette_d_g = 0.33;
507                                    params.palette_d_b = 0.67;
508                                    changed = true;
509                                }
510
511                                ui.separator();
512                            });
513
514                        ui.separator();
515
516                        ShaderControls::render_controls_widget(ui, &mut controls_request);
517
518                        ui.separator();
519
520                        should_start_export =
521                            ExportManager::render_export_ui_widget(ui, &mut export_request);
522
523                        ui.separator();
524                        ui.label(format!("Accumulated Samples: {}", self.frame_count));
525                        ui.label(format!(
526                            "Resolution: {}x{}",
527                            core.size.width, core.size.height
528                        ));
529                        ui.label(format!("FPS: {current_fps:.1}"));
530                    });
531            })
532        } else {
533            self.base.render_ui(core, |_ctx| {})
534        };
535
536        self.base.export_manager.apply_ui_request(export_request);
537        if controls_request.should_clear_buffers || self.should_reset_accumulation {
538            self.reset_accumulation();
539        }
540        self.base.apply_control_request(controls_request);
541
542        let current_time = self.base.controls.get_time(&self.base.start_time);
543
544        self.base.time_uniform.data.time = current_time;
545        self.base.time_uniform.data.frame = self.frame_count;
546        self.base.time_uniform.update(&core.queue);
547
548        // Update compute shader with the same time data
549        self.compute_shader
550            .set_time(current_time, 1.0 / 60.0, &core.queue);
551        self.compute_shader.time_uniform.data.frame = self.frame_count;
552        self.compute_shader.time_uniform.update(&core.queue);
553
554        if changed {
555            self.current_params = params;
556            self.should_reset_accumulation = true;
557        }
558
559        self.current_params.rotation_x = self.accumulated_rotation[0];
560        self.current_params.rotation_y = -self.accumulated_rotation[1];
561        self.current_params.rotation_z = self.accumulated_rotation[2];
562        self.current_params.zoom = self.accumulated_zoom;
563        self.compute_shader.set_custom_params(self.current_params, &core.queue);
564
565        if should_start_export {
566            self.base.export_manager.start_export();
567        }
568
569        self.compute_shader.dispatch(&mut frame.encoder, core);
570
571        self.base.renderer.render_to_view(&mut frame.encoder, &frame.view, &self.compute_shader.get_output_texture().bind_group);
572
573        self.base.end_frame(core, frame, full_output);
574
575        if self.current_params.accumulate > 0 {
576            self.frame_count += 1;
577        }
578
579        Ok(())
580    }
581
582    fn handle_input(&mut self, core: &Core, event: &WindowEvent) -> bool {
583        if self
584            .base
585            .egui_state
586            .on_window_event(core.window(), event)
587            .consumed
588        {
589            return true;
590        }
591
592        if self.base.handle_mouse_input(core, event, false) {
593            return true;
594        }
595
596        if let WindowEvent::KeyboardInput { event, .. } = event {
597            if let winit::keyboard::Key::Character(ch) = &event.logical_key {
598                match ch.as_str() {
599                    " " => {
600                        if event.state == winit::event::ElementState::Released {
601                            self.current_params.accumulate = 1 - self.current_params.accumulate;
602                            self.should_reset_accumulation = true;
603                            self.compute_shader
604                                .set_custom_params(self.current_params, &core.queue);
605                            return true;
606                        }
607                    }
608                    "m" | "M" => {
609                        if event.state == winit::event::ElementState::Released {
610                            self.mouse_enabled = !self.mouse_enabled;
611                            self.mouse_initialized = false;
612                            return true;
613                        }
614                    }
615                    "w" | "W" => {
616                        if event.state == winit::event::ElementState::Pressed {
617                            self.accumulated_rotation[1] -= 0.1;
618                            self.should_reset_accumulation = true;
619                            return true;
620                        }
621                    }
622                    "s" | "S" => {
623                        if event.state == winit::event::ElementState::Pressed {
624                            self.accumulated_rotation[1] += 0.1;
625                            self.should_reset_accumulation = true;
626                            return true;
627                        }
628                    }
629                    "a" | "A" => {
630                        if event.state == winit::event::ElementState::Pressed {
631                            self.accumulated_rotation[0] -= 0.1;
632                            self.should_reset_accumulation = true;
633                            return true;
634                        }
635                    }
636                    "d" | "D" => {
637                        if event.state == winit::event::ElementState::Pressed {
638                            self.accumulated_rotation[0] += 0.1;
639                            self.should_reset_accumulation = true;
640                            return true;
641                        }
642                    }
643                    "q" | "Q" => {
644                        if event.state == winit::event::ElementState::Pressed {
645                            self.accumulated_rotation[2] -= 0.1;
646                            self.should_reset_accumulation = true;
647                            return true;
648                        }
649                    }
650                    "e" | "E" => {
651                        if event.state == winit::event::ElementState::Pressed {
652                            self.accumulated_rotation[2] += 0.1;
653                            self.should_reset_accumulation = true;
654                            return true;
655                        }
656                    }
657                    _ => {}
658                }
659            }
660        }
661
662        if let WindowEvent::KeyboardInput { event, .. } = event {
663            if self
664                .base
665                .key_handler
666                .handle_keyboard_input(core.window(), event)
667            {
668                return true;
669            }
670        }
671
672        false
673    }
674}
675
676fn main() -> Result<(), Box<dyn std::error::Error>> {
677    env_logger::init();
678    let (app, event_loop) = cuneus::ShaderApp::new("Mandelbulb Path Tracer", 600, 400);
679
680    app.run(event_loop, MandelbulbShader::init)
681}