Shared Helpers

The windowed examples share a few modules. They are not registered as [[example]] targets; each example pulls them in with mod common; and friends.

examples/common.rs

Run limits (GOLDY_EXAMPLE_TIMEOUT / EXAMPLE_TIMEOUT), the trailing FPS window used by the GOLDY_PERF line, hidden-window creation so the first frame is never a blank flash, and render_pipeline / render_pipeline_for_surface, which rebuild a pipeline against the current colour-target format.

Present and readback stay in each example (SurfaceExchange::bind_render_target / bind_destination and (&mut submission >> &present).take()?, or (&mut submission >> &readback).take::<u8>() when GOLDY_EXAMPLE_CAPTURE is set). CaptureDump is only the packed-RGBA file that scripts/record_example_captures.sh stitches with ffmpeg. Optional: GOLDY_EXAMPLE_CAPTURE_FRAMES (default 75), GOLDY_EXAMPLE_CAPTURE_FPS (default 15), GOLDY_EXAMPLE_CAPTURE_WIDTH / HEIGHT (default 640×480).

//! Shared helpers for interactive examples (run limits, perf reporting, book captures).
//!
//! Set `GOLDY_EXAMPLE_CAPTURE` to a raw-RGBA output path to render headlessly
//! into pixels that `scripts/record_example_captures.sh` stitches with ffmpeg.
//! Optional: `GOLDY_EXAMPLE_CAPTURE_FRAMES` (default 75), `GOLDY_EXAMPLE_CAPTURE_FPS`
//! (default 15), `GOLDY_EXAMPLE_CAPTURE_WIDTH` / `HEIGHT` (default 640×480).
//!
//! Capture is file I/O plus a retained RGBA texture. Present still goes through
//! [`SurfaceExchange`] / [`MemoryExchange`] in each example.

use goldy::{
    RenderPipeline, RenderPipelineDesc, Runtime, ShaderModule, SurfaceExchange, Texture, TextureFlags, TextureFormat,
    TextureKind,
};
use std::fs::File;
use std::io::{BufWriter, Write};
use std::path::PathBuf;
use std::time::{Duration, Instant};
use winit::event_loop::ActiveEventLoop;
use winit::window::{Window, WindowAttributes};

/// Window attributes for examples that reveal only after the first frame is ready.
#[allow(dead_code)]
pub fn hidden_window(title: impl Into<String>, width: u32, height: u32) -> WindowAttributes {
    Window::default_attributes()
        .with_title(title.into())
        .with_inner_size(winit::dpi::LogicalSize::new(width, height))
        .with_visible(false)
}

/// Show a window after GPU init and an initial present path have completed.
pub fn reveal_window(window: &Window) {
    window.set_visible(true);
}

/// Rolling frame timestamps for windowed FPS (e.g. last 5s at exit).
#[allow(dead_code)]
pub struct FpsWindow {
    window: Duration,
    frames: Vec<Instant>,
}

#[allow(dead_code)]
impl FpsWindow {
    pub fn new(window_secs: f64) -> Self {
        Self {
            window: Duration::from_secs_f64(window_secs),
            frames: Vec::new(),
        }
    }

    pub fn record(&mut self, now: Instant) {
        self.prune(now);
        self.frames.push(now);
    }

    fn prune(&mut self, now: Instant) {
        let cutoff = now.checked_sub(self.window).unwrap_or(now);
        let keep_from = self.frames.partition_point(|t| *t < cutoff);
        if keep_from > 0 {
            self.frames.drain(..keep_from);
        }
    }

    /// Returns `(frames_in_window, window_span_secs, fps)` for the trailing window.
    pub fn stats(&mut self, now: Instant) -> Option<(u64, f64, f64)> {
        self.prune(now);
        let n = self.frames.len();
        if n == 0 {
            return None;
        }
        let span = now.duration_since(self.frames[0]).as_secs_f64();
        if span <= 0.0 {
            return None;
        }
        Some((n as u64, span, n as f64 / span))
    }
}

