git.lucas.co / cce-designer
graphic design tool
git clone https://git.lucas.co/cce-designer.git

commit2d5c4d4c490ea02edd792dde3b0f8dde947dad3d
parent0af15424cf
authorLucas Galante <[email protected]>
date2026-07-14 12:54
feat: cutover — cce-designer renders on ash (VkRenderer), wgpu retired

State drops WgpuAdapter and the four wgpu pipelines for the vk module built in
milestones 1-3: 2D quads via draw_frame, the 3D canvas via handle-based meshes
+ stage_scene (same draw order, same viewport-changed cache, backdrop cleared
on hide/zero-size), text via TextSpans against the shaped-Buffer cache, and
resize collapses to renderer.resize + set_corner_radius. State::new is sync
now (no pollster block_on).

The curved-text path (circular network pane rim labels) is reimplemented in
the text stage instead of ported: TextSpan grows rotation (angle about a
physical-px center) and clip_circle (same discard as shader.wgsl), replacing
the offscreen glyphon render + TexturedVertex pipeline — graphics.rs and
shader_textured.wgsl are deleted. FontSystem/SwashCache now live on State.

wgpu and pollster are no longer direct dependencies (glyphon still pulls wgpu
transitively for cosmic-text/swash); geometry::Vertex3D::desc() went with them.

Verified live: full app on the ash stack — node graph over the 3D backdrop,
default-project sphere via update_mesh, menus/params/spinboxes, circular pane
toggled both ways over the HTTP API with circle-clipped grid and labels — with
zero validation messages end to end. 23/23 tests pass.

Co-Authored-By: Claude Fable 5 <[email protected]>
Claude-Session: https://claude.ai/code/session_01RFkXq68hDwVDMKnu9fckz3

 Cargo.toml               |   4 +-
 src/app.rs               | 857 ++++-------------------------------------------
 src/geometry.rs          |  12 -
 src/graphics.rs          |  26 --
 src/main.rs              |   6 +-
 src/render.rs            | 484 ++++++--------------------
 src/shader_textured.wgsl |  41 ---
 src/vk/glyph.wgsl        |  12 +
 src/vk/text.rs           |  38 ++-
 src/vk_smoke.rs          |   6 +
 10 files changed, 239 insertions(+), 1247 deletions(-)

diff --git a/Cargo.toml b/Cargo.toml
index b2287bd..481f74f 100644
--- a/Cargo.toml
+++ b/Cargo.toml
@@ -11,9 +11,9 @@ calloop-wayland-source = "0.3.0"
 wayland-client = { version = "0.31", features = ["system"] }
 xkeysym = "0.2"
 raw-window-handle = "0.6"
-wgpu = "24"
 bytemuck = { version = "1", features = ["derive"] }
-pollster = "0.4"
+# glyphon supplies cosmic-text/swash (shaping + rasterization) for the ash text
+# stage; its wgpu renderer half is unused since the VkRenderer cutover.
 glyphon = "0.8"
 tokio = { version = "1", features = ["full"] }
 glam = "0.29"
diff --git a/src/app.rs b/src/app.rs
index 5e78a03..f35cf67 100644
--- a/src/app.rs
+++ b/src/app.rs
@@ -34,17 +34,16 @@ use wayland_client::{
     Connection, QueueHandle, Proxy,
 };
 
-use wgpu::util::DeviceExt;
 use cce_ui::widget::{Adapted, Breadcrumb, MenuBar, MenuController, ParametersBg, Splitter, Spreadsheet, StatusBar, TextLabel, WidgetHost, GraphNode, Graph, Button, Checkbox, Label, Dropdown};
 use cce_ui::widget::UiContext;
 use crate::viewport_3d::Viewport3D;
 use cce_ui::colors;
-use glyphon::{Attrs, Buffer, Cache, FontSystem, Metrics, Resolution, TextAtlas, TextRenderer, Viewport};
+use glyphon::{Attrs, Buffer, FontSystem, Metrics};
 use glam::{Mat4, Vec3};
 
 use crate::geometry::*;
 use crate::shortcut::{ShortcutManager, Action};
-use crate::graphics::TexturedVertex;
+use crate::vk::{SceneDraw, TextSpan};
 use cce_ui::engine::Vertex;
 use crate::window::{AppState, WindowEvent};
 
