multi_window

Three windows — plasma, tunnel, and starfield — sharing one Runtime. Each window owns its own SurfaceExchange, Context, and Scheme, which is the pattern for any multi-surface application.

cargo run --features examples --example multi_window

What it demonstrates

  • Multiple surfaces on a single device
  • One scheme and context per window
  • Independent resize and close handling per window

Controls

KeyAction
SpaceToggle the focused window's effect modifier
RReset the focused window's effect
EscapeClose the focused window; the app exits with the last one

Source

examples/multi_window.rs:

//! Multi-window example - three simultaneous effects in separate windows.
//!
//! Each window runs its own demo with an independent SurfaceExchange + Scheme.
//!
//! Run with: cargo run --example multi_window

use goldy::{
    shaders, Buffer, BufferFlags, BufferKind, Color, DepositTarget, DepositTransaction, Instance, Lease,
    LeaseRenderTarget, MemoryExchange, NodeAccess, RenderPipeline, RenderPipelineDesc, RequestAdapterOptions,
    RuntimeDescriptor, Scheme, ShaderModule, SurfaceConfig, SurfaceExchange, TargetLoad, Texture, TextureFormat,
    Transaction, VertexBufferLayout,
};
mod common;
use common::CaptureDump;
use std::ops::Shr;

const PLASMA_VERTEX_TIME: &str = r#"
struct VertexInput {
    float2 position : POSITION;
    float2 uv : TEXCOORD0;
    float time : TEXCOORD1;
};

struct VertexOutput {
    float4 position : SV_Position;
    float2 uv : TEXCOORD0;
    float time : TEXCOORD1;
};

[shader("vertex")]
VertexOutput vs_main(VertexInput input) {
    VertexOutput output;
    output.position = float4(input.position, 0.0, 1.0);
    output.uv = input.uv;
    output.time = input.time;
    return output;
}

float3 rainbow(float t) {
    float3 c = float3(
        sin(t * 6.28318 + 0.0) * 0.5 + 0.5,
        sin(t * 6.28318 + 2.094) * 0.5 + 0.5,
        sin(t * 6.28318 + 4.189) * 0.5 + 0.5
    );
    return c;
}

[shader("fragment")]
float4 fs_main(VertexOutput input) : SV_Target {
    float2 uv = input.uv * 4.0;
    float t = input.time;
    
    float v = sin(uv.x + t);
    v += sin(uv.y + t);
    v += sin(uv.x + uv.y + t);
    
    float cx = uv.x + 0.5 * sin(t / 3.0);
    float cy = uv.y + 0.5 * cos(t / 2.0);
    v += sin(sqrt(cx * cx + cy * cy + 1.0) + t);
    
    v = v / 2.0;
    
    return float4(rainbow(v), 1.0);
}
"#;

const TUNNEL_VERTEX_TIME: &str = r#"
struct VertexInput {
    float2 position : POSITION;
    float2 uv : TEXCOORD0;
    float time : TEXCOORD1;
};

struct VertexOutput {
    float4 position : SV_Position;
    float2 uv : TEXCOORD0;
    float time : TEXCOORD1;
};

[shader("vertex")]
VertexOutput vs_main(VertexInput input) {
    VertexOutput output;
    output.position = float4(input.position, 0.0, 1.0);
    output.uv = input.uv;
    output.time = input.time;
    return output;
}

[shader("fragment")]
float4 fs_main(VertexOutput input) : SV_Target {
    float2 uv = (input.uv - 0.5) * 2.0;
    float t = input.time;
    
    float dist = length(uv);
    float angle = atan2(uv.y, uv.x);
    
    float tunnel_depth = 1.0 / (dist + 0.1);
    float tunnel_angle = angle / 3.14159 + t * 0.2;
    
    float tx = tunnel_angle * 4.0;
    float ty = tunnel_depth - t * 2.0;
    
    float checker = floor(tx) + floor(ty);
    bool is_white = fmod(checker, 2.0) == 0.0;
    
    float depth_color = 1.0 - dist * 0.5;
    float3 color;
    
    if (is_white) {
        color = float3(0.8, 0.2, 0.4) * depth_color;
    } else {
        color = float3(0.2, 0.4, 0.8) * depth_color;
    }
    
    color += float3(0.3, 0.5, 1.0) * (1.0 - dist) * (1.0 - dist);
    color *= 1.0 - dist * 0.3;
    
    return float4(color, 1.0);
}
"#;