/// Build or rebuild a render pipeline for a colour-target format.
#[allow(dead_code)]
pub fn render_pipeline(
    device: &Runtime,
    shader: &ShaderModule,
    format: TextureFormat,
    desc: RenderPipelineDesc,
) -> anyhow::Result<RenderPipeline> {
    RenderPipeline::new(
        device,
        shader,
        shader,
        &RenderPipelineDesc {
            target_format: format,
            ..desc
        },
    )
}

/// Build or rebuild a render pipeline using the surface's current format.
#[allow(dead_code)]
pub fn render_pipeline_for_surface(
    device: &Runtime,
    shader: &ShaderModule,
    surface: &SurfaceExchange,
    desc: RenderPipelineDesc,
) -> anyhow::Result<RenderPipeline> {
    render_pipeline(device, shader, surface.format(), desc)
}

/// True when this process should dump frames instead of opening a window.
pub fn capture_requested() -> bool {
    match std::env::var("GOLDY_EXAMPLE_CAPTURE") {
        Ok(path) => !path.is_empty(),
        Err(_) => false,
    }
}

fn env_u32(key: &str, default: u32) -> u32 {
    std::env::var(key)
        .ok()
        .and_then(|raw| raw.parse().ok())
        .filter(|n| *n > 0)
        .unwrap_or(default)
}

fn env_f32(key: &str, default: f32) -> f32 {
    std::env::var(key)
        .ok()
        .and_then(|raw| raw.parse().ok())
        .filter(|n| *n > 0.0)
        .unwrap_or(default)
}

/// Packed-RGBA dump for mdBook clips (`GOLDY_EXAMPLE_CAPTURE`). Not a Goldy type.
pub struct CaptureDump {
    path: PathBuf,
    writer: Option<BufWriter<File>>,
    last: Option<Vec<u8>>,
    fps: f32,
    frames: u32,
    written: u32,
    width: u32,
    height: u32,
}

#[allow(dead_code)]
impl CaptureDump {
    fn capture_size() -> (u32, u32, u32, f32) {
        (
            env_u32("GOLDY_EXAMPLE_CAPTURE_WIDTH", 640),
            env_u32("GOLDY_EXAMPLE_CAPTURE_HEIGHT", 480),
            env_u32("GOLDY_EXAMPLE_CAPTURE_FRAMES", 75),
            env_f32("GOLDY_EXAMPLE_CAPTURE_FPS", 15.0),
        )
    }

    /// File dump from `GOLDY_EXAMPLE_CAPTURE`.
    pub fn from_env() -> anyhow::Result<Self> {
        let path = std::env::var("GOLDY_EXAMPLE_CAPTURE").expect("GOLDY_EXAMPLE_CAPTURE");
        let path = PathBuf::from(path);
        if let Some(parent) = path.parent() {
            if !parent.as_os_str().is_empty() {
                std::fs::create_dir_all(parent)?;
            }
        }
        let (width, height, frames, fps) = Self::capture_size();
        let file = File::create(&path)?;
        Ok(Self {
            path,
            writer: Some(BufWriter::new(file)),
            last: None,
            fps,
            frames,
            written: 0,
            width,
            height,
        })
    }

    /// In-memory frames only (e.g. `multi_window` panels before hstack).
    pub fn memory(width: u32, height: u32) -> Self {
        let fps = env_f32("GOLDY_EXAMPLE_CAPTURE_FPS", 15.0);
        Self {
            path: PathBuf::new(),
            writer: None,
            last: None,
            fps,
            frames: u32::MAX,
            written: 0,
            width,
            height,
        }
    }

    pub fn width(&self) -> u32 {
        self.width
    }

    pub fn height(&self) -> u32 {
        self.height
    }

    pub fn size(&self) -> (u32, u32) {
        (self.width, self.height)
    }

    pub fn format() -> TextureFormat {
        TextureFormat::Rgba8Unorm
    }

    /// Virtual clock `written / fps` so clips are deterministic.
    pub fn time(&self) -> f32 {
        self.written as f32 / self.fps
    }

    pub fn dt(&self) -> f32 {
        1.0 / self.fps
    }

    pub fn finished(&self) -> bool {
        self.written >= self.frames
    }