@@ -964,45 +963,24 @@ pub struct ViewportUniforms {
 }
 
 pub struct State {
-    pub wgpu_adapter: cce_ui::backend::WgpuAdapter,
-    pub render_pipeline: wgpu::RenderPipeline,
-    pub vertex_buffer: wgpu::Buffer,
+    pub renderer: crate::vk::VkRenderer,
+    pub font_system: FontSystem,
+    pub swash_cache: glyphon::SwashCache,
     pub window: XdgWindow,
     pub wl_surface: wl_surface::WlSurface,
-    pub vertex_count: u32,
     pub vertex_data: Vec<Vertex>,
 
-    pub pipeline_3d: wgpu::RenderPipeline,
-    pub bind_group_3d: wgpu::BindGroup,
-    pub bind_group_layout_3d: wgpu::BindGroupLayout,
-    pub uniform_buffer: wgpu::Buffer,
-    pub bind_group_grid: wgpu::BindGroup,
-    pub uniform_buffer_grid: wgpu::Buffer,
-    pub bind_group_pivot: wgpu::BindGroup,
-    pub uniform_buffer_pivot: wgpu::Buffer,
-    pub vertex_buffer_3d: wgpu::Buffer,
-    pub vertex_buffer_viewport_bg: wgpu::Buffer,
-    pub vertex_count_3d: u32,
-    pub vertex_buffer_spheres: wgpu::Buffer,
+    pub mesh_cube: crate::vk::MeshId,
+    pub mesh_viewport_bg: crate::vk::MeshId,
+    pub mesh_spheres: crate::vk::MeshId,
+    pub mesh_grid: crate::vk::MeshId,
+    pub mesh_origin: crate::vk::MeshId,
+    pub mesh_pivot: crate::vk::MeshId,
     pub vertex_count_spheres: u32,
-    pub vertex_buffer_grid: wgpu::Buffer,
-    pub vertex_count_grid: u32,
-    pub depth_texture: wgpu::Texture,
-    pub depth_texture_view: wgpu::TextureView,
-    pub backdrop_texture: wgpu::Texture,
-    pub backdrop_texture_view: wgpu::TextureView,
-    pub backdrop_sampler: wgpu::Sampler,
-    pub backdrop_bind_group_layout: wgpu::BindGroupLayout,
-    pub backdrop_bind_group: wgpu::BindGroup,
-    pub window_info_buffer: wgpu::Buffer,
     pub node_color: [f32; 3],
     pub grid_color: [f32; 3],
     pub cell_color: [f32; 3],
     pub gap_color: [f32; 3],
-    pub vertex_buffer_origin: wgpu::Buffer,
-    pub vertex_count_origin: u32,
-    pub vertex_buffer_pivot: wgpu::Buffer,
-    pub vertex_count_pivot: u32,
     pub origin_size: f32,
     pub camera_pivot_size: f32,
 
@@ -1025,18 +1003,6 @@ pub struct State {
     pub node_palette_filtered: Vec<usize>,
     pub node_palette_selected: usize,
 
-    pub curved_text_texture: wgpu::Texture,
-    pub curved_text_texture_view: wgpu::TextureView,
-    pub curved_text_sampler: wgpu::Sampler,
-    pub curved_text_bind_group: wgpu::BindGroup,
-    pub curved_text_pipeline: wgpu::RenderPipeline,
-    pub curved_text_atlas: TextAtlas,
-    pub curved_text_renderer: TextRenderer,
-    pub curved_text_viewport: Viewport,
-    pub textured_vertex_buffer: wgpu::Buffer,
-    pub textured_vertex_count: u32,
-
-
     pub drag_widget: Option<usize>,
     pub focused_widget: Option<usize>,
 
@@ -1316,19 +1282,19 @@ impl State {
     pub fn update_grid_geometry(&mut self) {
         let linear_grid_color = cce_ui::colors::to_linear_rgb(self.grid_color);
         let grid_verts = grid_vertices(self.grid_thickness, linear_grid_color);
-        self.wgpu_adapter.queue.write_buffer(&self.vertex_buffer_grid, 0, bytemuck::cast_slice(&grid_verts));
+        self.renderer.update_mesh(self.mesh_grid, bytemuck::cast_slice(&grid_verts));
         self.viewport_dirty = true;
     }
 
     pub fn update_origin_geometry(&mut self) {
         let origin_verts = origin_vectors_vertices(self.origin_size);
-        self.wgpu_adapter.queue.write_buffer(&self.vertex_buffer_origin, 0, bytemuck::cast_slice(&origin_verts));
+        self.renderer.update_mesh(self.mesh_origin, bytemuck::cast_slice(&origin_verts));
         self.viewport_dirty = true;
     }
 
     pub fn update_pivot_geometry(&mut self) {
         let pivot_verts = camera_pivot_vertices(self.camera_pivot_size);
-        self.wgpu_adapter.queue.write_buffer(&self.vertex_buffer_pivot, 0, bytemuck::cast_slice(&pivot_verts));
+        self.renderer.update_mesh(self.mesh_pivot, bytemuck::cast_slice(&pivot_verts));
         self.viewport_dirty = true;
     }
 
@@ -1343,7 +1309,7 @@ impl State {
             Vertex3D { position: [ 1.0,  1.0, 9.99], color: bg_color }, // Top-right
             Vertex3D { position: [-1.0,  1.0, 9.99], color: bg_color }, // Top-left
         ];
-        self.wgpu_adapter.queue.write_buffer(&self.vertex_buffer_viewport_bg, 0, bytemuck::cast_slice(&bg_verts));
+        self.renderer.update_mesh(self.mesh_viewport_bg, bytemuck::cast_slice(&bg_verts));
         self.viewport_dirty = true;
     }
 
@@ -2447,7 +2413,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
 
 
 
-    pub async fn new(
+    pub fn new(
         conn: &Connection,
         qh: &QueueHandle<AppState>,
         compositor_state: &CompositorState,
@@ -2479,469 +2445,38 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
 
         let display_ptr = conn.backend().display_id().as_ptr() as *mut std::ffi::c_void;
         let surface_ptr = wl_surface.id().as_ptr() as *mut std::ffi::c_void;
-        // Bundled fonts only (the 5th param opts into system fonts in the render
-        // FontSystem — Phase 6k; the designer's UI uses bundled families).
-        let wgpu_adapter = cce_ui::backend::WgpuAdapter::new(display_ptr, surface_ptr, pw, ph, false).await;
-
-        let device = &wgpu_adapter.device;
-        let queue = &wgpu_adapter.queue;
-
-        let config = &wgpu_adapter.config;
-
-        let backdrop_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
-            label: Some("Backdrop Bind Group Layout"),
-            entries: &[
-                wgpu::BindGroupLayoutEntry {
-                    binding: 0,
-                    visibility: wgpu::ShaderStages::FRAGMENT,
-                    ty: wgpu::BindingType::Texture {
-                        multisampled: false,
-                        view_dimension: wgpu::TextureViewDimension::D2,
-                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
-                    },
-                    count: None,
-                },
-                wgpu::BindGroupLayoutEntry {
-                    binding: 1,
-                    visibility: wgpu::ShaderStages::FRAGMENT,
-                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
-                    count: None,
-                },
-                wgpu::BindGroupLayoutEntry {
-                    binding: 2,
-                    visibility: wgpu::ShaderStages::FRAGMENT,
-                    ty: wgpu::BindingType::Buffer {
-                        ty: wgpu::BufferBindingType::Uniform,
-                        has_dynamic_offset: false,
-                        min_binding_size: wgpu::BufferSize::new(16),
-                    },
-                    count: None,
-                },
-            ],
-        });
-
-        let backdrop_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
-            label: Some("Backdrop Sampler"),
-            address_mode_u: wgpu::AddressMode::ClampToEdge,
-            address_mode_v: wgpu::AddressMode::ClampToEdge,
-            address_mode_w: wgpu::AddressMode::ClampToEdge,
-            mag_filter: wgpu::FilterMode::Linear,
-            min_filter: wgpu::FilterMode::Linear,
-            mipmap_filter: wgpu::FilterMode::Nearest,
-            ..Default::default()
-        });
-
-        let backdrop_texture = device.create_texture(&wgpu::TextureDescriptor {
-            label: Some("Backdrop Texture"),
-            size: wgpu::Extent3d { width: pw.max(1), height: ph.max(1), depth_or_array_layers: 1 },
-            mip_level_count: 1,
-            sample_count: 1,
-            dimension: wgpu::TextureDimension::D2,
-            format: config.format,
-            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
-            view_formats: &[],
-        });
-        let backdrop_texture_view = backdrop_texture.create_view(&wgpu::TextureViewDescriptor::default());
-
-        let window_info_buffer = device.create_buffer(&wgpu::BufferDescriptor {
-            label: Some("Window Info Buffer"),
-            size: 16,
-            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
-            mapped_at_creation: false,
-        });
-
-        let window_info_data = [
-            pw as f32,
-            ph as f32,
-            cce_ui::color::backplate_corner_radius() * scale as f32,
-            0.0,
-        ];
-        queue.write_buffer(&window_info_buffer, 0, bytemuck::cast_slice(&window_info_data));
-
-        let backdrop_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
-            label: Some("Backdrop Bind Group"),
-            layout: &backdrop_bind_group_layout,
-            entries: &[
-                wgpu::BindGroupEntry {
-                    binding: 0,
-                    resource: wgpu::BindingResource::TextureView(&backdrop_texture_view),
-                },
-                wgpu::BindGroupEntry {
-                    binding: 1,
-                    resource: wgpu::BindingResource::Sampler(&backdrop_sampler),
-                },
-                wgpu::BindGroupEntry {
-                    binding: 2,
-                    resource: window_info_buffer.as_entire_binding(),
-                },
-            ],
-        });
-
-        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
-            label: Some("Shader"),
-            source: wgpu::ShaderSource::Wgsl(include_str!("shader.wgsl").into()),
-        });
-
-        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
-            label: Some("Pipeline Layout"),
-            bind_group_layouts: &[&backdrop_bind_group_layout],
-            push_constant_ranges: &[],
-        });
-
-        let render_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
-            label: Some("Render Pipeline"),
-            layout: Some(&pipeline_layout),
-            vertex: wgpu::VertexState {
-                module: &shader,
-                entry_point: Some("vs_main"),
-                buffers: &[Vertex::desc()],
-                compilation_options: Default::default(),
-            },
-            fragment: Some(wgpu::FragmentState {
-                module: &shader,
-                entry_point: Some("fs_main"),
-                targets: &[Some(wgpu::ColorTargetState {
-                    format: config.format,
-                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
-                    write_mask: wgpu::ColorWrites::ALL,
-                })],
-                compilation_options: Default::default(),
-            }),
-            primitive: wgpu::PrimitiveState {
-                topology: wgpu::PrimitiveTopology::TriangleList,
-                strip_index_format: None,
-                front_face: wgpu::FrontFace::Ccw,
-                cull_mode: None,
-                polygon_mode: wgpu::PolygonMode::Fill,
-                unclipped_depth: false,
-                conservative: false,
-            },
-            depth_stencil: None,
-            multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false },
-            multiview: None,
-            cache: None,
-        });
-
-        // 3D pipeline
-        let shader_3d = device.create_shader_module(wgpu::ShaderModuleDescriptor {
-            label: Some("Shader 3D"),
-            source: wgpu::ShaderSource::Wgsl(include_str!("shader_3d.wgsl").into()),
-        });
-
-        let uniform_buffer = device.create_buffer(&wgpu::BufferDescriptor {
-            label: Some("Uniform Buffer"),
-            size: 80,
-            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
-            mapped_at_creation: false,
-        });
-
-        let bind_group_layout_3d = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
-            label: Some("3D Bind Group Layout"),
-            entries: &[wgpu::BindGroupLayoutEntry {
-                binding: 0,
-                visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT,
-                ty: wgpu::BindingType::Buffer {
-                    ty: wgpu::BufferBindingType::Uniform,
-                    has_dynamic_offset: false,
-                    min_binding_size: wgpu::BufferSize::new(80),
-                },
-                count: None,
-            }],
-        });
-
-        let pipeline_layout_3d = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
-            label: Some("3D Pipeline Layout"),
-            bind_group_layouts: &[&bind_group_layout_3d],
-            push_constant_ranges: &[],
-        });
-
-        let bind_group_3d = device.create_bind_group(&wgpu::BindGroupDescriptor {
-            label: Some("3D Bind Group"),
-            layout: &bind_group_layout_3d,
-            entries: &[wgpu::BindGroupEntry {
-                binding: 0,
-                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
-                    buffer: &uniform_buffer,
-                    offset: 0,
-                    size: wgpu::BufferSize::new(80),
-                }),
-            }],
-        });
-
-        let uniform_buffer_grid = device.create_buffer(&wgpu::BufferDescriptor {
-            label: Some("Grid Uniform Buffer"),
-            size: 80,
-            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
-            mapped_at_creation: false,
-        });
-
-        let bind_group_grid = device.create_bind_group(&wgpu::BindGroupDescriptor {
-            label: Some("Grid 3D Bind Group"),
-            layout: &bind_group_layout_3d,
-            entries: &[wgpu::BindGroupEntry {
-                binding: 0,
-                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
-                    buffer: &uniform_buffer_grid,
-                    offset: 0,
-                    size: wgpu::BufferSize::new(80),
-                }),
-            }],
-        });
-
-        let uniform_buffer_pivot = device.create_buffer(&wgpu::BufferDescriptor {
-            label: Some("Pivot Uniform Buffer"),
-            size: 80,
-            usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
-            mapped_at_creation: false,
-        });
-
-        let bind_group_pivot = device.create_bind_group(&wgpu::BindGroupDescriptor {
-            label: Some("Pivot Bind Group"),
-            layout: &bind_group_layout_3d,
-            entries: &[wgpu::BindGroupEntry {
-                binding: 0,
-                resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding {
-                    buffer: &uniform_buffer_pivot,
-                    offset: 0,
-                    size: wgpu::BufferSize::new(80),
-                }),
-            }],
-        });
-
-        let vertex_buffer_3d = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
-            label: Some("Cube Vertex Buffer"),
-            contents: bytemuck::cast_slice(&cube_vertices()),
-            usage: wgpu::BufferUsages::VERTEX,
-        });
-
-        let vertex_buffer_spheres = device.create_buffer(&wgpu::BufferDescriptor {
-            label: Some("Sphere Node Vertex Buffer"),
-            size: 1,
-            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-            mapped_at_creation: false,
-        });
-
+        let corner_radius = cce_ui::color::backplate_corner_radius() * scale as f32;
+        let mut renderer = unsafe {
+            crate::vk::VkRenderer::new(display_ptr, surface_ptr, pw, ph, corner_radius)
+        };
+        // Bundled fonts only (the designer's UI uses bundled families).
+        let font_system = cce_ui::create_font_system();
+        let swash_cache = glyphon::SwashCache::new();
+
+        // Static 3D meshes; the spheres mesh starts empty and is rebuilt from
+        // the node graph (rebuild_scene_geometry).
+        let cube_verts = cube_vertices();
+        let mesh_cube = renderer.create_mesh(bytemuck::cast_slice(&cube_verts));
         let linear_grid_color = cce_ui::colors::to_linear_rgb(settings.viewport.grid_color);
         let grid_verts = grid_vertices(settings.viewport.grid_thickness, linear_grid_color);
-        let vertex_count_grid = grid_verts.len() as u32;
-        let vertex_buffer_grid = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
-            label: Some("Grid Vertex Buffer"),
-            contents: bytemuck::cast_slice(&grid_verts),
-            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-        });
-
+        let mesh_grid = renderer.create_mesh(bytemuck::cast_slice(&grid_verts));
         let origin_verts = origin_vectors_vertices(settings.viewport.origin_size);
-        let vertex_count_origin = origin_verts.len() as u32;
-        let vertex_buffer_origin = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
-            label: Some("Origin Vectors Vertex Buffer"),
-            contents: bytemuck::cast_slice(&origin_verts),
-            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-        });
-
+        let mesh_origin = renderer.create_mesh(bytemuck::cast_slice(&origin_verts));
         let pivot_verts = camera_pivot_vertices(settings.viewport.camera_pivot_size);