use std::collections::HashMap;
use std::sync::Arc;
use std::time::Instant;
use winit::{
    application::ApplicationHandler,
    dpi::LogicalSize,
    event::{ElementState, MouseButton, WindowEvent},
    event_loop::{ActiveEventLoop, ControlFlow, EventLoop},
    keyboard::{Key, NamedKey},
    window::{Window, WindowAttributes, WindowId},
};
#[goldy::gpu]
struct QuadVertex {
    position: [f32; 2],
    uv: [f32; 2],
    time: f32,
}

impl QuadVertex {
    fn layout() -> VertexBufferLayout {
        Self::GPU_TYPE.vertex_buffer_layout().expect("quad vertex layout")
    }
}

fn create_quad(time: f32) -> [QuadVertex; 6] {
    [
        QuadVertex {
            position: [-1.0, -1.0],
            uv: [0.0, 1.0],
            time,
        },
        QuadVertex {
            position: [1.0, -1.0],
            uv: [1.0, 1.0],
            time,
        },
        QuadVertex {
            position: [1.0, 1.0],
            uv: [1.0, 0.0],
            time,
        },
        QuadVertex {
            position: [-1.0, -1.0],
            uv: [0.0, 1.0],
            time,
        },
        QuadVertex {
            position: [1.0, 1.0],
            uv: [1.0, 0.0],
            time,
        },
        QuadVertex {
            position: [-1.0, 1.0],
            uv: [0.0, 0.0],
            time,
        },
    ]
}

#[derive(Clone, Copy, PartialEq)]
enum EffectType {
    Plasma,
    Tunnel,
    Starfield,
}

impl EffectType {
    fn title(&self) -> &'static str {
        match self {
            EffectType::Plasma => "Plasma [Space=pause, Click=reset]",
            EffectType::Tunnel => "Tunnel [Space=reverse, Click=reset]",
            EffectType::Starfield => "Starfield [Space=warp, Click=reset]",
        }
    }

    fn shader_source(&self) -> &'static str {
        match self {
            EffectType::Plasma => PLASMA_VERTEX_TIME,
            EffectType::Tunnel => TUNNEL_VERTEX_TIME,
            EffectType::Starfield => shaders::STARFIELD,
        }
    }
}

struct WindowState {
    window: Option<Arc<Window>>,
    ctx: goldy::Context,
    surface: Option<SurfaceExchange>,
    present: Option<Transaction>,
    capture: Option<CaptureDump>,
    readback: Option<Texture>,
    scheme: Scheme,
    scene_rt: Lease<LeaseRenderTarget>,
    pipeline: RenderPipeline,
    effect_type: EffectType,
    start_time: Instant,
    paused: bool,
    paused_at: f32,
    time_multiplier: f32,
    vertex_parcel: Buffer,
    upload_scheme: Scheme,
    vertex_deposit: DepositTransaction,
    has_focus: bool,
}

impl WindowState {
    fn create_pipeline(
        device: &goldy::Runtime,
        shader: &ShaderModule,
        format: TextureFormat,
    ) -> anyhow::Result<RenderPipeline> {
        common::render_pipeline(
            device,
            shader,
            format,
            RenderPipelineDesc {
                vertex_layout: QuadVertex::layout(),
                ..Default::default()
            },
        )
    }

    fn record_pass(
        scheme: &mut Scheme,
        pipeline: &RenderPipeline,
        vertex_parcel: &Buffer,
        scene_rt: &Lease<LeaseRenderTarget>,
        label: &'static str,
    ) {
        let mut pass = scheme.render_pass(label, scene_rt, TargetLoad::Clear(Color::BLACK));
        pass.with_parcel(vertex_parcel, NodeAccess::Read);
        pass.set_pipeline(pipeline);
        pass.set_vertex_buffer(0, vertex_parcel);
        pass.draw(0..6, 0..1);
        pass.finish();
    }

    fn bind_frame(
        scheme: &mut Scheme,
        scene_rt: &Lease<LeaseRenderTarget>,
        surface: Option<&SurfaceExchange>,
        readback: Option<&Texture>,
    ) -> anyhow::Result<Option<Transaction>> {
        if let Some(surface) = surface {
            let present = surface.bind_render_target(scheme, scene_rt)?;
            Ok(Some(present))
        } else {
            let readback = readback.expect("capture readback");
            scheme.copy_to_texture(scene_rt, readback)?;

            Ok(None)
        }
    }