    pub fn write_rgba(&mut self, pixels: &[u8]) -> anyhow::Result<()> {
        let expected = (self.width as usize)
            .saturating_mul(self.height as usize)
            .saturating_mul(4);
        anyhow::ensure!(
            pixels.len() == expected,
            "capture frame is {} bytes, expected {expected} ({}x{} rgba)",
            pixels.len(),
            self.width,
            self.height
        );
        if let Some(file) = &mut self.writer {
            file.write_all(pixels)?;
        }
        self.last = Some(pixels.to_vec());
        self.written += 1;
        if let Some(file) = &mut self.writer {
            if self.written >= self.frames {
                file.flush()?;
                println!(
                    "GOLDY_CAPTURE: wrote {} frames ({}x{} rgba) to {}",
                    self.written,
                    self.width,
                    self.height,
                    self.path.display()
                );
            }
        }
        Ok(())
    }

    pub fn take_rgba(&mut self) -> Option<Vec<u8>> {
        self.last.take()
    }
}

/// Retained RGBA8 texture for `copy_to_texture` plus a host claim on the capture path.
#[allow(dead_code)]
pub fn capture_readback(device: &Runtime, width: u32, height: u32) -> anyhow::Result<Texture> {
    device.acquire_texture(
        width,
        height,
        TextureFormat::Rgba8Unorm,
        TextureKind::Direct,
        TextureFlags::COPY_SRC | TextureFlags::COPY_DST,
        None,
    )
}

/// Horizontal concat of equal-sized packed RGBA panels (for `multi_window` capture).
#[allow(dead_code)]
pub fn hstack_rgba(panels: &[&[u8]], width: u32, height: u32) -> anyhow::Result<Vec<u8>> {
    let row_bytes = width as usize * 4;
    let panel_bytes = row_bytes * height as usize;
    for (i, panel) in panels.iter().enumerate() {
        anyhow::ensure!(
            panel.len() == panel_bytes,
            "hstack panel {i} is {} bytes, expected {panel_bytes}",
            panel.len()
        );
    }
    let n = panels.len();
    let mut out = vec![0u8; panel_bytes.saturating_mul(n)];
    for y in 0..height as usize {
        for (i, panel) in panels.iter().enumerate() {
            let src = y * row_bytes;
            let dst = y * row_bytes * n + i * row_bytes;
            out[dst..dst + row_bytes].copy_from_slice(&panel[src..src + row_bytes]);
        }
    }
    Ok(out)
}

/// Run limit in seconds from `GOLDY_EXAMPLE_TIMEOUT` or `EXAMPLE_TIMEOUT`.
pub fn run_limit_secs() -> Option<f64> {
    for key in ["GOLDY_EXAMPLE_TIMEOUT", "EXAMPLE_TIMEOUT"] {
        if let Ok(raw) = std::env::var(key) {
            if let Ok(secs) = raw.parse::<f64>() {
                if secs > 0.0 {
                    return Some(secs);
                }
            }
        }
    }
    None
}

/// Exit the event loop once the run limit elapses so `Drop` can print `GOLDY_PERF`.
pub fn exit_if_timed_out(event_loop: &ActiveEventLoop, start: Instant) {
    if let Some(limit) = run_limit_secs() {
        if start.elapsed() >= Duration::from_secs_f64(limit) {
            event_loop.exit();
        }
    }
}

examples/digital_clock_shared.rs

Seven-segment digit geometry for digital_clock.

//! Shared digital-clock rendering helpers for the `digital_clock` example.

use goldy::types::Color;

/// Vertex with 2D position and RGBA color.
#[goldy::gpu]
#[derive(Debug)]
pub struct ClockVertex {
    pub position: [f32; 2],
    pub color: [f32; 4],
}

impl ClockVertex {
    pub const fn new(x: f32, y: f32, color: Color) -> Self {
        Self {
            position: [x, y],
            color: [color.r, color.g, color.b, color.a],
        }
    }
}