-        let vertex_count_pivot = pivot_verts.len() as u32;
-        let vertex_buffer_pivot = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
-            label: Some("Camera Pivot Vertex Buffer"),
-            contents: bytemuck::cast_slice(&pivot_verts),
-            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-        });
-
+        let mesh_pivot = renderer.create_mesh(bytemuck::cast_slice(&pivot_verts));
         let bg_color = cce_ui::colors::to_linear_rgb(settings.viewport.bg_color);
         let bg_verts = [
             Vertex3D { position: [-1.0, -1.0, 9.99], color: bg_color }, // Bottom-left
             Vertex3D { position: [ 1.0, -1.0, 9.99], color: bg_color }, // Bottom-right
             Vertex3D { position: [-1.0,  1.0, 9.99], color: bg_color }, // Top-left
-
             Vertex3D { position: [ 1.0, -1.0, 9.99], color: bg_color }, // Bottom-right
             Vertex3D { position: [ 1.0,  1.0, 9.99], color: bg_color }, // Top-right
             Vertex3D { position: [-1.0,  1.0, 9.99], color: bg_color }, // Top-left
         ];
-        let vertex_buffer_viewport_bg = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
-            label: Some("Viewport BG Vertex Buffer"),
-            contents: bytemuck::cast_slice(&bg_verts),
-            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-        });
-
-        let pipeline_3d = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
-            label: Some("3D Pipeline"),
-            layout: Some(&pipeline_layout_3d),
-            vertex: wgpu::VertexState {
-                module: &shader_3d,
-                entry_point: Some("vs_main"),
-                buffers: &[Vertex3D::desc()],
-                compilation_options: Default::default(),
-            },
-            fragment: Some(wgpu::FragmentState {
-                module: &shader_3d,
-                entry_point: Some("fs_main"),
-                targets: &[Some(wgpu::ColorTargetState {
-                    format: config.format,
-                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
-                    write_mask: wgpu::ColorWrites::ALL,
-                })],
-                compilation_options: Default::default(),
-            }),
-            primitive: wgpu::PrimitiveState {
-                topology: wgpu::PrimitiveTopology::TriangleList,
-                strip_index_format: None,
-                front_face: wgpu::FrontFace::Ccw,
-                cull_mode: Some(wgpu::Face::Back),
-                polygon_mode: wgpu::PolygonMode::Fill,
-                unclipped_depth: false,
-                conservative: false,
-            },
-            depth_stencil: Some(wgpu::DepthStencilState {
-                format: wgpu::TextureFormat::Depth32Float,
-                depth_write_enabled: true,
-                depth_compare: wgpu::CompareFunction::Less,
-                stencil: wgpu::StencilState::default(),
-                bias: wgpu::DepthBiasState::default(),
-            }),
-            multisample: wgpu::MultisampleState { count: 1, mask: !0, alpha_to_coverage_enabled: false },
-            multiview: None,
-            cache: None,
-        });
-
-        let cache = Cache::new(device);
-
-        let shader_textured = device.create_shader_module(wgpu::ShaderModuleDescriptor {
-            label: Some("Textured Shader"),
-            source: wgpu::ShaderSource::Wgsl(include_str!("shader_textured.wgsl").into()),
-        });
-
-        let textured_bind_group_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
-            label: Some("Textured Bind Group Layout"),
-            entries: &[
-                wgpu::BindGroupLayoutEntry {
-                    binding: 0,
-                    visibility: wgpu::ShaderStages::FRAGMENT,
-                    ty: wgpu::BindingType::Texture {
-                        multisampled: false,
-                        view_dimension: wgpu::TextureViewDimension::D2,
-                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
-                    },
-                    count: None,
-                },
-                wgpu::BindGroupLayoutEntry {
-                    binding: 1,
-                    visibility: wgpu::ShaderStages::FRAGMENT,
-                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
-                    count: None,
-                },
-            ],
-        });
-
-        let textured_pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
-            label: Some("Textured Pipeline Layout"),
-            bind_group_layouts: &[&textured_bind_group_layout],
-            push_constant_ranges: &[],
-        });
-
-        let curved_text_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
-            label: Some("Textured Render Pipeline"),
-            layout: Some(&textured_pipeline_layout),
-            vertex: wgpu::VertexState {
-                module: &shader_textured,
-                entry_point: Some("vs_main"),
-                buffers: &[TexturedVertex::desc()],
-                compilation_options: Default::default(),
-            },
-            fragment: Some(wgpu::FragmentState {
-                module: &shader_textured,
-                entry_point: Some("fs_main"),
-                targets: &[Some(wgpu::ColorTargetState {
-                    format: config.format,
-                    blend: Some(wgpu::BlendState::ALPHA_BLENDING),
-                    write_mask: wgpu::ColorWrites::ALL,
-                })],
-                compilation_options: Default::default(),
-            }),
-            primitive: wgpu::PrimitiveState {
-                topology: wgpu::PrimitiveTopology::TriangleList,
-                strip_index_format: None,
-                front_face: wgpu::FrontFace::Ccw,
-                cull_mode: None,
-                unclipped_depth: false,
-                polygon_mode: wgpu::PolygonMode::Fill,
-                conservative: false,
-            },
-            depth_stencil: None,
-            multisample: wgpu::MultisampleState::default(),
-            multiview: None,
-            cache: None,
-        });
-
-        let curved_text_texture = device.create_texture(&wgpu::TextureDescriptor {
-            label: Some("Curved Text Texture"),
-            size: wgpu::Extent3d {
-                width: 1024,
-                height: 1024,
-                depth_or_array_layers: 1,
-            },
-            mip_level_count: 1,
-            sample_count: 1,
-            dimension: wgpu::TextureDimension::D2,
-            format: config.format,
-            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
-            view_formats: &[],
-        });
-        let curved_text_texture_view = curved_text_texture.create_view(&wgpu::TextureViewDescriptor::default());
-
-        let curved_text_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
-            label: Some("Curved Text Sampler"),
-            address_mode_u: wgpu::AddressMode::ClampToEdge,
-            address_mode_v: wgpu::AddressMode::ClampToEdge,
-            address_mode_w: wgpu::AddressMode::ClampToEdge,
-            mag_filter: wgpu::FilterMode::Linear,
-            min_filter: wgpu::FilterMode::Linear,
-            mipmap_filter: wgpu::FilterMode::Nearest,
-            ..Default::default()
-        });
-
-        let curved_text_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
-            label: Some("Curved Text Bind Group"),
-            layout: &textured_bind_group_layout,
-            entries: &[
-                wgpu::BindGroupEntry {
-                    binding: 0,
-                    resource: wgpu::BindingResource::TextureView(&curved_text_texture_view),
-                },
-                wgpu::BindGroupEntry {
-                    binding: 1,
-                    resource: wgpu::BindingResource::Sampler(&curved_text_sampler),
-                },
-            ],
-        });
-
-        let mut curved_text_atlas = TextAtlas::new(&device, &queue, &cache, config.format);
-        let curved_text_renderer = TextRenderer::new(&mut curved_text_atlas, &device, wgpu::MultisampleState::default(), None);
-        let mut curved_text_viewport = Viewport::new(&device, &cache);
-        curved_text_viewport.update(&queue, Resolution { width: 1024, height: 1024 });
-
-        let textured_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
-            label: Some("Textured Vertex Buffer"),
-            size: 1,
-            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-            mapped_at_creation: false,
-        });
-
+        let mesh_viewport_bg = renderer.create_mesh(bytemuck::cast_slice(&bg_verts));
+        let mesh_spheres = renderer.create_mesh(&[]);
 
         let splitter_layout = cce_ui::layout::SplitterLayout::new(sw, SPLITTER_W, MIN_COLUMN);
-        let (depth_texture, depth_texture_view) = {
-            let tex = device.create_texture(&wgpu::TextureDescriptor {
-                label: Some("Depth Texture"),
-                size: wgpu::Extent3d { width: pw.max(1), height: ph.max(1), depth_or_array_layers: 1 },
-                mip_level_count: 1,
-                sample_count: 1,
-                dimension: wgpu::TextureDimension::D2,
-                format: wgpu::TextureFormat::Depth32Float,
-                usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
-                view_formats: &[],
-            });
-            let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
-            (tex, view)
-        };
-
         let templates_root = load_fs_tree();
         let node_templates = flatten_node_templates(&templates_root);
 
@@ -3035,13 +2570,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         positions.resize_with(WIDGET_COUNT, || (0.0, 0.0, 0.0, 0.0));
 
 
-        let vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
-            label: Some("Vertex Buffer"),
-            size: 1,
-            usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-            mapped_at_creation: false,
-        });
-
         let mut shortcut_manager = ShortcutManager::new();
         shortcut_manager.register("Ctrl+g", Action::ToggleGrid).unwrap();
         shortcut_manager.register("Ctrl+e", Action::ToggleCube).unwrap();