    fn output_size(&self) -> (u32, u32) {
        if let Some(surface) = &self.surface {
            surface.size()
        } else {
            self.capture.as_ref().expect("capture").size()
        }
    }

    fn output_format(&self) -> TextureFormat {
        if let Some(surface) = &self.surface {
            surface.format()
        } else {
            CaptureDump::format()
        }
    }

    fn rerecord_scheme(&mut self) {
        let mut scheme = Scheme::new(&self.ctx);
        let (width, height) = self.output_size();
        let format = self.output_format();
        if let Ok(rt) = self.ctx.lease_render_target(width.max(1), height.max(1), format, None) {
            Self::record_pass(
                &mut scheme,
                &self.pipeline,
                &self.vertex_parcel,
                &rt,
                self.effect_type.title(),
            );
            if let Ok(present) = Self::bind_frame(&mut scheme, &rt, self.surface.as_ref(), self.readback.as_ref()) {
                self.present = present;
                self.scene_rt = rt;
                self.scheme = scheme;
            }
        }
    }

    fn windowed(
        window: Arc<Window>,
        ctx: &goldy::Context,
        device: &Arc<goldy::Runtime>,
        effect_type: EffectType,
    ) -> anyhow::Result<Self> {
        let surface = SurfaceExchange::new(ctx, window.as_ref(), SurfaceConfig::default())?;
        let format = surface.format();
        let (width, height) = surface.size();
        let shader = ShaderModule::from_slang(device, effect_type.shader_source())?;
        let pipeline = Self::create_pipeline(device, &shader, format)?;

        let vertex_parcel =
            device.acquire_buffer_sized::<QuadVertex>(6, BufferKind::Scattered, BufferFlags::empty())?;

        let mut scheme = Scheme::new(ctx);
        let scene_rt = ctx.lease_render_target(width.max(1), height.max(1), format, None)?;
        Self::record_pass(&mut scheme, &pipeline, &vertex_parcel, &scene_rt, effect_type.title());
        let present = Self::bind_frame(&mut scheme, &scene_rt, Some(&surface), None)?;

        let mut upload_scheme = Scheme::new(ctx);
        let vertex_deposit = MemoryExchange::new(ctx).bind_deposit(
            &mut upload_scheme,
            DepositTarget::buffer(&vertex_parcel, vertex_parcel.byte_size()),
        )?;

        Ok(Self {
            window: Some(window),
            ctx: ctx.clone(),
            surface: Some(surface),
            present,
            capture: None,
            readback: None,
            scheme,
            scene_rt,
            pipeline,
            effect_type,
            start_time: Instant::now(),
            paused: false,
            paused_at: 0.0,
            time_multiplier: 1.0,
            vertex_parcel,
            upload_scheme,
            vertex_deposit,
            has_focus: false,
        })
    }

    fn capture_panel(
        ctx: &goldy::Context,
        device: &Arc<goldy::Runtime>,
        effect_type: EffectType,
        width: u32,
        height: u32,
    ) -> anyhow::Result<Self> {
        let capture = CaptureDump::memory(width, height);
        let format = CaptureDump::format();
        let readback = common::capture_readback(&device, width, height)?;
        let shader = ShaderModule::from_slang(device, effect_type.shader_source())?;
        let pipeline = Self::create_pipeline(device, &shader, format)?;

        let vertex_parcel =
            device.acquire_buffer_sized::<QuadVertex>(6, BufferKind::Scattered, BufferFlags::empty())?;

        let mut scheme = Scheme::new(ctx);
        let scene_rt = ctx.lease_render_target(width.max(1), height.max(1), format, None)?;
        Self::record_pass(&mut scheme, &pipeline, &vertex_parcel, &scene_rt, effect_type.title());
        let present = Self::bind_frame(&mut scheme, &scene_rt, None, Some(&readback))?;

        let mut upload_scheme = Scheme::new(ctx);
        let vertex_deposit = MemoryExchange::new(ctx).bind_deposit(
            &mut upload_scheme,
            DepositTarget::buffer(&vertex_parcel, vertex_parcel.byte_size()),
        )?;

        Ok(Self {
            window: None,
            ctx: ctx.clone(),
            surface: None,
            present,
            capture: Some(capture),
            readback: Some(readback),
            scheme,
            scene_rt,
            pipeline,
            effect_type,
            start_time: Instant::now(),
            paused: false,
            paused_at: 0.0,
            time_multiplier: 1.0,
            vertex_parcel,
            upload_scheme,
            vertex_deposit,
            has_focus: false,
        })
    }