/// Seven-segment display patterns.
/// Order: top, top-left, top-right, middle, bottom-left, bottom-right, bottom
pub const SEGMENT_PATTERNS: [[bool; 7]; 11] = [
    [true, true, true, false, true, true, true],       // 0
    [false, false, true, false, false, true, false],   // 1
    [true, false, true, true, true, false, true],      // 2
    [true, false, true, true, false, true, true],      // 3
    [false, true, true, true, false, true, false],     // 4
    [true, true, false, true, false, true, true],      // 5
    [true, true, false, true, true, true, true],       // 6
    [true, false, true, false, false, true, false],    // 7
    [true, true, true, true, true, true, true],        // 8
    [true, true, true, true, false, true, true],       // 9
    [false, false, false, false, false, false, false], // 10 = blank (for colon position)
];

pub const COLORS: [Color; 8] = [
    Color {
        r: 0.2,
        g: 1.0,
        b: 0.3,
        a: 1.0,
    },
    Color {
        r: 1.0,
        g: 0.3,
        b: 0.2,
        a: 1.0,
    },
    Color {
        r: 1.0,
        g: 0.6,
        b: 0.0,
        a: 1.0,
    },
    Color {
        r: 1.0,
        g: 1.0,
        b: 0.2,
        a: 1.0,
    },
    Color {
        r: 0.2,
        g: 1.0,
        b: 1.0,
        a: 1.0,
    },
    Color {
        r: 0.4,
        g: 0.6,
        b: 1.0,
        a: 1.0,
    },
    Color {
        r: 0.8,
        g: 0.3,
        b: 1.0,
        a: 1.0,
    },
    Color {
        r: 1.0,
        g: 0.4,
        b: 0.8,
        a: 1.0,
    },
];

pub fn quad_vertices(x: f32, y: f32, w: f32, h: f32, color: Color) -> [ClockVertex; 6] {
    [
        ClockVertex::new(x, y, color),
        ClockVertex::new(x + w, y, color),
        ClockVertex::new(x + w, y + h, color),
        ClockVertex::new(x, y, color),
        ClockVertex::new(x + w, y + h, color),
        ClockVertex::new(x, y + h, color),
    ]
}

pub fn pixel_to_ndc(px: f32, py: f32, width: f32, height: f32) -> (f32, f32) {
    let x = (px / width) * 2.0 - 1.0;
    let y = 1.0 - (py / height) * 2.0;
    (x, y)
}

pub fn digit_vertices(
    digit: u8,
    cx: f32,
    cy: f32,
    scale: f32,
    color: Color,
    width: f32,
    height: f32,
) -> Vec<ClockVertex> {
    let mut vertices = Vec::new();

    let seg_w = 60.0 * scale;
    let seg_h = 12.0 * scale;
    let dig_h = 120.0 * scale;
    let gap = 4.0 * scale;

    if digit == 10 {
        let dot_size = seg_h * 1.5;
        let dot_spacing = dig_h * 0.5;

        let (x, y) = pixel_to_ndc(cx - dot_size / 2.0, cy - dot_spacing - dot_size / 2.0, width, height);
        let (w, h) = (dot_size / width * 2.0, dot_size / height * 2.0);
        vertices.extend_from_slice(&quad_vertices(x, y, w, -h, color));

        let (x, y) = pixel_to_ndc(cx - dot_size / 2.0, cy + dot_spacing - dot_size / 2.0, width, height);
        vertices.extend_from_slice(&quad_vertices(x, y, w, -h, color));

        return vertices;
    }

    let pattern = SEGMENT_PATTERNS[digit as usize];

    let mut add_segment = |px: f32, py: f32, pw: f32, ph: f32| {
        let (x, y) = pixel_to_ndc(px, py, width, height);
        let (w, h) = (pw / width * 2.0, ph / height * 2.0);
        vertices.extend_from_slice(&quad_vertices(x, y, w, -h, color));
    };

    if pattern[0] {
        add_segment(cx - seg_w / 2.0, cy - dig_h, seg_w, seg_h);
    }
    if pattern[1] {
        add_segment(
            cx - seg_w / 2.0 - seg_h,
            cy - dig_h + seg_h + gap,
            seg_h,
            dig_h - seg_h - gap * 2.0,
        );
    }
    if pattern[2] {
        add_segment(
            cx + seg_w / 2.0,
            cy - dig_h + seg_h + gap,
            seg_h,
            dig_h - seg_h - gap * 2.0,
        );
    }
    if pattern[3] {
        add_segment(cx - seg_w / 2.0, cy - seg_h / 2.0, seg_w, seg_h);
    }
    if pattern[4] {
        add_segment(cx - seg_w / 2.0 - seg_h, cy + gap, seg_h, dig_h - seg_h - gap * 2.0);
    }
    if pattern[5] {
        add_segment(cx + seg_w / 2.0, cy + gap, seg_h, dig_h - seg_h - gap * 2.0);
    }
    if pattern[6] {
        add_segment(cx - seg_w / 2.0, cy + dig_h - seg_h, seg_w, seg_h);
    }

    vertices
}