@@ -3054,34 +2582,17 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
         let mut state = Self {
             window,
             wl_surface,
-            wgpu_adapter,
-            render_pipeline,
-            vertex_buffer,
-            vertex_count: 0,
+            renderer,
+            font_system,
+            swash_cache,
             vertex_data: Vec::with_capacity(4096),
-            pipeline_3d,
-            bind_group_3d,
-            bind_group_layout_3d,
-            uniform_buffer,
-            bind_group_grid,
-            uniform_buffer_grid,
-            bind_group_pivot,
-            uniform_buffer_pivot,
-            vertex_buffer_3d,
-            vertex_buffer_viewport_bg,
-            vertex_count_3d: cube_vertices().len() as u32,
-            vertex_buffer_spheres,
+            mesh_cube,
+            mesh_viewport_bg,
+            mesh_spheres,
+            mesh_grid,
+            mesh_origin,
+            mesh_pivot,
             vertex_count_spheres: 0,
-            vertex_buffer_grid,
-            vertex_count_grid,
-            depth_texture,
-            depth_texture_view,
-            backdrop_texture,
-            backdrop_texture_view,
-            backdrop_sampler,
-            backdrop_bind_group_layout,
-            backdrop_bind_group,
-            window_info_buffer,
             node_color: {
                 let nc = cce_ui::color::graph_node_color();
                 [nc[0], nc[1], nc[2]]
@@ -3089,10 +2600,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             grid_color: settings.viewport.grid_color,
             cell_color: cce_ui::color::graph_cell_color(),
             gap_color: cce_ui::color::graph_gap_color(),
-            vertex_buffer_origin,
-            vertex_count_origin,
-            vertex_buffer_pivot,
-            vertex_count_pivot,
             origin_size: settings.viewport.origin_size,
             camera_pivot_size: settings.viewport.camera_pivot_size,
             fs_root: fs_root.clone(),
@@ -3111,17 +2618,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             node_palette_query: String::new(),
             node_palette_filtered: Vec::new(),
             node_palette_selected: 0,
-            curved_text_texture,
-            curved_text_texture_view,
-            curved_text_sampler,
-            curved_text_bind_group,
-            curved_text_pipeline,
-            curved_text_atlas,
-            curved_text_renderer,
-            curved_text_viewport,
-            textured_vertex_buffer,
-            textured_vertex_count: 0,
-
             drag_widget: None,
             focused_widget: None,
             cursor_x: 0.0,
@@ -4122,42 +3618,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             self.physical_height = height;
             self.width = width as f32 / self.scale as f32;
             self.height = height as f32 / self.scale as f32;
-            self.wgpu_adapter.resize(width, height);
-
-            let (tex, view) = self.create_depth_texture();
-            self.depth_texture = tex;
-            self.depth_texture_view = view;
-
-            let (b_tex, b_view) = self.create_backdrop_texture();
-            self.backdrop_texture = b_tex;
-            self.backdrop_texture_view = b_view;
-
-            let window_info_data = [
-                width as f32,
-                height as f32,
-                cce_ui::color::backplate_corner_radius() * self.scale as f32,
-                0.0,
-            ];
-            self.wgpu_adapter.queue.write_buffer(&self.window_info_buffer, 0, bytemuck::cast_slice(&window_info_data));
-
-            self.backdrop_bind_group = self.wgpu_adapter.device.create_bind_group(&wgpu::BindGroupDescriptor {
-                label: Some("Backdrop Bind Group"),
-                layout: &self.backdrop_bind_group_layout,
-                entries: &[
-                    wgpu::BindGroupEntry {
-                        binding: 0,
-                        resource: wgpu::BindingResource::TextureView(&self.backdrop_texture_view),
-                    },
-                    wgpu::BindGroupEntry {
-                        binding: 1,
-                        resource: wgpu::BindingResource::Sampler(&self.backdrop_sampler),
-                    },
-                    wgpu::BindGroupEntry {
-                        binding: 2,
-                        resource: self.window_info_buffer.as_entire_binding(),
-                    },
-                ],
-            });
+            self.renderer
+                .set_corner_radius(cce_ui::color::backplate_corner_radius() * self.scale as f32);
+            self.renderer.resize(width, height);
 
             if old_width > 0.0 {
                 let r = self.width / old_width;
@@ -5573,22 +5036,9 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
 
         self.prepare_text();
 
-        let output = match self.wgpu_adapter.surface.get_current_texture() {
-            Ok(t) => t,
-            Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
-                self.wgpu_adapter.surface.configure(&self.wgpu_adapter.device, &self.wgpu_adapter.config);
-                return false;
-            }
-            Err(wgpu::SurfaceError::Timeout) => return false,
-            Err(e) => { eprintln!("Surface error: {e:?}"); return false; }
-        };
-
-        let view = output.texture.create_view(&wgpu::TextureViewDescriptor::default());
-        let mut encoder = self.wgpu_adapter.device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
-            label: Some("Encoder"),
-        });
-
-        // 3D canvas render pass (background layer)
+        // 3D canvas: stage the scene into the renderer's backdrop when the
+        // viewport is visible and its inputs changed; unstaged frames reuse the
+        // previous backdrop (the renderer's equivalent of the old cached pass).
         if !self.is_detached_network && self.show_viewport {
             let cx_logical = 0.0;
             let cy_logical = HEADER_H;
@@ -5608,7 +5058,6 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
             }
 
             if cw > 0 && ch > 0 {
-
                 let mut camera_pos = Vec3::new(2.5, 1.8, 2.5);
                 let mut rx = 0.0f32;
                 let mut ry = 0.0f32;
@@ -5682,26 +5131,7 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                 if viewport_changed {
                     let aspect = cw as f32 / ch as f32;
                     let (proj, view_mat, model) = self.viewport().get_matrices(aspect, Some(camera_pos), Some(Vec3::new(rx, ry, rz)), Some(pivot));
-                    let mvp = proj * view_mat * model;
-                    let window_size = [self.physical_width as f32, self.physical_height as f32];
-                    let window_radius = cce_ui::color::backplate_corner_radius() * self.scale as f32;
-
-                    let uniforms = ViewportUniforms {
-                        mvp: mvp.to_cols_array_2d(),
-                        window_size,
-                        window_radius,
-                        _padding: 0.0,
-                    };
-                    self.wgpu_adapter.queue.write_buffer(&self.uniform_buffer, 0, bytemuck::cast_slice(&[uniforms]));
-
-                    let mvp_grid = proj * view_mat * model;
-                    let uniforms_grid = ViewportUniforms {
-                        mvp: mvp_grid.to_cols_array_2d(),
-                        window_size,
-                        window_radius,
-                        _padding: 0.0,
-                    };
-                    self.wgpu_adapter.queue.write_buffer(&self.uniform_buffer_grid, 0, bytemuck::cast_slice(&[uniforms_grid]));
+                    let mvp = (proj * view_mat * model).to_cols_array_2d();
 
                     let cam_angle_y = camera_pos.x.atan2(camera_pos.z);
                     let rot_angle = if self.active_camera != "Default Camera" {
@@ -5712,79 +5142,27 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                         self.viewport().rotation_y + cam_angle_y
                     };
                     let model_pivot = Mat4::from_translation(pivot) * Mat4::from_rotation_y(rot_angle);
-                    let mvp_pivot = proj * view_mat * model_pivot;
-                    let uniforms_pivot = ViewportUniforms {
-                        mvp: mvp_pivot.to_cols_array_2d(),
-                        window_size,
-                        window_radius,
-                        _padding: 0.0,
-                    };
-                    self.wgpu_adapter.queue.write_buffer(&self.uniform_buffer_pivot, 0, bytemuck::cast_slice(&[uniforms_pivot]));
-
-                    let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                        label: Some("3D Render Pass"),
-                        color_attachments: &[Some(wgpu::RenderPassColorAttachment {
-                            view: &self.backdrop_texture_view,
-                            resolve_target: None,
-                            ops: wgpu::Operations {
-                                load: wgpu::LoadOp::Clear(wgpu::Color {
-                                    r: 0.0,
-                                    g: 0.0,
-                                    b: 0.0,
-                                    a: 0.0,
-                                }),
-                                store: wgpu::StoreOp::Store,
-                            },
-                        })],
-                        depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
-                            view: &self.depth_texture_view,
-                            depth_ops: Some(wgpu::Operations {
-                                load: wgpu::LoadOp::Clear(1.0),
-                                store: wgpu::StoreOp::Discard,
-                            }),
-                            stencil_ops: None,
-                        }),
-                        timestamp_writes: None,
-                        occlusion_query_set: None,
-                    });
-
-                    pass.set_scissor_rect(sx, sy, cw, ch);
-                    pass.set_pipeline(&self.pipeline_3d);
-
-                    // Draw viewport background quad (rounds corners via shader)
-                    pass.set_bind_group(0, &self.bind_group_3d, &[]);
-                    pass.set_vertex_buffer(0, self.vertex_buffer_viewport_bg.slice(..));
-                    pass.draw(0..6, 0..1);
+                    let mvp_pivot = (proj * view_mat * model_pivot).to_cols_array_2d();
 
+                    // Same draw order as the wgpu pass: bg quad, grid, origin,
+                    // pivot, cube, spheres.
+                    let mut draws = vec![SceneDraw { mesh: self.mesh_viewport_bg, mvp }];
                     if self.viewport().show_grid {
-                        pass.set_bind_group(0, &self.bind_group_grid, &[]);
-                        pass.set_vertex_buffer(0, self.vertex_buffer_grid.slice(..));
-                        pass.draw(0..self.vertex_count_grid, 0..1);
+                        draws.push(SceneDraw { mesh: self.mesh_grid, mvp });
                     }
-
                     if self.viewport().show_origin {
-                        pass.set_bind_group(0, &self.bind_group_3d, &[]);
-                        pass.set_vertex_buffer(0, self.vertex_buffer_origin.slice(..));
-                        pass.draw(0..self.vertex_count_origin, 0..1);
+                        draws.push(SceneDraw { mesh: self.mesh_origin, mvp });
                     }
-
                     if self.viewport().show_camera_pivot {
-                        pass.set_bind_group(0, &self.bind_group_pivot, &[]);
-                        pass.set_vertex_buffer(0, self.vertex_buffer_pivot.slice(..));
-                        pass.draw(0..self.vertex_count_pivot, 0..1);
+                        draws.push(SceneDraw { mesh: self.mesh_pivot, mvp: mvp_pivot });
                     }
-
-                    pass.set_bind_group(0, &self.bind_group_3d, &[]);
-
                     if self.viewport().show_cube {
-                        pass.set_vertex_buffer(0, self.vertex_buffer_3d.slice(..));
-                        pass.draw(0..self.vertex_count_3d, 0..1);
+                        draws.push(SceneDraw { mesh: self.mesh_cube, mvp });
                     }
-
                     if self.vertex_count_spheres > 0 {
-                        pass.set_vertex_buffer(0, self.vertex_buffer_spheres.slice(..));
-                        pass.draw(0..self.vertex_count_spheres, 0..1);
+                        draws.push(SceneDraw { mesh: self.mesh_spheres, mvp });
                     }
+                    self.renderer.stage_scene((sx, sy, cw, ch), draws);
 
                     // Update viewport cache
                     self.last_viewport_camera_pos = camera_pos;
@@ -5806,112 +5184,23 @@ pub(crate) fn geometry_to_spreadsheet_data(geom: &Geometry) -> (Vec<String>, Vec
                     self.last_viewport_show_viewport = self.show_viewport;
                     self.viewport_dirty = false;
                 }