    fn current_time(&self) -> f32 {
        if let Some(capture) = &self.capture {
            return capture.time();
        }
        if self.paused {
            self.paused_at
        } else {
            self.paused_at + self.start_time.elapsed().as_secs_f32() * self.time_multiplier
        }
    }

    fn toggle_pause(&mut self) {
        if self.paused {
            self.start_time = Instant::now();
            self.paused = false;
        } else {
            self.paused_at = self.current_time();
            self.paused = true;
        }
    }

    fn toggle_effect_modifier(&mut self) {
        match self.effect_type {
            EffectType::Plasma => self.toggle_pause(),
            EffectType::Tunnel => {
                self.paused_at = self.current_time();
                self.start_time = Instant::now();
                self.time_multiplier *= -1.0;
            }
            EffectType::Starfield => {
                self.paused_at = self.current_time();
                self.start_time = Instant::now();
                self.time_multiplier = if self.time_multiplier > 2.0 { 1.0 } else { 5.0 };
            }
        }
    }

    fn reset(&mut self) {
        self.start_time = Instant::now();
        self.paused = false;
        self.paused_at = 0.0;
        self.time_multiplier = 1.0;
    }

    fn render(&mut self, _ctx: &goldy::Context) -> anyhow::Result<()> {
        if let Some(window) = &self.window {
            let size = window.inner_size();
            if size.width == 0 || size.height == 0 {
                return Ok(());
            }
        }

        let vertices = create_quad(self.current_time());
        (&self.vertex_deposit << vertices.as_slice())?;
        self.upload_scheme.submit()?;

        let mut submission = self.scheme.submit()?;
        if let Some(present) = &self.present {
            (&mut submission >> present).take()?;
        } else {
            let pixels = (&mut submission >> self.readback.as_ref().unwrap())
                .take::<u8>()?
                .to_vec();
            self.capture.as_mut().unwrap().write_rgba(&pixels)?;
        }
        Ok(())
    }

    fn handle_resize(&mut self, width: u32, height: u32) {
        if width == 0 || height == 0 {
            return;
        }
        let Some(surface) = &self.surface else {
            return;
        };
        let (prev_w, prev_h) = surface.size();
        // Pipeline does not depend on surface size; skip no-op Resized events
        // (winit often fires these on reveal) so we don't recompile Slang→DXIL.
        if prev_w == width && prev_h == height {
            return;
        }
        let _ = surface.resize(width, height);
        self.rerecord_scheme();
    }
}

struct App {
    instance: Instance,
    ctx: Option<goldy::Context>,
    device: Option<Arc<goldy::Runtime>>,
    windows: HashMap<WindowId, WindowState>,
    effects_to_create: Vec<EffectType>,
    frame_count: u32,
    start_time: std::time::Instant,
}

impl App {
    fn new() -> anyhow::Result<Self> {
        Ok(Self {
            instance: Instance::new()?,
            ctx: None,
            device: None,
            windows: HashMap::new(),
            effects_to_create: vec![EffectType::Plasma, EffectType::Tunnel, EffectType::Starfield],
            frame_count: 0,
            start_time: std::time::Instant::now(),
        })
    }

    fn create_window(
        &mut self,
        event_loop: &ActiveEventLoop,
        effect_type: EffectType,
        position: (i32, i32),
    ) -> anyhow::Result<()> {
        let device = self.device.as_ref().unwrap().clone();
        let ctx = self.ctx.as_ref().unwrap();

        let attrs = WindowAttributes::default()
            .with_title(format!("Goldy - {}", effect_type.title()))
            .with_inner_size(LogicalSize::new(500, 500))
            .with_position(winit::dpi::LogicalPosition::new(position.0, position.1))
            .with_visible(false);

        let window = Arc::new(event_loop.create_window(attrs)?);
        let window_id = window.id();

        let mut state = WindowState::windowed(window.clone(), ctx, &device, effect_type)?;
        state.render(ctx)?;
        common::reveal_window(&window);
        window.request_redraw();

        self.windows.insert(window_id, state);
        Ok(())
    }
}

