Decoupled Rendering (winit & wgpu)
If you are building an application where chart updates are infrequent, or if you are struggling with wgpu version conflicts between your UI framework and Charton, Decoupled Rendering is the solution.
Using WgpuRenderer, Charton acts as a completely isolated black box. It initializes its own invisible WGPU instance, renders the chart offscreen, reads the pixels back to CPU memory, composites the text, and hands you a raw Vec<u8> RGBA buffer.
The GPU → CPU → GPU Roundtrip
This architecture trades raw performance for ultimate architectural safety. Your host application simply takes the CPU bytes and uploads them to its own GPU texture for display.
Here is a step-by-step, hands-on tutorial to help you build a complete project using Charton's decoupled rendering architecture.This architecture trades raw performance for ultimate architectural safety. Your host application simply takes the CPU bytes generated by Charton and uploads them to its own GPU texture for display. Let's build it from scratch!
Step 1: Create the Project
Open your terminal and create a new Rust binary project:
cargo new winit_example
cd winit_example
Step 2: Configure Cargo.toml
Next, we need to declare our dependencies. We will use winit to create the application window, wgpu to manage the host graphics context, and pollster to run our asynchronous setup code.
Open your Cargo.toml file and replace its contents with the following:
[package]
name = "winit_example"
version = "0.1.0"
edition = "2024"
[dependencies]
charton = { version = "0.5", features = ["wgpu", "png"] }
winit = "0.29"
wgpu = "29"
pollster = "0.4"
Step 3: Write the Application Code
Now, open src/main.rs. We are going to build the application in three logical phases:
- The Shader Pipeline: Creating a "Full-Screen Triangle" shader to paint our texture.
- Phase 1 (Charton's GPU): Setting up Charton to render offscreen.
- Phase 2 (Host App GPU): Setting up the window and the event loop to display the pixels.
use std::sync::Arc; use charton::prelude::*; use charton::render::WgpuRenderer; use winit::{ event::{Event, WindowEvent}, event_loop::{ControlFlow, EventLoop}, window::WindowBuilder, }; /// Creates a minimal render pipeline to display a 2D texture on the screen. /// /// This uses a common graphics trick: a "Full-Screen Triangle". /// Instead of creating a vertex buffer with a quad (4 vertices), we hardcode /// 3 oversized vertices in the vertex shader (`vs_main`) that cover the entire viewport. /// This avoids the overhead of managing vertex/index buffers. fn create_present_pipeline( device: &wgpu::Device, surface_format: wgpu::TextureFormat, ) -> (wgpu::RenderPipeline, wgpu::BindGroupLayout) { let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("charton present shader"), source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(r#" struct VertexOutput { @builtin(position) position: vec4<f32>, @location(0) uv: vec2<f32>, } @vertex fn vs_main(@builtin(vertex_index) vertex_index: u32) -> VertexOutput { // Hardcoded oversized triangle that covers the entire NDC (Normalized Device Coordinates) space var positions = array<vec2<f32>, 3>( vec2<f32>(-1.0, -1.0), vec2<f32>(3.0, -1.0), vec2<f32>(-1.0, 3.0), ); // Corresponding UV coordinates for mapping the texture var uvs = array<vec2<f32>, 3>( vec2<f32>(0.0, 1.0), vec2<f32>(2.0, 1.0), vec2<f32>(0.0, -1.0), ); var out: VertexOutput; out.position = vec4<f32>(positions[vertex_index], 0.0, 1.0); out.uv = uvs[vertex_index]; return out; } @group(0) @binding(0) var source_texture: texture_2d<f32>; @group(0) @binding(1) var source_sampler: sampler; @fragment fn fs_main(in: VertexOutput) -> @location(0) vec4<f32> { // Sample the charton-generated texture and output to screen return textureSampleLevel(source_texture, source_sampler, in.uv, 0.0); } "#)), }); let bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { label: Some("charton present bind group layout"), entries: &[ // Texture binding wgpu::BindGroupLayoutEntry { binding: 0, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Texture { sample_type: wgpu::TextureSampleType::Float { filterable: true }, view_dimension: wgpu::TextureViewDimension::D2, multisampled: false, }, count: None, }, // Sampler binding wgpu::BindGroupLayoutEntry { binding: 1, visibility: wgpu::ShaderStages::FRAGMENT, ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), count: None, }, ], }); let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { label: Some("charton present pipeline layout"), bind_group_layouts: &[Some(&bind_group_layout)], ..Default::default() }); let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { label: Some("charton present pipeline"), layout: Some(&pipeline_layout), vertex: wgpu::VertexState { module: &shader, entry_point: Some("vs_main"), compilation_options: Default::default(), buffers: &[], // Empty! Using the hardcoded vertices in the shader. }, fragment: Some(wgpu::FragmentState { module: &shader, entry_point: Some("fs_main"), compilation_options: Default::default(), targets: &[Some(wgpu::ColorTargetState { format: surface_format, blend: None, write_mask: wgpu::ColorWrites::ALL, })], }), primitive: Default::default(), depth_stencil: None, multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false, }, multiview_mask: std::num::NonZeroU32::new(0), cache: None, }); (pipeline, bind_group_layout) } async fn run() { let event_loop = EventLoop::new().unwrap(); let window = Arc::new( WindowBuilder::new() .with_title("Charton WgpuRenderer Example") .with_inner_size(winit::dpi::LogicalSize::new(800.0, 600.0)) .build(&event_loop) .unwrap(), ); // ========================================== // Phase 1: Chart Rendering (Headless & Decoupled) // ========================================== // WgpuRenderer creates its OWN wgpu device/queue internally. let mut charton_renderer = WgpuRenderer::new(); // Build the declarative chart let ds = load_dataset("mtcars").unwrap(); let chart = Chart::build(&ds) .unwrap() .mark_point() .unwrap() .configure_point(|p| p.with_size(10.0)) .encode(( alt::x("wt"), alt::y("mpg"), alt::color("gear").with_scale(Scale::Discrete), )) .unwrap() .configure_theme(|t| t.with_label_size(20.0).with_tick_label_size(18.0)) .with_size(800, 600); // ========================================== // Phase 2: Window Presentation (App-owned WGPU Context) // ========================================== // We create a SECOND wgpu instance specifically for the host window. let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { backends: wgpu::Backends::all(), flags: wgpu::InstanceFlags::default(), backend_options: wgpu::BackendOptions::default(), memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), display: None, }); let surface = instance.create_surface(window.clone()).unwrap(); let adapter = instance .request_adapter(&wgpu::RequestAdapterOptions { compatible_surface: Some(&surface), ..Default::default() }) .await .unwrap(); let (device, queue) = adapter .request_device(&wgpu::DeviceDescriptor::default()) .await .unwrap(); let size = window.inner_size(); let capabilities = surface.get_capabilities(&adapter); let surface_format = capabilities.formats[0].remove_srgb_suffix(); let mut config = surface .get_default_config(&adapter, size.width, size.height) .unwrap(); config.format = surface_format; surface.configure(&device, &config); let (present_pipeline, bind_group_layout) = create_present_pipeline(&device, surface_format); let sampler = device.create_sampler(&wgpu::SamplerDescriptor { mag_filter: wgpu::FilterMode::Linear, min_filter: wgpu::FilterMode::Linear, mipmap_filter: wgpu::MipmapFilterMode::Linear, lod_min_clamp: 0.0, lod_max_clamp: 1.0, compare: None, anisotropy_clamp: 16, ..Default::default() }); let _ = event_loop.run(move |event, target| { target.set_control_flow(ControlFlow::Poll); match event { Event::WindowEvent { event: WindowEvent::CloseRequested, .. } => target.exit(), Event::WindowEvent { event: WindowEvent::RedrawRequested, .. } => { // 1. Render Chart -> CPU Pixels // Charton's internal GPU renders the chart, reads it back, composites text, // and outputs raw RGBA bytes. let pixels = charton_renderer.render(&chart, 800, 600, 1.0).unwrap(); // 2. Upload CPU Pixels -> App GPU Texture // NOTE: This GPU -> CPU -> GPU roundtrip is perfect for simple tools or low-FPS apps. // For high-performance interactive apps (like Bevy/egui), users should use // `chart.render_to_surface()` to avoid this memory readback entirely. let tex = device.create_texture(&wgpu::TextureDescriptor { label: Some("charton output texture"), size: wgpu::Extent3d { width: 800, height: 600, depth_or_array_layers: 1, }, mip_level_count: 1, sample_count: 1, dimension: wgpu::TextureDimension::D2, format: wgpu::TextureFormat::Rgba8Unorm, usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, view_formats: &[], }); queue.write_texture( wgpu::TexelCopyTextureInfo { texture: &tex, mip_level: 0, origin: wgpu::Origin3d::ZERO, aspect: wgpu::TextureAspect::All, }, &pixels, wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(800 * 4), rows_per_image: Some(600), }, wgpu::Extent3d { width: 800, height: 600, depth_or_array_layers: 1, }, ); let view = tex.create_view(&wgpu::TextureViewDescriptor::default()); let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { label: Some("charton present bind group"), layout: &bind_group_layout, entries: &[ wgpu::BindGroupEntry { binding: 0, resource: wgpu::BindingResource::TextureView(&view), }, wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::Sampler(&sampler), }, ], }); // 3. Draw Texture to Screen let frame = match surface.get_current_texture() { wgpu::CurrentSurfaceTexture::Success(texture) => texture, wgpu::CurrentSurfaceTexture::Suboptimal(texture) => texture, wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { return; } wgpu::CurrentSurfaceTexture::Outdated | wgpu::CurrentSurfaceTexture::Lost => { return; } _ => return, }; let frame_view = frame.texture.create_view(&wgpu::TextureViewDescriptor::default()); let mut encoder = device.create_command_encoder( &wgpu::CommandEncoderDescriptor::default(), ); { let mut rpass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { label: Some("charton present render pass"), color_attachments: &[Some(wgpu::RenderPassColorAttachment { view: &frame_view, resolve_target: None, ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store, }, depth_slice: None, })], depth_stencil_attachment: None, timestamp_writes: None, occlusion_query_set: None, multiview_mask: std::num::NonZeroU32::new(0), }); rpass.set_pipeline(&present_pipeline); rpass.set_bind_group(0, &bind_group, &[]); // Draw 3 vertices for our hardcoded full-screen triangle rpass.draw(0..3, 0..1); } queue.submit(Some(encoder.finish())); frame.present(); } Event::AboutToWait => { window.request_redraw(); } _ => {} } }); } fn main() { pollster::block_on(run()); }
Step 4: Run the Application
Now that your files are set up, open your terminal and start the application:
cargo run
You should see a new window pop up titled "Charton WgpuRenderer Example" displaying the scattered points from the mtcars dataset!
Important Performance Considerations & Architectural Trade-offs
Notice how charton_renderer.render(&chart, 800, 600, 1.0).unwrap() is called inside the RedrawRequested event block. This means the chart is being completely re-rendered, read from the GPU to the CPU, and sent back to the host App's GPU on every single frame.
Why this compromise? (The WGPU Dependency Hell)
This GPU → CPU → GPU roundtrip is an intentional architectural trade-off. In the Rust ecosystem, sharing a GPU context requires both the host application and the rendering library to depend on the exact same version of wgpu. By returning raw CPU bytes (Vec<u8>) instead of a GPU texture, Charton acts as a completely isolated black box. You can use wgpu v0.29 in your app while Charton internally uses wgpu v0.28, and they will never conflict.
The Performance Tip
Because of the heavy memory readback overhead, this decoupled approach is designed for tools, static dashboards, or low-FPS applications.
- In production: You should cache the
Vec<u8>output. Only callcharton_renderer.render()when your dataset or chart configuration actually changes. Do not run it blindly on everyRedrawRequestedevent. - Need 60 FPS? If you are building a high-performance interactive application (like a game in Bevy or a fluid egui dashboard) and can guarantee
wgpuversion alignment, you should abandon this decoupled method and usechart.render_to_surface()for true Zero-Copy GPU rendering.