-            } else {
-                let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                    label: Some("Backdrop Clear Pass"),
-                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
-                        view: &self.backdrop_texture_view,
-                        resolve_target: None,
-                        ops: wgpu::Operations {
-                            load: wgpu::LoadOp::Clear({
-                                let linear_bg = cce_ui::colors::to_linear_rgb(self.viewport().bg_color);
-                                wgpu::Color {
-                                    r: linear_bg[0] as f64,
-                                    g: linear_bg[1] as f64,
-                                    b: linear_bg[2] as f64,
-                                    a: 1.0,
-                                }
-                            }),
-                            store: wgpu::StoreOp::Store,
-                        },
-                    })],
-                    depth_stencil_attachment: None,
-                    timestamp_writes: None,
-                    occlusion_query_set: None,
-                });
+            } else if self.viewport_dirty {
+                // Zero-area pane: clear the backdrop once.
+                self.renderer
+                    .stage_scene((0, 0, self.physical_width, self.physical_height), Vec::new());
+                self.last_viewport_show_viewport = false;
+                self.viewport_dirty = false;
             }
-        } else {
-            let _pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                label: Some("Backdrop Clear Pass"),
-                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
-                    view: &self.backdrop_texture_view,
-                    resolve_target: None,
-                    ops: wgpu::Operations {
-                        load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }),
-                        store: wgpu::StoreOp::Store,
-                    },
-                })],
-                depth_stencil_attachment: None,
-                timestamp_writes: None,
-                occlusion_query_set: None,
-            });
-        }
-
-        // Copy backdrop to output swapchain texture
-        if !self.is_detached_network {
-            encoder.copy_texture_to_texture(
-                wgpu::TexelCopyTextureInfo {
-                    texture: &self.backdrop_texture,
-                    mip_level: 0,
-                    origin: wgpu::Origin3d::ZERO,
-                    aspect: wgpu::TextureAspect::All,
-                },
-                wgpu::TexelCopyTextureInfo {
-                    texture: &output.texture,
-                    mip_level: 0,
-                    origin: wgpu::Origin3d::ZERO,
-                    aspect: wgpu::TextureAspect::All,
-                },
-                wgpu::Extent3d {
-                    width: self.physical_width,
-                    height: self.physical_height,
-                    depth_or_array_layers: 1,
-                },
-            );
-        }
-
-        // UI render pass (foreground layer)
-        {
-            let load_op = if self.is_detached_network {
-                wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 })
-            } else {
-                wgpu::LoadOp::Load
-            };
-
-            let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                label: Some("UI Render Pass"),
-                color_attachments: &[Some(wgpu::RenderPassColorAttachment {
-                    view: &view,
-                    resolve_target: None,
-                    ops: wgpu::Operations {
-                        load: load_op,
-                        store: wgpu::StoreOp::Store,
-                    },
-                })],
-                depth_stencil_attachment: None,
-                timestamp_writes: None,
-                occlusion_query_set: None,
-            });
-
-            pass.set_pipeline(&self.render_pipeline);
-            pass.set_bind_group(0, &self.backdrop_bind_group, &[]);
-            pass.set_vertex_buffer(0, self.vertex_buffer.slice(..));
-            pass.draw(0..self.vertex_count, 0..1);
-
-            if self.textured_vertex_count > 0 {
-                // eprintln!("DEBUG_RENDER: textured_vertex_count={}", self.textured_vertex_count);
-                pass.set_pipeline(&self.curved_text_pipeline);
-                pass.set_bind_group(0, &self.curved_text_bind_group, &[]);
-                pass.set_vertex_buffer(0, self.textured_vertex_buffer.slice(..));
-                pass.draw(0..self.textured_vertex_count, 0..1);
-            }
-
-            self.wgpu_adapter.text_renderer.render(&self.wgpu_adapter.text_atlas, &self.wgpu_adapter.text_viewport, &mut pass).unwrap();
-
+        } else if !self.is_detached_network && self.viewport_dirty {
+            // Viewport hidden: clear the backdrop (the old path's clear pass),
+            // and force a re-stage when it comes back.
+            self.renderer
+                .stage_scene((0, 0, self.physical_width, self.physical_height), Vec::new());
+            self.last_viewport_show_viewport = false;
+            self.viewport_dirty = false;
         }
 
-        self.wgpu_adapter.queue.submit(std::iter::once(encoder.finish()));
-        output.present();
+        let _ = self.renderer.draw_frame(&self.vertex_data);
         tick_changed || panned
     }
 }
diff --git a/src/geometry.rs b/src/geometry.rs
index 908c44e..22fdd12 100644
--- a/src/geometry.rs
+++ b/src/geometry.rs
@@ -111,18 +111,6 @@ pub struct Vertex3D {
     pub color: [f32; 3],
 }
 
