feat: bind GUI real time rendering behind a feature

This commit is contained in:
2026-09-27 14:43:23 +02:00
parent 4212839f0d
commit d78594af7e
8 changed files with 65 additions and 14 deletions
+1
View File
@@ -25,6 +25,7 @@ cargo test # reference-orbit math, share-link round-trip, WGSL
cargo test --test shader_valid # just the WGSL parse/validate tests (naga, no GPU needed)
cargo clippy
cargo fmt # rustfmt.toml just pins edition = "2024"
cargo build --release --no-default-features # headless-only binary: no eframe/egui (the default `gui` feature)
```
Web build (WebGPU):
+9 -2
View File
@@ -3,11 +3,18 @@ name = "mandelbrot"
version = "0.1.0"
edition = "2024"
[features]
default = ["gui"]
# Windowed egui app. Without it only `--headless` rendering is built.
gui = ["dep:eframe", "dep:egui"]
[dependencies]
bytemuck = { version = "1.25.2", features = ["derive"] }
dashu-float = "0.6.0"
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"] }
egui = "0.36.2"
eframe = { version = "0.36.2", default-features = false, features = ["wgpu", "default_fonts", "x11", "wayland", "accesskit"], optional = true }
egui = { version = "0.36.2", optional = true }
ecolor = { version = "0.36.2", features = ["bytemuck"] }
wgpu = "30.0.1"
glam = "0.33.8"
log = "0.4.34"
png = "0.18.1"
+24 -6
View File
@@ -1,9 +1,9 @@
use std::sync::{Arc, Mutex};
#[cfg(feature = "gui")]
use eframe::CreationContext;
#[cfg(feature = "gui")]
use eframe::egui_wgpu;
#[cfg(target_arch = "wasm32")]
use eframe::egui_wgpu::wgpu;
use glam::Vec4;
use glam::Vec4Swizzles;
@@ -316,6 +316,7 @@ impl ConstOrbit {
/// Checkbox + speed/radius sliders; (re)centers the orbit on `current`
/// when switched on.
#[cfg(feature = "gui")]
fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: (f64, f64)) {
if ui.checkbox(&mut self.on, format!("Morph {name}")).changed() && self.on {
self.enable(current);
@@ -371,6 +372,7 @@ impl AxisOsc {
self.base + self.amplitude * self.phase.sin()
}
#[cfg(feature = "gui")]
fn ui(&mut self, ui: &mut egui::Ui, name: &str, current: f64) {
if ui
.checkbox(&mut self.on, format!("Animate {name}"))
@@ -594,6 +596,7 @@ pub struct FractalApp {
/// PNG export resolution multiplier over the on-screen size.
export_scale: f32,
/// Last on-screen fractal size in physical pixels (for export sizing).
#[cfg(feature = "gui")]
last_size_px: egui::Vec2,
/// egui time (seconds) of the most recent pan/zoom. While recent (within
/// `INTERACT_SETTLE`) the fractal renders downscaled for smooth interaction.
@@ -646,6 +649,7 @@ fn parse_bits_for(s: &str, min_bits: usize) -> usize {
}
impl FractalApp {
#[cfg(feature = "gui")]
pub fn new(cc: &CreationContext<'_>) -> Self {
let render_state = cc
.wgpu_render_state
@@ -740,6 +744,7 @@ impl FractalApp {
pending: false,
export_scale: 2.0,
render_scale_3d: 2.0,
#[cfg(feature = "gui")]
last_size_px: egui::vec2(1280.0, 720.0),
last_interact_time: -1.0e9,
export_requested: false,
@@ -1414,6 +1419,7 @@ impl FractalApp {
/// 3D-mode zoom toward the screen point `off` (points from the widget
/// center): unproject it through the camera onto the z = 0 fractal plane,
/// then zoom the 2D view about the matching fractal-texture pixel.
#[cfg(feature = "gui")]
fn zoom_3d_at(&mut self, off: egui::Vec2, rect: egui::Rect, height_px: f64, factor: f64) {
let ndc = (off / rect.size()) * 2.;
let camera_ndc_pos = self
@@ -1515,6 +1521,7 @@ impl FractalApp {
/// Render the current view to a PNG at `export_scale` × the on-screen size,
/// then save it (native: file in cwd; web: browser download). Runs off the
/// UI thread so a progress bar can animate; progress lands in `self.export`.
#[cfg(feature = "gui")]
fn do_export(&mut self, frame: &mut eframe::Frame) {
if self.export.is_some() {
return; // one export at a time
@@ -1641,6 +1648,7 @@ impl FractalApp {
/// Pick up a finished export (setting the status line) and keep repainting
/// while one is in flight so its progress bar animates.
#[cfg(feature = "gui")]
fn poll_export(&mut self, ctx: &egui::Context) {
if let Some(shared) = &self.export {
let done = shared.lock().unwrap().result.take();
@@ -1661,6 +1669,7 @@ impl FractalApp {
/// Floating top-left overlay with the panel toggle and fullscreen toggle.
/// Always on top of the fractal, so both stay reachable when the controls
/// panel is collapsed (the common case on a phone).
#[cfg(feature = "gui")]
fn overlay_buttons(&mut self, ui: &mut egui::Ui) {
egui::Area::new(egui::Id::new("overlay_buttons"))
.anchor(egui::Align2::LEFT_TOP, egui::vec2(8.0, 8.0))
@@ -1719,6 +1728,7 @@ impl FractalApp {
/// "Fractal Info" window. Kept separate from `overlay_buttons` (top-left)
/// so it stays out of the way of the panel toggle / fullscreen controls,
/// but is still reachable even when the controls panel is collapsed.
#[cfg(feature = "gui")]
fn info_button(&mut self, ui: &mut egui::Ui) {
egui::Area::new(egui::Id::new("info_button"))
.anchor(egui::Align2::LEFT_BOTTOM, egui::vec2(8.0, -8.0))
@@ -1740,6 +1750,7 @@ impl FractalApp {
/// Window with details about what's currently on screen: formula, active
/// per-kind constants, zoom depth, iteration count. Reads live state, so
/// it stays correct as the user pans/zooms/switches kinds.
#[cfg(feature = "gui")]
fn info_window(&mut self, ctx: &egui::Context) {
let mut open = self.info_open;
egui::Window::new("Fractal Info")
@@ -1796,6 +1807,7 @@ impl FractalApp {
/// Help window: what the app does, plus a reference for mouse/touch and
/// keyboard controls.
#[cfg(feature = "gui")]
fn help_window(&mut self, ctx: &egui::Context) {
let mut open = self.help_open;
egui::Window::new("Help")
@@ -1886,7 +1898,7 @@ impl FractalApp {
}
/// Push the desired fullscreen state to the platform.
#[cfg(not(target_arch = "wasm32"))]
#[cfg(all(feature = "gui", not(target_arch = "wasm32")))]
fn apply_fullscreen(&mut self, ctx: &egui::Context) {
ctx.send_viewport_cmd(egui::ViewportCommand::Fullscreen(self.fullscreen));
}
@@ -1894,7 +1906,7 @@ impl FractalApp {
/// Push the desired fullscreen state to the browser. `request_fullscreen`
/// must run inside a user gesture; the button click provides the transient
/// activation that carries into this frame.
#[cfg(target_arch = "wasm32")]
#[cfg(all(feature = "gui", target_arch = "wasm32"))]
fn apply_fullscreen(&mut self, _ctx: &egui::Context) {
let Some(doc) = web_sys::window().and_then(|w| w.document()) else {
return;
@@ -1910,14 +1922,14 @@ impl FractalApp {
/// Refresh `self.fullscreen` from the real platform state, so the label is
/// correct even when fullscreen is left by Esc/F11 or the browser UI.
#[cfg(not(target_arch = "wasm32"))]
#[cfg(all(feature = "gui", not(target_arch = "wasm32")))]
fn sync_fullscreen(&mut self, ctx: &egui::Context) {
if let Some(fs) = ctx.input(|i| i.viewport().fullscreen) {
self.fullscreen = fs;
}
}
#[cfg(target_arch = "wasm32")]
#[cfg(all(feature = "gui", target_arch = "wasm32"))]
fn sync_fullscreen(&mut self, _ctx: &egui::Context) {
if let Some(doc) = web_sys::window().and_then(|w| w.document()) {
self.fullscreen = doc.fullscreen_element().is_some();
@@ -1929,6 +1941,7 @@ impl FractalApp {
/// divides once the window closes, so the readout is steady rather than
/// jittering every frame. Only advances when egui repaints — i.e. while the
/// app is doing work — so an idle app shows its last measured rate.
#[cfg(feature = "gui")]
fn update_fps(&mut self, ui: &egui::Ui) {
let now = ui.input(|i| i.time);
// Reset the window if time went backwards or hasn't started yet.
@@ -1948,6 +1961,7 @@ impl FractalApp {
/// Advance any enabled animations by the frame's elapsed time, and request a
/// repaint while active. Animations render at full resolution/AA (they do not
/// trigger the interaction low-res pass).
#[cfg(feature = "gui")]
fn tick_animations(&mut self, ui: &egui::Ui) {
// Julia c only matters in Julia mode; Phoenix p only for the Phoenix kind; Lambda λ only for Lambda kind.
let julia_on = self.anim.julia.on && self.mode == FractalMode::Julia;
@@ -2092,6 +2106,7 @@ impl FractalApp {
}
}
#[cfg(feature = "gui")]
fn controls_ui(&mut self, ui: &mut egui::Ui) {
ui.heading("Fractal Explorer");
ui.separator();
@@ -2574,6 +2589,7 @@ impl FractalApp {
/// Controls for Buddhabrot mode: nested iteration caps (Nebulabrot R/G/B
/// coloring), exposure, and the progressive-accumulation toggle.
#[cfg(feature = "gui")]
fn buddhabrot_ui(&mut self, ui: &mut egui::Ui) {
ui.separator();
ui.add(
@@ -2614,6 +2630,7 @@ impl FractalApp {
ui.small("PNG export isn't available in Buddhabrot mode yet.");
}
#[cfg(feature = "gui")]
fn fractal_ui(&mut self, ui: &mut egui::Ui) {
let size = ui.available_size();
let (rect, response) = ui.allocate_exact_size(size, egui::Sense::click_and_drag());
@@ -2939,6 +2956,7 @@ impl FractalApp {
}
}
#[cfg(feature = "gui")]
impl eframe::App for FractalApp {
fn ui(&mut self, ui: &mut egui::Ui, frame: &mut eframe::Frame) {
self.poll_export(ui.ctx());
+3 -1
View File
@@ -5,7 +5,8 @@
use std::collections::HashMap;
use eframe::egui_wgpu::{self, wgpu};
#[cfg(feature = "gui")]
use eframe::egui_wgpu;
/// Random samples dispatched per accumulating frame. Chosen so a frame stays
/// interactive on a modest GPU even when most samples run the full `b_cap`
@@ -336,6 +337,7 @@ pub struct BuddhabrotCallback {
pub size_px: [u32; 2],
}
#[cfg(feature = "gui")]
impl egui_wgpu::CallbackTrait for BuddhabrotCallback {
fn prepare(
&self,
+3 -1
View File
@@ -12,7 +12,8 @@
use std::collections::HashMap;
use std::sync::Arc;
use eframe::egui_wgpu::{self, wgpu};
#[cfg(feature = "gui")]
use eframe::egui_wgpu;
use wgpu::util::DeviceExt as _;
use super::kind::FractalKind;
@@ -1884,6 +1885,7 @@ pub struct FractalCallback {
pub size_px: [u32; 2],
}
#[cfg(feature = "gui")]
impl egui_wgpu::CallbackTrait for FractalCallback {
fn prepare(
&self,
-2
View File
@@ -9,8 +9,6 @@ use std::collections::HashMap;
use std::sync::atomic::{AtomicBool, AtomicUsize, Ordering};
use std::sync::{Mutex, mpsc};
use eframe::egui_wgpu::wgpu;
use crate::app::{FractalApp, RefJob, parse_complex_pair, unix_timestamp};
use crate::cli::Cli;
use crate::fractal::{
+4 -1
View File
@@ -1,7 +1,9 @@
use std::f32::consts::PI;
use bytemuck::{Pod, Zeroable};
use egui::{Color32, Ui};
use ecolor::Color32;
#[cfg(feature = "gui")]
use egui::Ui;
/// Maximum number of simultaneous lights.
pub const MAX_LIGHT_COUNT: usize = 16;
@@ -27,6 +29,7 @@ impl Default for Light {
}
impl Light {
#[cfg(feature = "gui")]
pub fn widget(&mut self, ui: &mut Ui) -> bool {
let formater = |v, _| format!("{}°", ((v * 180. / std::f64::consts::PI) as u32));
let parser = |s: &str| {
+21 -1
View File
@@ -1,6 +1,8 @@
// Without these, rust fails to infer Send/Sync trait impls
// Probably caused by the new trait solver
#![recursion_limit = "256"]
// Without the `gui` feature, UI-only state and helpers go unused.
#![cfg_attr(not(feature = "gui"), allow(dead_code, unused_imports))]
// Fractal Explorer — Rust + wgpu + egui + WGSL deep-zoom Mandelbrot.
//
@@ -21,8 +23,17 @@ mod headless;
#[cfg(not(target_arch = "wasm32"))]
mod worker;
#[cfg(all(target_arch = "wasm32", not(feature = "gui")))]
compile_error!("the web build needs the `gui` feature");
#[cfg(feature = "gui")]
use app::FractalApp;
#[cfg(all(feature = "gui", not(target_arch = "wasm32")))]
type MainResult = eframe::Result;
#[cfg(all(not(feature = "gui"), not(target_arch = "wasm32")))]
type MainResult = Result<(), String>;
/// wgpu configuration for eframe. The fractal fragment shader reads the
/// reference orbit from a **storage buffer**, so the device must allow storage
/// buffers in the fragment stage. eframe's default requests WebGL2-downlevel
@@ -30,6 +41,7 @@ use app::FractalApp;
/// * request the adapter's real limits (which include storage buffers), and
/// * force the WebGPU backend on the web (WebGL2 can't do storage buffers at
/// all) — failing cleanly on browsers without WebGPU, per the design.
#[cfg(feature = "gui")]
fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
use eframe::egui_wgpu::{WgpuSetup, wgpu};
@@ -51,7 +63,7 @@ fn wgpu_options() -> eframe::egui_wgpu::WgpuConfiguration {
}
#[cfg(not(target_arch = "wasm32"))]
fn main() -> eframe::Result {
fn main() -> MainResult {
use clap::Parser as _;
env_logger::builder()
@@ -70,6 +82,13 @@ fn main() -> eframe::Result {
};
}
#[cfg(not(feature = "gui"))]
{
eprintln!("error: built without the \"gui\" feature; only --headless is supported");
std::process::exit(1);
}
#[cfg(feature = "gui")]
let native_options = eframe::NativeOptions {
renderer: eframe::Renderer::Wgpu,
wgpu_options: wgpu_options(),
@@ -80,6 +99,7 @@ fn main() -> eframe::Result {
..Default::default()
};
#[cfg(feature = "gui")]
eframe::run_native(
"Fractal Explorer",
native_options,