impl ApplicationHandler for App {
    fn resumed(&mut self, event_loop: &ActiveEventLoop) {
        if self.device.is_none() {
            match self
                .instance
                .request_adapter(&RequestAdapterOptions::default())
                .and_then(|a| a.request_runtime(&RuntimeDescriptor::default()))
            {
                Ok(device) => {
                    let device = Arc::new(device);
                    self.ctx = Some(device.create_context().expect("create context"));
                    self.device = Some(device);
                }
                Err(e) => {
                    tracing::error!("Failed to create device: {}", e);
                    event_loop.exit();
                    return;
                }
            }
        }

        let effects = std::mem::take(&mut self.effects_to_create);
        for (i, effect) in effects.into_iter().enumerate() {
            let x = 50 + (i as i32) * 520;
            let y = 100;

            if let Err(e) = self.create_window(event_loop, effect, (x, y)) {
                tracing::error!("Failed to create window for {:?}: {}", effect.title(), e);
            }
        }
    }

    fn window_event(&mut self, event_loop: &ActiveEventLoop, window_id: WindowId, event: WindowEvent) {
        let state = match self.windows.get_mut(&window_id) {
            Some(s) => s,
            None => return,
        };

        match event {
            WindowEvent::CloseRequested => {
                self.windows.remove(&window_id);
                if self.windows.is_empty() {
                    event_loop.exit();
                }
            }
            WindowEvent::Focused(focused) => {
                state.has_focus = focused;
                if focused {
                    println!(
                        "Focus: {} ({})",
                        state.effect_type.title(),
                        if state.paused { "paused" } else { "running" }
                    );
                }
            }
            WindowEvent::KeyboardInput { event, .. } if event.state == ElementState::Pressed => {
                match event.logical_key {
                    Key::Named(NamedKey::Escape) => {
                        self.windows.remove(&window_id);
                        if self.windows.is_empty() {
                            event_loop.exit();
                        }
                    }
                    Key::Named(NamedKey::Space) => {
                        if let Some(s) = self.windows.get_mut(&window_id) {
                            s.toggle_effect_modifier();
                            println!("[{}] Modifier toggled", s.effect_type.title());
                        }
                    }
                    Key::Character(ref c) if c == "r" || c == "R" => {
                        if let Some(s) = self.windows.get_mut(&window_id) {
                            s.reset();
                            println!("[{}] Reset", s.effect_type.title());
                        }
                    }
                    _ => {}
                }
            }
            WindowEvent::MouseInput {
                state: ElementState::Pressed,
                button: MouseButton::Left,
                ..
            } => {
                if let Some(s) = self.windows.get_mut(&window_id) {
                    s.reset();
                    println!("[{}] Reset (click)", s.effect_type.title());
                }
            }
            WindowEvent::RedrawRequested => {
                let Some(ctx) = self.ctx.clone() else {
                    return;
                };
                if let Some(s) = self.windows.get_mut(&window_id) {
                    if let Err(e) = s.render(&ctx) {
                        tracing::error!("[{}] Render error: {}", s.effect_type.title(), e);
                    }
                }
            }
            WindowEvent::Resized(new_size) => {
                if let Some(s) = self.windows.get_mut(&window_id) {
                    s.handle_resize(new_size.width, new_size.height);
                }
            }
            _ => {}
        }
    }

    fn about_to_wait(&mut self, event_loop: &ActiveEventLoop) {
        common::exit_if_timed_out(event_loop, self.start_time);

        if self.ctx.is_none() {
            return;
        }

        self.frame_count += 1;

        for state in self.windows.values() {
            if let Some(window) = &state.window {
                window.request_redraw();
            }
        }
    }
}

impl Drop for App {
    fn drop(&mut self) {
        let elapsed = self.start_time.elapsed().as_secs_f64();
        let fps = if elapsed > 0.0 {
            self.frame_count as f64 / elapsed
        } else {
            0.0
        };
        println!(
            "GOLDY_PERF: frames={} elapsed={elapsed:.2}s avg_fps={fps:.1}",
            self.frame_count
        );
    }
}