-impl Vertex3D {
-    const ATTRIBS: [wgpu::VertexAttribute; 2] = wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3];
-
-    pub fn desc() -> wgpu::VertexBufferLayout<'static> {
-        wgpu::VertexBufferLayout {
-            array_stride: std::mem::size_of::<Self>() as wgpu::BufferAddress,
-            step_mode: wgpu::VertexStepMode::Vertex,
-            attributes: &Self::ATTRIBS,
-        }
-    }
-}
-
 pub fn cube_vertices() -> Vec<Vertex3D> {
     let s = 0.5;
     let data: &[([f32; 3], [f32; 3])] = &[
diff --git a/src/graphics.rs b/src/graphics.rs
deleted file mode 100644
index e467a5a..0000000
--- a/src/graphics.rs
+++ /dev/null
@@ -1,26 +0,0 @@
-
-#[repr(C)]
-#[derive(Debug, Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
-pub struct TexturedVertex {
-    pub position: [f32; 2],
-    pub tex_coords: [f32; 2],
-    pub color: [f32; 4],
-    pub clip_circle: [f32; 3],
-}
-
-impl TexturedVertex {
-    const ATTRIBS: [wgpu::VertexAttribute; 4] = wgpu::vertex_attr_array![
-        0 => Float32x2,
-        1 => Float32x2,
-        2 => Float32x4,
-        3 => Float32x3,
-    ];
-
-    pub fn desc() -> wgpu::VertexBufferLayout<'static> {
-        wgpu::VertexBufferLayout {
-            array_stride: std::mem::size_of::<TexturedVertex>() as wgpu::BufferAddress,
-            step_mode: wgpu::VertexStepMode::Vertex,
-            attributes: &Self::ATTRIBS,
-        }
-    }
-}
diff --git a/src/main.rs b/src/main.rs
index e80f7d5..964ba9e 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -42,7 +42,7 @@ use glam::{Mat4, Vec3};
 
 pub mod app;
 pub mod viewport_3d;
-pub mod graphics;
+pub mod vk;
 pub mod api;
 pub mod window;
 pub mod geometry;
@@ -102,7 +102,7 @@ fn main() {
         ((1280.0 * scale) as u32, (800.0 * scale) as u32)
     };
 
-    let state = pollster::block_on(State::new(
+    let state = State::new(
         &conn,
         &qh,
         &app.compositor_state,
@@ -110,7 +110,7 @@ fn main() {
         pw, ph,
         scale,
         is_detached_network,
-    ));
+    );
 
     app.window = Some(state.window.clone());
     app.surface = Some(state.wl_surface.clone());
diff --git a/src/render.rs b/src/render.rs
index c0a4610..3390111 100644
--- a/src/render.rs
+++ b/src/render.rs
@@ -1,5 +1,4 @@
 
-use glyphon::{Buffer, Resolution, TextArea, TextBounds};
 use cce_ui::widget::TextLabel;
 use cce_ui::colors;
 use cce_ui::widget::WidgetHost;
@@ -14,11 +13,11 @@ use crate::app::{
     LEFT_MENUBAR_IDX, PARAM_MENUBAR_IDX, NETWORK_PANEL_IDX,
     push_circle_vertices, push_circle_border_vertices,
 };
-use crate::graphics::TexturedVertex;
 use cce_ui::engine::Vertex;
 use crate::geometry::{
     network_sphere_vertices_with_errors,
 };
+use crate::vk::TextSpan;
 use cce_ui::engine::{
     push_widget_vertices, push_extra_quad_vertices,
     push_extra_quad_vertices_clipped, push_arc_background_vertices,
@@ -26,36 +25,6 @@ use cce_ui::engine::{
 };
 
 impl State {
-    pub(crate) fn create_depth_texture(&self) -> (wgpu::Texture, wgpu::TextureView) {
-        let tex = self.wgpu_adapter.device.create_texture(&wgpu::TextureDescriptor {
-            label: Some("Depth Texture"),
-            size: wgpu::Extent3d { width: self.physical_width.max(1), height: self.physical_height.max(1), depth_or_array_layers: 1 },
-            mip_level_count: 1,
-            sample_count: 1,
-            dimension: wgpu::TextureDimension::D2,
-            format: wgpu::TextureFormat::Depth32Float,
-            usage: wgpu::TextureUsages::RENDER_ATTACHMENT,
-            view_formats: &[],
-        });
-        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
-        (tex, view)
-    }
-
-    pub(crate) fn create_backdrop_texture(&self) -> (wgpu::Texture, wgpu::TextureView) {
-        let tex = self.wgpu_adapter.device.create_texture(&wgpu::TextureDescriptor {
-            label: Some("Backdrop Texture"),
-            size: wgpu::Extent3d { width: self.physical_width.max(1), height: self.physical_height.max(1), depth_or_array_layers: 1 },
-            mip_level_count: 1,
-            sample_count: 1,
-            dimension: wgpu::TextureDimension::D2,
-            format: self.wgpu_adapter.config.format,
-            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_SRC,
-            view_formats: &[],
-        });
-        let view = tex.create_view(&wgpu::TextureViewDescriptor::default());
-        (tex, view)
-    }
-
     pub(crate) fn collect_vertices(&mut self, verts: &mut Vec<Vertex>) {
         verts.clear();
         let sw = self.width;
@@ -243,7 +212,7 @@ impl State {
             let cx = self.circular_network_layout.x;
             let cy = self.circular_network_layout.y;
             let r = self.circular_network_layout.r;
-            
+
             let bg_color = w.color();
             push_arc_background_vertices(
                 cx, cy, r,
@@ -256,7 +225,7 @@ impl State {
                 active_clip_circle,
                 verts,
             );
-            
+
             let border_color = [0.22, 0.22, 0.28, 0.90 * self.network_opacity];
             push_arc_background_vertices(
                 cx, cy, r - MENUBAR_H,
@@ -269,7 +238,7 @@ impl State {
                 active_clip_circle,
                 verts,
             );
-            
+
             for (qx, qy, qw, qh, qc) in w.extra_quads() {
                 push_extra_quad_vertices(w, qx, qy, qw, qh, sw, sh, qc, active_clip_circle, verts);
             }
@@ -396,22 +365,12 @@ impl State {
         }
     }
 
+    /// Rebuild the 2D vertex stream. The actual GPU upload happens in
+    /// `VkRenderer::draw_frame`, which consumes `vertex_data` every frame.
     pub(crate) fn upload_vertices(&mut self) {
         self.text_dirty = true;
         let mut verts = std::mem::take(&mut self.vertex_data);
         self.collect_vertices(&mut verts);
-        self.vertex_count = verts.len() as u32;
-        let data = bytemuck::cast_slice(&verts);
-        let needed = data.len() as wgpu::BufferAddress;
-        if needed > self.vertex_buffer.size() {
-            self.vertex_buffer = self.wgpu_adapter.device.create_buffer(&wgpu::BufferDescriptor {
-                label: Some("Vertex Buffer"),
-                size: needed,
-                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                mapped_at_creation: false,
-            });
-        }
-        self.wgpu_adapter.queue.write_buffer(&self.vertex_buffer, 0, data);
         self.vertex_data = verts;
     }
 
@@ -441,17 +400,7 @@ impl State {
 
         let verts = geom.to_vertex3d_vec();
         self.vertex_count_spheres = verts.len() as u32;
-        let data = bytemuck::cast_slice(&verts);
-        let needed = data.len() as wgpu::BufferAddress;
-        if needed > self.vertex_buffer_spheres.size() {
-            self.vertex_buffer_spheres = self.wgpu_adapter.device.create_buffer(&wgpu::BufferDescriptor {
-                label: Some("Vertex Buffer Spheres"),
-                size: needed,
-                usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                mapped_at_creation: false,
-            });
-        }
-        self.wgpu_adapter.queue.write_buffer(&self.vertex_buffer_spheres, 0, data);
+        self.renderer.update_mesh(self.mesh_spheres, bytemuck::cast_slice(&verts));
         self.viewport_dirty = true;
     }
 
@@ -501,11 +450,11 @@ impl State {
         }
         self.text_dirty = false;
 
-        // 1. Prepare text on all widgets using self.wgpu_adapter.font_system
+        // 1. Prepare text on all widgets using self.font_system
         for i in 0..WIDGET_COUNT {
             let is_menubar = i == HEADER_IDX || i == LEFT_MENUBAR_IDX || i == RIGHT_MENUBAR_IDX || i == PARAM_MENUBAR_IDX || i == SPREADSHEET_MENUBAR_IDX;
             if !is_menubar {
-                self.slots.get_dyn_mut(i).prepare_text(&mut self.wgpu_adapter.font_system);
+                self.slots.get_dyn_mut(i).prepare_text(&mut self.font_system);
             }
         }
 
@@ -516,32 +465,22 @@ impl State {
         let network_circle_x = self.circular_network_layout.x;
         let network_circle_y = self.circular_network_layout.y;
         let network_circle_radius = self.circular_network_layout.r;
+        let network_opacity = self.network_opacity;
+        let focused_widget = self.focused_widget;
+        let _ = sw;
+        let _ = sh;
 
         let Self {
-            ref mut wgpu_adapter,
+            ref mut renderer,
+            ref mut font_system,
+            ref mut swash_cache,
             physical_width, physical_height, scale,
             ref slots,
-            ref curved_text_texture,
-            ref mut curved_text_atlas,
-            ref mut curved_text_renderer,
-            ref mut curved_text_viewport,
-            ref mut textured_vertex_buffer,
-            ref mut textured_vertex_count,
             ref mut text_buffer_cache,
             ref ui_context,
+            ref positions,
             ..
-        } = self;
-
-        let cce_ui::backend::WgpuAdapter {
-            ref device,
-            ref queue,
-            ref mut font_system,
-            ref mut text_atlas,
-            ref mut text_viewport,
-            ref mut text_renderer,
-            ref mut swash_cache,
-            ..
-        } = wgpu_adapter;
+        } = *self;
 
         // Clear cache if too large to prevent unbounded memory growth
         if text_buffer_cache.len() > 500 {
@@ -617,7 +556,7 @@ impl State {
             if is_menubar {
                 continue;
             }
-            if self.focused_widget == Some(i) || i == PARAM_IDX {
+            if focused_widget == Some(i) || i == PARAM_IDX {
                 let mut popover_pc = cce_ui::layout::PopoverCollector::new();
                 w.render_popover(&mut popover_pc);
                 for (t, size, _x, _y, _tc, font_opt, _bounds) in popover_pc.texts {
@@ -630,44 +569,12 @@ impl State {
             }
         }
 
-        let viewport = Resolution { width: *physical_width, height: *physical_height };
-        text_viewport.update(queue, viewport);
-        let s = *scale as f32;
-
-        let mut popovers = Vec::new();
-        fn collect_popovers(
-            w: &dyn WidgetHost,
-            popovers: &mut Vec<(f32, f32, f32, f32)>,
-            ctx: &cce_ui::context::UiContext,
-        ) {
-            if let Some(rect) = w.popover_rect() {
-                popovers.push(rect);
-            }
-            for child_ptr in ctx.tree.children_ptrs(w.base().id()) {
-                unsafe {
-                    if let Some(child) = child_ptr.as_ref() {
-                        collect_popovers(child, popovers, ctx);
-                    }
-                }
-            }
-        }
+        let s = scale as f32;
 
-        for i in 0..WIDGET_COUNT {
-            let w = slots.get_dyn(i);
-            if w.visible() {
-                collect_popovers(w, &mut popovers, ui_context);
-            }
-        }
-
-        let mut areas: Vec<TextArea> = Vec::new();
-
-        // Temporary storage for legacy buffers generated during this frame
-        let mut legacy_buffers: Vec<&Buffer> = Vec::new();
-        let mut legacy_labels: Vec<TextLabel> = Vec::new();
-        let mut legacy_bounds: Vec<TextBounds> = Vec::new();
-        let mut legacy_is_network: Vec<bool> = Vec::new();
-
-        let mut curved_labels = Vec::new();
+        // Build the frame's spans against the shaped cache. From here the cache
+        // is only read (`get`), so the borrows stay immutable for the spans.
+        let text_buffer_cache = &*text_buffer_cache;
+        let mut spans: Vec<TextSpan> = Vec::new();
 
         for i in 0..WIDGET_COUNT {
             let w = slots.get_dyn(i);
@@ -681,30 +588,26 @@ impl State {
             let is_node = i == CONTENT_IDX;
             let is_network_part = i == CONTENT_IDX || i == LEFT_MENUBAR_IDX || i == BREADCRUMB_IDX || i == NETWORK_PANEL_IDX;
 
+            // Widget-level clip bounds (physical px), matching the old TextBounds.
             let bounds = if is_node {
                 if circular_network_pane {
-                    TextBounds {
-                        left: ((network_circle_x - network_circle_radius) * s) as i32,
-                        top: ((network_circle_y - network_circle_radius) * s) as i32,
-                        right: ((network_circle_x + network_circle_radius) * s) as i32,
-                        bottom: (((network_circle_y + network_circle_radius) * s) as i32).max(0),
-                    }
+                    [
+                        ((network_circle_x - network_circle_radius) * s) as i32,
+                        ((network_circle_y - network_circle_radius) * s) as i32,
+                        ((network_circle_x + network_circle_radius) * s) as i32,
+                        (((network_circle_y + network_circle_radius) * s) as i32).max(0),
+                    ]
                 } else {
-                    let (gx, gy, gw, gh) = self.positions[CONTENT_IDX];
-                    TextBounds {
-                        left: (gx * s) as i32,
-                        top: (gy * s) as i32,
-                        right: ((gx + gw) * s) as i32,
-                        bottom: (((gy + gh) * s) as i32).max(0),
-                    }
+                    let (gx, gy, gw, gh) = positions[CONTENT_IDX];
+                    [
+                        (gx * s) as i32,
+                        (gy * s) as i32,
+                        ((gx + gw) * s) as i32,
+                        (((gy + gh) * s) as i32).max(0),
+                    ]
                 }
             } else {
-                TextBounds {
-                    left: 0,
-                    top: 0,
-                    right: *physical_width as i32,
-                    bottom: *physical_height as i32,
-                }
+                [0, 0, physical_width as i32, physical_height as i32]
             };
 
             for (text, x, y, font_size, color, font, label_bounds) in &widget_text[i] {
@@ -719,7 +622,38 @@ impl State {
                 }
 
                 if is_curved {
-                    curved_labels.push(TextLabel { text: text.clone(), x: *x, y: *y, font_size: *font_size, color: *color });
+                    // Curved rim label: rotate the glyph quad about its center to
+                    // face outward, clipped to the circular pane — the ash port of
+                    // the old render-to-texture + TexturedVertex path.
+                    let font_size = 12.0;
+                    let tw = TextLabel::estimate_width(text, font_size);
+                    let th = font_size;
+                    let cx = x + tw / 2.0;
+                    let cy = y + th / 2.0;
+                    let theta = (cy - network_circle_y).atan2(cx - network_circle_x);
+                    let angle = theta + std::f32::consts::FRAC_PI_2;
+                    let key = (text.clone(), (font_size * 100.0) as u32, None);
+                    if let Some(buf) = text_buffer_cache.get(&key) {
+                        spans.push(TextSpan {
+                            buffer: buf,
+                            left: (x * s).round(),
+                            top: (y * s).round(),
+                            scale: s,
+                            bounds: None,
+                            default_color: [
+                                color[0] as f32 / 255.0,
+                                color[1] as f32 / 255.0,
+                                color[2] as f32 / 255.0,
+                                1.0,
+                            ],
+                            rotation: Some((angle, cx * s, cy * s)),
+                            clip_circle: [
+                                network_circle_x * s,
+                                network_circle_y * s,
+                                network_circle_radius * s,
+                            ],
+                        });
+                    }
                 } else {
                     if circular_network_pane && is_network_part && i != LEFT_MENUBAR_IDX {
                         let dx = x - network_circle_x;
@@ -735,43 +669,36 @@ impl State {
                         let pt = (t * s).round() as i32;
                         let pr = (r * s).round() as i32;
                         let pb = (b * s).round() as i32;
-                        item_bounds = TextBounds {
-                            left: item_bounds.left.max(pl),
-                            top: item_bounds.top.max(pt),
-                            right: item_bounds.right.min(pr),
-                            bottom: item_bounds.bottom.min(pb),
-                        };
+                        item_bounds = [
+                            item_bounds[0].max(pl),
+                            item_bounds[1].max(pt),
+                            item_bounds[2].min(pr),
+                            item_bounds[3].min(pb),
+                        ];
                     }
                     let key = (text.clone(), (font_size * 100.0) as u32, font.clone());
-                    let buf_ref = text_buffer_cache.get(&key).unwrap();
-
-                    legacy_buffers.push(buf_ref);
-                    legacy_labels.push(TextLabel { text: text.clone(), x: *x, y: *y, font_size: *font_size, color: *color });
-                    legacy_bounds.push(item_bounds);
-                    legacy_is_network.push(is_network_part);
+                    let Some(buf) = text_buffer_cache.get(&key) else { continue };
+                    let alpha = if is_network_part { network_opacity.clamp(0.0, 1.0) } else { 1.0 };
+                    spans.push(TextSpan {
+                        buffer: buf,
+                        left: (x * s).round(),
+                        top: (y * s).round(),
+                        scale: s,
+                        bounds: Some(item_bounds),
+                        default_color: [
+                            color[0] as f32 / 255.0,
+                            color[1] as f32 / 255.0,
+                            color[2] as f32 / 255.0,
+                            alpha,
+                        ],
+                        rotation: None,
+                        clip_circle: [0.0; 3],
+                    });
                 }
             }
         }
 
-        // Add the legacy buffered items
-        for (((buf, label), bounds), is_net) in legacy_buffers.iter()
-            .zip(legacy_labels.iter())
-            .zip(legacy_bounds.iter())
-            .zip(legacy_is_network.iter())
-        {
-            let alpha = if *is_net { (self.network_opacity * 255.0).clamp(0.0, 255.0) as u8 } else { 255 };
-            areas.push(TextArea {
-                buffer: *buf,
-                left: (label.x * s).round(),
-                top: (label.y * s).round(),
-                scale: s,
-                bounds: *bounds,
-                default_color: glyphon::Color::rgba(label.color[0], label.color[1], label.color[2], alpha),
-                custom_glyphs: &[],
-            });
-        }
-
-        // Add popover text areas
+        // Popover text spans
         for i in 0..WIDGET_COUNT {
             let w = slots.get_dyn(i);
             if !w.visible() {
@@ -781,230 +708,41 @@ impl State {
             if is_menubar {
                 continue;
             }
-            if self.focused_widget == Some(i) || i == PARAM_IDX {
+            if focused_widget == Some(i) || i == PARAM_IDX {
                 let mut popover_pc = cce_ui::layout::PopoverCollector::new();
                 w.render_popover(&mut popover_pc);
                 for (t, size, x, y, tc, font_opt, label_bounds) in popover_pc.texts {
                     let key = (t.clone(), (size * 100.0) as u32, font_opt.clone());
-                    if let Some(buf_ref) = text_buffer_cache.get(&key) {
-                        let mut item_bounds = TextBounds {
-                            left: 0,
-                            top: 0,
-                            right: *physical_width as i32,
-                            bottom: *physical_height as i32,
-                        };
+                    if let Some(buf) = text_buffer_cache.get(&key) {
+                        let mut item_bounds = [0, 0, physical_width as i32, physical_height as i32];
                         if let Some([l, t_bound, r, b]) = label_bounds {
                             let pl = (l * s).round() as i32;
                             let pt = (t_bound * s).round() as i32;
                             let pr = (r * s).round() as i32;
                             let pb = (b * s).round() as i32;
-                            item_bounds = TextBounds {
-                                left: item_bounds.left.max(pl),
-                                top: item_bounds.top.max(pt),
-                                right: item_bounds.right.min(pr),
-                                bottom: item_bounds.bottom.min(pb),
-                            };
+                            item_bounds = [
+                                item_bounds[0].max(pl),
+                                item_bounds[1].max(pt),
+                                item_bounds[2].min(pr),
+                                item_bounds[3].min(pb),
+                            ];
                         }
-                        areas.push(TextArea {
-                            buffer: buf_ref,
+                        spans.push(TextSpan {
+                            buffer: buf,
                             left: (x * s).round(),
                             top: (y * s).round(),
                             scale: s,
-                            bounds: item_bounds,
-                            default_color: glyphon::Color::rgb(
-                                (tc[0] * 255.0) as u8,
-                                (tc[1] * 255.0) as u8,
-                                (tc[2] * 255.0) as u8,
-                            ),
-                            custom_glyphs: &[],
+                            bounds: Some(item_bounds),
+                            default_color: [tc[0], tc[1], tc[2], 1.0],
+                            rotation: None,
+                            clip_circle: [0.0; 3],
                         });
                     }
                 }
             }
         }
 
-        if !popovers.is_empty() {
-            for (idx, area) in areas.iter().enumerate() {
-                for run in area.buffer.layout_runs() {
-                    println!("DEBUG: Area {}, text={:?}, x={}, y={}", idx, run.text, area.left, area.top);
-                }
-            }
-        }
-
-        text_renderer.prepare(device, queue, font_system, text_atlas, text_viewport, areas, swash_cache).unwrap();
-
-        // Process curved labels
-        let mut textured_verts = Vec::new();
-
-        if !curved_labels.is_empty() {
-            struct CurvedDrawInfo<'a> {
-                label: TextLabel,
-                tx: f32,
-                ty: f32,
-                tw: f32,
-                th: f32,
-                buffer: &'a Buffer,
-            }
-
-            let mut curved_draws = Vec::new();
-            let mut current_x = 4.0;
-            let mut current_y = 4.0;
-            let font_size = 12.0;
-            let row_height = (font_size + 8.0) * s;
-
-            for label in curved_labels {
-                let char_w = TextLabel::estimate_width(&label.text, font_size);
-                let physical_w = char_w * s;
-                if current_x + physical_w + 4.0 > 1024.0 {
-                    current_x = 4.0;
-                    current_y += row_height;
-                }
-                let key = (label.text.clone(), (font_size * 100.0) as u32, None);
-                let buf = text_buffer_cache.get(&key).unwrap();
-                curved_draws.push(CurvedDrawInfo {
-                    label: label.clone(),
-                    tx: current_x,
-                    ty: current_y,
-                    tw: char_w,
-                    th: font_size,
-                    buffer: buf,
-                });
-                current_x += physical_w + 8.0 * s;
-            }
-
-            let mut curved_areas = Vec::new();
-            for draw in &curved_draws {
-                curved_areas.push(TextArea {
-                    buffer: draw.buffer,
-                    left: draw.tx.round(),
-                    top: draw.ty.round(),
-                    scale: s,
-                    bounds: TextBounds {
-                        left: 0,
-                        top: 0,
-                        right: 1024,
-                        bottom: 1024,
-                    },
-                    default_color: glyphon::Color::rgb(255, 255, 255),
-                    custom_glyphs: &[],
-                });
-            }
-
-            curved_text_renderer.prepare(
-                device,
-                queue,
-                font_system,
-                curved_text_atlas,
-                curved_text_viewport,
-                curved_areas,
-                swash_cache,
-            ).unwrap();
-
-            let mut texture_encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor {
-                label: Some("Curved Text Texture Encoder"),
-            });
-            {
-                let view_for_pass = curved_text_texture.create_view(&wgpu::TextureViewDescriptor::default());
-                let mut pass = texture_encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
-                    label: Some("Curved Text Render Pass"),
-                    color_attachments: &[Some(wgpu::RenderPassColorAttachment {
-                        view: &view_for_pass,
-                        resolve_target: None,
-                        ops: wgpu::Operations {
-                            load: wgpu::LoadOp::Clear(wgpu::Color { r: 0.0, g: 0.0, b: 0.0, a: 0.0 }),
-                            store: wgpu::StoreOp::Store,
-                        },
-                    })],
-                    depth_stencil_attachment: None,
-                    timestamp_writes: None,
-                    occlusion_query_set: None,
-                });
-                curved_text_renderer.render(curved_text_atlas, curved_text_viewport, &mut pass).unwrap();
-            }
-            queue.submit(std::iter::once(texture_encoder.finish()));
-
-            let clip_circle_val = if circular_network_pane {
-                [network_circle_x * s, network_circle_y * s, network_circle_radius * s]
-            } else {
-                [0.0, 0.0, 0.0]
-            };
-
-            for draw in curved_draws {
-                let dx = (draw.label.x + draw.tw / 2.0) - network_circle_x;
-                let dy = (draw.label.y + draw.th / 2.0) - network_circle_y;
-                let theta = dy.atan2(dx);
-                let angle = theta + std::f32::consts::FRAC_PI_2;
-
-                let cx = draw.label.x + draw.tw / 2.0;
-                let cy = draw.label.y + draw.th / 2.0;
-                let w_half = draw.tw / 2.0;
-                let h_half = draw.th / 2.0;
-
-                let cos_a = angle.cos();
-                let sin_a = angle.sin();
-
-                let local_pts = [
-                    [-w_half, -h_half],
-                    [w_half, -h_half],
-                    [-w_half, h_half],
-                    [w_half, h_half],
-                ];
-
-                let mut screen_pts = [[0.0; 2]; 4];
-                for (k, pt) in local_pts.iter().enumerate() {
-                    let rx = pt[0] * cos_a - pt[1] * sin_a;
-                    let ry = pt[0] * sin_a + pt[1] * cos_a;
-                    screen_pts[k] = [cx + rx, cy + ry];
-                }
-
-                let ndc_pts = screen_pts.map(|pt| [
-                    (pt[0] / sw) * 2.0 - 1.0,
-                    1.0 - (pt[1] / sh) * 2.0,
-                ]);
-
-                let u0 = draw.tx / 1024.0;
-                let v0 = draw.ty / 1024.0;
-                let u1 = (draw.tx + draw.tw * s) / 1024.0;
-                let v1 = (draw.ty + draw.th * s) / 1024.0;
-
-                let c = [
-                    draw.label.color[0] as f32 / 255.0,
-                    draw.label.color[1] as f32 / 255.0,
-                    draw.label.color[2] as f32 / 255.0,
-                    1.0,
-                ];
-
-                let v_tl = TexturedVertex { position: ndc_pts[0], tex_coords: [u0, v0], color: c, clip_circle: clip_circle_val };
-                let v_tr = TexturedVertex { position: ndc_pts[1], tex_coords: [u1, v0], color: c, clip_circle: clip_circle_val };
-                let v_bl = TexturedVertex { position: ndc_pts[2], tex_coords: [u0, v1], color: c, clip_circle: clip_circle_val };
-                let v_br = TexturedVertex { position: ndc_pts[3], tex_coords: [u1, v1], color: c, clip_circle: clip_circle_val };
-
-                textured_verts.push(v_tl);
-                textured_verts.push(v_tr);
-                textured_verts.push(v_bl);
-
-                textured_verts.push(v_tr);
-                textured_verts.push(v_br);
-                textured_verts.push(v_bl);
-            }
-        }
-
-        *textured_vertex_count = textured_verts.len() as u32;
-        if *textured_vertex_count > 0 {
-            let data = bytemuck::cast_slice(&textured_verts);
-            let needed = data.len() as wgpu::BufferAddress;
-            if needed > textured_vertex_buffer.size() {
-                *textured_vertex_buffer = device.create_buffer(&wgpu::BufferDescriptor {
-                    label: Some("Textured Vertex Buffer"),
-                    size: needed,
-                    usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
-                    mapped_at_creation: false,
-                });
-            }
-            queue.write_buffer(textured_vertex_buffer, 0, data);
-        }
+        renderer.prepare_text(font_system, swash_cache, &spans);
     }
 
 }
-
-
diff --git a/src/shader_textured.wgsl b/src/shader_textured.wgsl
deleted file mode 100644
index 24a34f0..0000000
--- a/src/shader_textured.wgsl
+++ /dev/null
@@ -1,41 +0,0 @@
-struct VertexInput {
-    @location(0) position: vec2f,
-    @location(1) tex_coords: vec2f,
-    @location(2) color: vec4f,
-    @location(3) clip_circle: vec3f,
-}
-
-struct VertexOutput {
-    @builtin(position) clip_position: vec4f,
-    @location(0) tex_coords: vec2f,
-    @location(1) color: vec4f,
-    @location(2) clip_circle: vec3f,
-}
-
-@vertex
-fn vs_main(in: VertexInput) -> VertexOutput {
-    var out: VertexOutput;
-    out.clip_position = vec4f(in.position, 0.0, 1.0);
-    out.tex_coords = in.tex_coords;
-    out.color = in.color;
-    out.clip_circle = in.clip_circle;
-    return out;
-}
-
-@group(0) @binding(0) var t_diffuse: texture_2d<f32>;
-@group(0) @binding(1) var s_diffuse: sampler;
-
-@fragment
-fn fs_main(in: VertexOutput) -> @location(0) vec4f {
-    if (in.clip_circle.z > 0.0) {
-        let dx = in.clip_position.x - in.clip_circle.x;
-        let dy = in.clip_position.y - in.clip_circle.y;
-        if (dx * dx + dy * dy > in.clip_circle.z * in.clip_circle.z) {
-            discard;
-        }
-    }
-    let tex_color = textureSample(t_diffuse, s_diffuse, in.tex_coords);
-    return tex_color * in.color;
-}
-
-
diff --git a/src/vk/glyph.wgsl b/src/vk/glyph.wgsl
index e2eb4a8..8cd26e0 100644
--- a/src/vk/glyph.wgsl
+++ b/src/vk/glyph.wgsl
@@ -9,6 +9,7 @@ struct VertexOutput {
     @builtin(position) clip_position: vec4f,
     @location(0) uv: vec2f,
     @location(1) color: vec4f,
+    @location(2) clip_circle: vec3f,
 }
 
 @vertex
@@ -16,15 +17,26 @@ fn vs_main(
     @location(0) position: vec2f,
     @location(1) uv: vec2f,
     @location(2) color: vec4f,
+    @location(3) clip_circle: vec3f,
 ) -> VertexOutput {
     var out: VertexOutput;
     out.clip_position = vec4f(position, 0.0, 1.0);
     out.uv = uv;
     out.color = color;
+    out.clip_circle = clip_circle;
     return out;
 }
 
 @fragment
 fn fs_main(in: VertexOutput) -> @location(0) vec4f {
+    // Same circle clip as shader.wgsl (framebuffer px): used by the circular
+    // network pane's curved rim labels.
+    if (in.clip_circle.z > 0.0) {
+        let dx = in.clip_position.x - in.clip_circle.x;
+        let dy = in.clip_position.y - in.clip_circle.y;
+        if (dx * dx + dy * dy > in.clip_circle.z * in.clip_circle.z) {
+            discard;
+        }
+    }
     return in.color * textureSample(t_atlas, s_atlas, in.uv);
 }
diff --git a/src/vk/text.rs b/src/vk/text.rs
index 6e88551..ecc5c63 100644
--- a/src/vk/text.rs
+++ b/src/vk/text.rs
@@ -42,6 +42,12 @@ pub struct TextSpan<'a> {
     pub bounds: Option<[i32; 4]>,
     /// 0..=1 sRGB + alpha, applied to glyphs without their own color.
     pub default_color: [f32; 4],
+    /// Rotate the span's glyph quads by (radians, center_x, center_y) in
+    /// physical pixels — the circular network pane's curved rim labels.
+    pub rotation: Option<(f32, f32, f32)>,
+    /// Fragment circle clip (center_x, center_y, radius) in physical pixels;
+    /// zero radius disables (matches shader.wgsl's clip_circle).
+    pub clip_circle: [f32; 3],
 }
 
 #[repr(C)]
@@ -50,6 +56,7 @@ struct GlyphVertex {
     position: [f32; 2],
     uv: [f32; 2],
     color: [f32; 4],
+    clip_circle: [f32; 3],
 }
 
 #[derive(Clone, Copy)]
@@ -198,6 +205,11 @@ impl TextStage {
                     .binding(0)
                     .format(vk::Format::R32G32B32A32_SFLOAT)
                     .offset(16),
+                vk::VertexInputAttributeDescription::default()
+                    .location(3)
+                    .binding(0)
+                    .format(vk::Format::R32G32B32_SFLOAT)
+                    .offset(32),
             ];
             let vertex_input = vk::PipelineVertexInputStateCreateInfo::default()
                 .vertex_binding_descriptions(&vertex_bindings)
@@ -556,14 +568,28 @@ impl TextStage {
                         span.default_color
                     };
 
-                    let ndc = |px: f32, py: f32| {
-                        [(px / sw) * 2.0 - 1.0, 1.0 - (py / sh) * 2.0]
+                    // Corner positions, optionally rotated about the span's center
+                    // (physical px) before the NDC mapping.
+                    let corners = match span.rotation {
+                        None => [[x0, y0], [x1, y0], [x0, y1], [x1, y1]],
+                        Some((angle, cx, cy)) => {
+                            let (sin_a, cos_a) = angle.sin_cos();
+                            let rot = |px: f32, py: f32| {
+                                let (dx, dy) = (px - cx, py - cy);
+                                [cx + dx * cos_a - dy * sin_a, cy + dx * sin_a + dy * cos_a]
+                            };
+                            [rot(x0, y0), rot(x1, y0), rot(x0, y1), rot(x1, y1)]
+                        }
+                    };
+                    let ndc = |p: [f32; 2]| {
+                        [(p[0] / sw) * 2.0 - 1.0, 1.0 - (p[1] / sh) * 2.0]
                     };
                     let uv = |u: f32, v: f32| [u / ATLAS_SIZE as f32, v / ATLAS_SIZE as f32];
-                    let tl = GlyphVertex { position: ndc(x0, y0), uv: uv(u0, v0), color };
-                    let tr = GlyphVertex { position: ndc(x1, y0), uv: uv(u1, v0), color };
-                    let bl = GlyphVertex { position: ndc(x0, y1), uv: uv(u0, v1), color };
-                    let br = GlyphVertex { position: ndc(x1, y1), uv: uv(u1, v1), color };
+                    let clip_circle = span.clip_circle;
+                    let tl = GlyphVertex { position: ndc(corners[0]), uv: uv(u0, v0), color, clip_circle };
+                    let tr = GlyphVertex { position: ndc(corners[1]), uv: uv(u1, v0), color, clip_circle };
+                    let bl = GlyphVertex { position: ndc(corners[2]), uv: uv(u0, v1), color, clip_circle };
+                    let br = GlyphVertex { position: ndc(corners[3]), uv: uv(u1, v1), color, clip_circle };
                     self.pending_vertices.extend([tl, tr, bl, tr, br, bl]);
                 }
             }
diff --git a/src/vk_smoke.rs b/src/vk_smoke.rs
index ad5b992..a4b11cd 100644
--- a/src/vk_smoke.rs
+++ b/src/vk_smoke.rs
@@ -349,6 +349,8 @@ fn main() {
                 scale: s,
                 bounds: None,
                 default_color: [0.92, 0.92, 0.95, 1.0],
+                rotation: None,
+                clip_circle: [0.0; 3],
             },
             TextSpan {
                 buffer: &body_buf,
@@ -358,6 +360,8 @@ fn main() {
                 bounds: None,
                 // Animated color: proves per-frame vertex rebuilds.
                 default_color: [pulse, 0.80, 0.55, 1.0],
+                rotation: None,
+                clip_circle: [0.0; 3],
             },
             TextSpan {
                 buffer: &clipped_buf,
@@ -371,6 +375,8 @@ fn main() {
                     (233.0 * s) as i32,
                 ]),
                 default_color: [0.70, 0.85, 1.00, 1.0],
+                rotation: None,
+                clip_circle: [0.0; 3],
             },
         ];
         if let Some(renderer) = &mut app.renderer {