#[derive(Debug, Clone, Copy, Default)]
pub struct TimeData {
    pub hours: u8,
    pub minutes: u8,
    pub seconds: u8,
}

impl TimeData {
    pub fn from_elapsed_secs(elapsed: u64) -> Self {
        Self {
            hours: ((elapsed / 3600) % 100) as u8,
            minutes: ((elapsed % 3600) / 60) as u8,
            seconds: (elapsed % 60) as u8,
        }
    }

    pub fn to_digits(&self) -> [u8; 8] {
        [
            self.hours / 10,
            self.hours % 10,
            10,
            self.minutes / 10,
            self.minutes % 10,
            10,
            self.seconds / 10,
            self.seconds % 10,
        ]
    }
}

pub fn generate_clock_vertices(time: TimeData, color: Color, width: u32, height: u32) -> Vec<ClockVertex> {
    let digits = time.to_digits();

    let scale = height as f32 / 720.0;
    let digit_width = 80.0 * scale;
    let colon_width = 40.0 * scale;
    let spacing = 20.0 * scale;

    let total_width = digit_width * 6.0 + colon_width * 2.0 + spacing * 7.0;

    let cy = height as f32 / 2.0;
    let mut cx = (width as f32 - total_width) / 2.0 + digit_width / 2.0;

    let mut all_vertices = Vec::new();

    for &digit in digits.iter() {
        let w = if digit == 10 { colon_width } else { digit_width };
        let verts = digit_vertices(digit, cx, cy, scale, color, width as f32, height as f32);
        all_vertices.extend_from_slice(&verts);
        cx += w + spacing;
    }

    all_vertices
}

#[allow(dead_code)]
#[derive(Debug, Clone, Default)]
pub struct ClockState {
    pub color_index: usize,
    pub paused: bool,
    pub accumulated_secs: u64,
}

#[allow(dead_code)]
impl ClockState {
    pub fn color(&self) -> Color {
        let mut color = COLORS[self.color_index];
        if self.paused {
            color.r *= 0.5;
            color.g *= 0.5;
            color.b *= 0.5;
        }
        color
    }

    pub fn background_color(&self) -> Color {
        let bg = if self.paused { 0.06 } else { 0.02 };
        Color {
            r: bg,
            g: bg,
            b: bg,
            a: 1.0,
        }
    }

    pub fn next_color(&mut self) {
        self.color_index = (self.color_index + 1) % COLORS.len();
    }

    pub fn toggle_pause(&mut self, current_elapsed: u64) {
        if self.paused {
            self.paused = false;
        } else {
            self.accumulated_secs = current_elapsed;
            self.paused = true;
        }
    }
}

examples/instance2d.rs

The per-instance struct for instancing, laid out to match QuadInstance in instancing_update.slang and instancing_render.slang.

//! Per-instance data for the instancing example.

#[goldy::gpu]
#[derive(Debug, Default)]
pub struct Instance2D {
    pub position: [f32; 2],
    pub rotation: f32,
    pub scale: f32,
    pub color: [f32; 4],
}

impl Instance2D {
    pub const fn new(x: f32, y: f32, rotation: f32, scale: f32, color: [f32; 4]) -> Self {
        Self {
            position: [x, y],
            rotation,
            scale,
            color,
        }
    }
}