fn capture_panels() -> anyhow::Result<()> {
    let instance = Instance::new()?;
    let device = Arc::new(
        instance
            .request_adapter(&RequestAdapterOptions::default())?
            .request_runtime(&RuntimeDescriptor::default())?,
    );
    let ctx = device.create_context()?;
    let mut output = CaptureDump::from_env()?;
    let (out_w, out_h) = output.size();
    anyhow::ensure!(out_w % 3 == 0, "multi_window capture width must be divisible by 3");
    let panel = (out_w / 3, out_h);
    let effects = [EffectType::Plasma, EffectType::Tunnel, EffectType::Starfield];
    let mut panels = Vec::new();
    for effect in effects {
        panels.push(WindowState::capture_panel(&ctx, &device, effect, panel.0, panel.1)?);
    }
    while !output.finished() {
        let mut frames = Vec::with_capacity(3);
        for panel_state in &mut panels {
            panel_state.render(&ctx)?;
            frames.push(
                panel_state
                    .capture
                    .as_mut()
                    .and_then(CaptureDump::take_rgba)
                    .ok_or_else(|| anyhow::anyhow!("missing panel pixels"))?,
            );
        }
        let stacked = common::hstack_rgba(&[&frames[0], &frames[1], &frames[2]], panel.0, panel.1)?;
        output.write_rgba(&stacked)?;
    }
    Ok(())
}

fn main() -> anyhow::Result<()> {
    tracing_subscriber::fmt()
        .with_env_filter(
            tracing_subscriber::EnvFilter::try_from_default_env()
                .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("warn")),
        )
        .init();

    if common::capture_requested() {
        return capture_panels();
    }

    println!("Goldy Multi-Window Example (Scheme + Present)");
    println!("Three windows, three effects, independent controls:");
    println!();
    println!("  Plasma:    Space=pause     Click/R=reset");
    println!("  Tunnel:    Space=reverse   Click/R=reset");
    println!("  Starfield: Space=warp      Click/R=reset");
    println!();
    println!("Escape closes the focused window. Close all to exit.");
    println!();

    let event_loop = EventLoop::new()?;
    event_loop.set_control_flow(ControlFlow::Poll);
    event_loop.run_app(&mut App::new()?)?;
    Ok(())
}

The example pulls in examples/common.rs — see Shared Helpers.

Shaders

shaders/starfield.slang:

// 3D Starfield - flying forward through space
// Uses vertex time attribute for animation (same pattern as plasma/tunnel)

struct VertexInput {
    float2 position : POSITION;
    float2 uv : TEXCOORD0;
    float time : TEXCOORD1;
};

struct VertexOutput {
    float4 position : SV_Position;
    float2 uv : TEXCOORD0;
    float time : TEXCOORD1;
};

[goldy_vertex]
VertexOutput vs_main(VertexInput input) {
    VertexOutput output;
    output.position = float4(input.position, 0.0, 1.0);
    output.uv = input.uv;
    output.time = input.time;
    return output;
}

float hash(float p) {
    return frac(sin(p * 127.1) * 43758.5453);
}

[goldy_fragment]
float4 fs_main(VertexOutput input) : SV_Target {
    float2 uv = (input.uv - 0.5) * 2.0;
    float t = input.time;
    float3 color = float3(0.0, 0.0, 0.02);
    
    int num_stars = 200;
    float speed = 0.3;
    
    for (int i = 0; i < num_stars; i++) {
        float fi = float(i);
        
        // Random angle for each star
        float angle = hash(fi) * 6.28318;
        // Random max distance (how far from center it can go)
        float max_dist = 0.3 + hash(fi + 50.0) * 1.2;
        
        // All stars cycle at same speed, just different phases
        float phase = hash(fi + 100.0);
        float cycle = frac(t * speed + phase);
        
        // Distance from center increases as cycle goes 0->1
        float dist = max_dist * cycle;
        
        float star_x = cos(angle) * dist;
        float star_y = sin(angle) * dist;
        
        float pixel_dist = length(uv - float2(star_x, star_y));
        
        // Size increases with distance (closer = bigger)
        float size = 0.002 + cycle * 0.015;
        
        // Brightness increases with distance
        float brightness = cycle * smoothstep(size, 0.0, pixel_dist);
        
        // Fade in at spawn
        float fade = smoothstep(0.0, 0.1, cycle);
        
        color += float3(0.9, 0.95, 1.0) * brightness * fade;
    }
    
    return float4(clamp(color, float3(0.0, 0.0, 0.0), float3(1.0, 1.0, 1.0)), 1.0);
}