# 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:
```bash
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:
```toml
[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.
```rust
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:
```bash
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 call `charton_renderer.render()` when your dataset or chart configuration actually changes. Do not run it blindly on every `RedrawRequested` event.
* 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 `wgpu` version alignment, you should abandon this decoupled method and use `chart.render_to_surface()` for true Zero-Copy GPU rendering.