facett-core/src/render/gpu/graphcloud.rs
//! **THE shared instanced graph-cloud GPU lane** (feature `wgpu`) — an instanced
//! emissive node/edge renderer with real GPU **bloom**, and the ONE renderer every
//! facett graph surface paints its node/edge cloud through.
//!
//! # Why it lives in facett-core (L5)
//!
//! This lane was born inside `facett-graph3d` (as `graph3d::graph_gpu`) to take the
//! measured single-core rotation wall off the CPU: egui tessellates 2.47 M triangles
//! for a 30 000-node cloud on **one** thread (33.75 ms/frame — see
//! `facett/.nornir/graph3d-rotate-perf-design.md`), while the projection that feeds it
//! costs 0.02 ms. The tessellation is the wall, and the cure is to stop tessellating:
//! upload the cloud as **instances** and issue two draw calls.
//!
//! That cure is not 3-D. Every input this module takes is already **screen-space
//! pixels** — a node is a centre + radius, an edge is two endpoints + a half-width. The
//! whole 2-D graph family (`facett-graphpan`, `facett-graphview`, `facett-graphnav`,
//! `facett-graph`) hits exactly the same wall for exactly the same reason, so the
//! renderer moved DOWN here and every graph crate consumes it. `facett_graph3d::graph_gpu`
//! remains as a re-export — the public path is unchanged, and there is still exactly one
//! renderer.
//!
//! It reuses two shared facett-core L0 pieces:
//!
//! - [`OffscreenColorDepth`](crate::render::gpu::OffscreenColorDepth) — the HDR
//! (`Rgba16Float`) colour+depth offscreen target. Nodes/edges draw into its colour
//! attachment with **additive blend** (`src = ONE, dst = ONE`), so a fragment that
//! lands on a texel another already lit **sums** its linear radiance (edge crossings
//! accumulate past 1.0, which the HDR target keeps).
//! - [`GaussianBlur`](crate::render::gpu::GaussianBlur) — the separable blur
//! substrate that spreads the bright-pass output for the bloom.
//!
//! The post chain is bright-pass (soft-knee threshold) → blur → composite (scene +
//! bloom·intensity → Reinhard tonemap → LDR). Emissive node/edge colours carry linear
//! radiance **above 1.0**, so the bright pass isolates exactly the glowing graph and
//! dense crossings bloom hottest.
//!
//! Two entry points:
//! - [`render_graph_offscreen`] — the **headless** render-proof: draws a graph and reads
//! back the LDR framebuffer + the instance draw counts, so the lane is validated as
//! DATA without a windowing host.
//! - [`cloud`] — the **live** `egui_wgpu` paint callback + the install-once lifecycle.
//! Read [`cloud::cloud_renderer_installed`] before you skip a CPU painter.
#![allow(clippy::too_many_lines, reason = "self-contained instanced+bloom pipeline set")]
use egui_wgpu::wgpu;
use crate::render::gpu::offscreen::{OffscreenColorDepth, OFFSCREEN_FORMAT};
use crate::render::gpu::GaussianBlur;
/// One instanced emissive node — a screen-space disc. Matches `NodeInst` in
/// `graph_gpu.wgsl`. 32 bytes.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct NodeInstance {
/// Centre in physical px (top-left origin, y down).
pub center: [f32; 2],
/// Radius in px.
pub radius: f32,
pub _pad: f32,
/// Emissive linear radiance `[r, g, b, a]` (rgb may exceed 1.0 to bloom).
pub color: [f32; 4],
}
/// One instanced emissive edge — a thick screen-space line segment. Matches
/// `EdgeInst` in `graph_gpu.wgsl`. 40 bytes.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct EdgeInstance {
/// Endpoint A in physical px.
pub a: [f32; 2],
/// Endpoint B in physical px.
pub b: [f32; 2],
/// Half-width in px.
pub width: f32,
pub _pad: f32,
/// Emissive linear radiance `[r, g, b, a]`.
pub color: [f32; 4],
}
/// **One node of the CLOUD model** — the form the GPU keeps, uploaded once. 16 bytes.
///
/// `pos` is the node in the layout's `~[-1,1]` cube (NOT screen px: the vertex shader
/// projects it), and `color` is the node's RGBA8 packed little-endian exactly as
/// `egui::Color32::to_array()` gives it. Colour rides in the 16th byte because a WGSL
/// `array<vec3<f32>>` has stride 16 anyway — so the padding a `[f32;3]` would waste is
/// spent on the colour instead, and the whole model is ONE buffer.
#[repr(C)]
#[derive(Clone, Copy, Debug, Default, bytemuck::Pod, bytemuck::Zeroable)]
pub struct CloudPoint {
/// Position in the layout cube (`~[-1,1]³`), y-up.
pub pos: [f32; 3],
/// RGBA8, little-endian (`r | g<<8 | b<<16 | a<<24`).
pub color: u32,
}
impl CloudPoint {
/// Build from a position and an egui colour.
#[must_use]
pub fn new(pos: [f32; 3], color: egui::Color32) -> Self {
let [r, g, b, a] = color.to_array();
Self { pos, color: u32::from(r) | (u32::from(g) << 8) | (u32::from(b) << 16) | (u32::from(a) << 24) }
}
}
/// **One edge of the CLOUD model** — an index pair into [`CloudPoint`]s. 8 bytes,
/// against 40 for a screen-space `EdgeInstance`, and it never changes when the camera
/// moves.
pub type CloudEdge = [u32; 2];
/// The per-frame camera for the cloud pass — **the only thing that moves**. 48 bytes,
/// written once per frame however many million points are on the GPU.
#[repr(C)]
#[derive(Clone, Copy, Debug, bytemuck::Pod, bytemuck::Zeroable)]
pub struct CloudCamera {
/// `xy` = pane size px, `zw` = pane centre px.
pub viewport: [f32; 4],
/// `x` = yaw (rad), `y` = scale px, `z` = emissive peak, `w` = node radius shrink.
pub cam: [f32; 4],
/// `x` = edge half-width px, `y` = edge colour multiplier, `zw` = pad.
pub edge: [f32; 4],
}
impl CloudCamera {
/// The camera a `Graph3D` frame implies: pane `w×h`, orbit `yaw`, `emissive` peak,
/// and the crowd-driven node radius `shrink`. Scale and centre follow the pane the
/// same way the CPU build did (`w.min(h) * 0.40`, centred).
#[must_use]
pub fn frame(w: f32, h: f32, yaw: f32, emissive: f32, shrink: f32) -> Self {
Self {
viewport: [w.max(1.0), h.max(1.0), w * 0.5, h * 0.5],
cam: [yaw, w.min(h) * 0.40, emissive, shrink],
// 1.5 px half-width and 0.6× radiance — the constants the CPU build used.
edge: [1.5, 0.6, 0.0, 0.0],
}
}
/// The camera for a FLAT pan/zoom pane (yaw 0, every point at `z = 0`): the pane is
/// `size_px` physical px, world `(0, 0)` lands at `origin_px` (pane-local physical
/// px), one world unit is `scale_px` px, every disc has radius `radius_px` and every
/// edge half-width `edge_half_px`. Feed points as `[x, −y, 0]`: the shader projects
/// `centre + (x, −y) · scale`, a y-down canvas projects `origin + (x, y) · zoom`.
#[must_use]
pub fn plane(size_px: [f32; 2], origin_px: [f32; 2], scale_px: f32, radius_px: f32, edge_half_px: f32, emissive: f32) -> Self {
Self {
viewport: [size_px[0].max(1.0), size_px[1].max(1.0), origin_px[0], origin_px[1]],
// The node vertex shader's disc is `(3 + (z + 1) · 2.5) · shrink` = 5.5 · shrink at z = 0.
cam: [0.0, scale_px, emissive, radius_px.max(0.5) / 5.5],
edge: [edge_half_px.max(0.5), 0.6, 0.0, 0.0],
}
}
}
/// **A cloud model resident on the GPU** — points + edge indices uploaded once, plus
/// the bind group that reaches them. Hold it across frames; rebuild only when
/// `generation` moves.
///
/// The buffers are kept alive by this struct and reached only through `bg`, hence the
/// underscore names: they are owned, not read.
pub struct CloudBuffers {
bg: wgpu::BindGroup,
/// Points in the model (the node draw's instance count).
pub node_count: u32,
/// Edges in the model (the edge draw's instance count).
pub edge_count: u32,
/// The model version these buffers were built from. A host compares its own
/// counter against this to decide whether to re-upload — the ONE place staleness
/// can be caught, and the reason positions are not a public field anywhere.
pub generation: u64,
_pts: wgpu::Buffer,
_edg: wgpu::Buffer,
}
/// Which source feeds the P1 scene pass. Private: it exists so
/// [`GraphGpu::render_src`] can be written once for both lanes instead of twinned.
enum Src<'a> {
/// Screen-space instances the CPU built this frame.
Instances(&'a GraphScene),
/// Model-space points already resident on the GPU.
Cloud(&'a CloudBuffers),
}
/// A graph to draw on the GPU lane: a background clear colour + the node + edge
/// instance batches (already projected to screen px by the caller).
#[derive(Clone, Default)]
pub struct GraphScene {
/// Background clear colour (linear, `[r, g, b, a]`).
pub background: [f32; 4],
pub nodes: Vec<NodeInstance>,
pub edges: Vec<EdgeInstance>,
}
/// Bloom post-stack tuning (threshold/knee/intensity/exposure). The defaults keep
/// sub-1.0 scene radiance untouched and bloom only the emissive graph.
#[derive(Clone, Copy, Debug)]
pub struct BloomParams {
pub threshold: f32,
pub knee: f32,
pub intensity: f32,
pub exposure: f32,
/// Blur σ (texels) for the glow spread.
pub sigma: f32,
}
impl Default for BloomParams {
fn default() -> Self {
Self { threshold: 1.0, knee: 0.6, intensity: 1.3, exposure: 1.0, sigma: 3.0 }
}
}
impl BloomParams {
/// Bloom disabled (intensity 0) — the A/B reference for the render-proof.
#[must_use]
pub fn off(self) -> Self {
Self { intensity: 0.0, ..self }
}
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct SceneUniforms {
viewport: [f32; 4],
}
#[repr(C)]
#[derive(Clone, Copy, bytemuck::Pod, bytemuck::Zeroable)]
struct PostUniforms {
params: [f32; 4],
}
/// The result of a headless GPU graph render — the read-back LDR framebuffer plus the
/// instance draw counts (the render-proof asserts on these as DATA).
pub struct GraphRender {
pub width: u32,
pub height: u32,
/// Straight RGBA8, row-major (length `width*height*4`).
pub rgba: Vec<u8>,
/// Node instances actually issued to the draw call.
pub node_instances: u32,
/// Edge instances actually issued to the draw call.
pub edge_instances: u32,
}
impl GraphRender {
/// Mean per-pixel luminance (Rec.601) over the LDR frame — the energy measure the
/// bloom A/B oracle compares (bloom on vs off).
#[must_use]
pub fn mean_luma(&self) -> f64 {
let mut sum = 0.0f64;
for px in self.rgba.as_chunks::<4>().0 {
sum += 0.299 * px[0] as f64 + 0.587 * px[1] as f64 + 0.114 * px[2] as f64;
}
let n = (self.width * self.height) as f64;
if n > 0.0 { sum / (n * 255.0) } else { 0.0 }
}
/// Luminance (0..1) of the pixel at `(x, y)` — for the additive-accumulation probe.
#[must_use]
pub fn luma_at(&self, x: u32, y: u32) -> f64 {
if x >= self.width || y >= self.height {
return 0.0;
}
let i = ((y * self.width + x) * 4) as usize;
(0.299 * self.rgba[i] as f64 + 0.587 * self.rgba[i + 1] as f64 + 0.114 * self.rgba[i + 2] as f64) / 255.0
}
}
/// The reusable graph GPU pipelines (node + edge instanced draws + the bloom post
/// stack). Built once from a device + the LDR target format; drive it with
/// [`GraphGpu::render`] (headless) — or, in the follow-up, from an `egui_wgpu`
/// callback.
pub struct GraphGpu {
node_pipeline: wgpu::RenderPipeline,
edge_pipeline: wgpu::RenderPipeline,
bright_pipeline: wgpu::RenderPipeline,
composite_pipeline: wgpu::RenderPipeline,
scene_bgl: wgpu::BindGroupLayout,
sample_bgl: wgpu::BindGroupLayout,
composite_bgl: wgpu::BindGroupLayout,
sampler: wgpu::Sampler,
scene_u: wgpu::Buffer,
post_u: wgpu::Buffer,
/// The CLOUD pass: model-space points + edge indices live on the GPU and only the
/// camera is written per frame. `None` until [`GraphGpu::new`] builds them — they
/// are always built; the option is only so the layout can be borrowed.
cloud_node_pipeline: wgpu::RenderPipeline,
cloud_edge_pipeline: wgpu::RenderPipeline,
cloud_bgl: wgpu::BindGroupLayout,
cloud_u: wgpu::Buffer,
/// Persistent scene bind group (`group(0) = { scene_u }`) for the node/edge passes.
/// Held so the LIVE `egui_wgpu` callback ([`cloud`]) can encode the instanced draw
/// without rebuilding a bind group per frame.
scene_bg: wgpu::BindGroup,
blur: GaussianBlur,
ldr_format: wgpu::TextureFormat,
}
impl GraphGpu {
/// Build the node/edge/bloom pipelines for an `ldr_format` colour target (the
/// composite's output format — the host surface, or `Rgba8Unorm` headless).
pub fn new(device: &wgpu::Device, ldr_format: wgpu::TextureFormat) -> Self {
let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("graph3d_gpu"),
source: wgpu::ShaderSource::Wgsl(crate::render::wgsl::GRAPHCLOUD_WGSL.into()),
});
// group(0) = { scene uniform } for the node/edge passes.
let scene_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("graph3d_scene_bgl"),
entries: &[uniform_entry(0, wgpu::ShaderStages::VERTEX)],
});
// group(0) = { post uniform, src tex, sampler } for the bright pass.
let sample_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("graph3d_sample_bgl"),
entries: &[
uniform_entry(0, wgpu::ShaderStages::FRAGMENT),
tex_entry(1),
sampler_entry(2),
],
});
// group(0) = { post uniform, scene tex, sampler, bloom tex } for composite.
let composite_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("graph3d_composite_bgl"),
entries: &[
uniform_entry(0, wgpu::ShaderStages::FRAGMENT),
tex_entry(1),
sampler_entry(2),
tex_entry(3),
],
});
let additive = wgpu::BlendState {
color: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
alpha: wgpu::BlendComponent {
src_factor: wgpu::BlendFactor::One,
dst_factor: wgpu::BlendFactor::One,
operation: wgpu::BlendOperation::Add,
},
};
let node_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("graph3d_node_pll"),
bind_group_layouts: &[Some(&scene_bgl)],
immediate_size: 0,
});
let node_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("graph3d_node_pipeline"),
layout: Some(&node_pll),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("node_vs"),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<NodeInstance>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
// EXPLICIT offsets: `NodeInstance` has a `_pad` after `radius`, so `color`
// lives at byte 16 — the auto-packed `vertex_attr_array` would place it at
// 12 (over the pad), shifting every channel and zeroing red. (Was a latent
// bug the luma-only render-proof never caught.)
attributes: &[
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 0, shader_location: 0 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32, offset: 8, shader_location: 1 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 16, shader_location: 2 },
],
})],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: None,
multisample: crate::render::gpu::msaa_state(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("node_fs"),
targets: &[Some(wgpu::ColorTargetState {
format: OFFSCREEN_FORMAT,
blend: Some(additive),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
let edge_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("graph3d_edge_pll"),
bind_group_layouts: &[Some(&scene_bgl)],
immediate_size: 0,
});
let edge_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("graph3d_edge_pipeline"),
layout: Some(&edge_pll),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("edge_vs"),
buffers: &[Some(wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<EdgeInstance>() as u64,
step_mode: wgpu::VertexStepMode::Instance,
// EXPLICIT offsets: `EdgeInstance` has a `_pad` after `width`, so `color`
// lives at byte 24 (auto-packing would put it at 20 over the pad).
attributes: &[
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 0, shader_location: 0 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x2, offset: 8, shader_location: 1 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32, offset: 16, shader_location: 2 },
wgpu::VertexAttribute { format: wgpu::VertexFormat::Float32x4, offset: 24, shader_location: 3 },
],
})],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: None,
multisample: crate::render::gpu::msaa_state(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("edge_fs"),
targets: &[Some(wgpu::ColorTargetState {
format: OFFSCREEN_FORMAT,
blend: Some(additive),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
let bright_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("graph3d_bright_pll"),
bind_group_layouts: &[Some(&sample_bgl)],
immediate_size: 0,
});
let bright_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("graph3d_bright_pipeline"),
layout: Some(&bright_pll),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("post_vs"),
buffers: &[],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: None,
multisample: crate::render::gpu::msaa_state(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("bright_fs"),
targets: &[Some(wgpu::ColorTargetState {
format: OFFSCREEN_FORMAT,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
let composite_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("graph3d_composite_pll"),
bind_group_layouts: &[Some(&composite_bgl)],
immediate_size: 0,
});
let composite_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("graph3d_composite_pipeline"),
layout: Some(&composite_pll),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some("post_vs"),
buffers: &[],
compilation_options: Default::default(),
},
primitive: wgpu::PrimitiveState { topology: wgpu::PrimitiveTopology::TriangleList, ..Default::default() },
depth_stencil: None,
multisample: crate::render::gpu::msaa_state(),
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some("composite_fs"),
targets: &[Some(wgpu::ColorTargetState {
format: ldr_format,
blend: None,
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
multiview_mask: None,
cache: None,
});
let sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("graph3d_sampler"),
mag_filter: wgpu::FilterMode::Linear,
min_filter: wgpu::FilterMode::Linear,
address_mode_u: wgpu::AddressMode::ClampToEdge,
address_mode_v: wgpu::AddressMode::ClampToEdge,
..Default::default()
});
let mkbuf = |label: &str, size: u64| device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
let scene_u = mkbuf("graph3d_scene_u", std::mem::size_of::<SceneUniforms>() as u64);
// ── the CLOUD pass: uniform + two read-only STORAGE buffers, vertex stage ──
// Storage in the vertex stage is what lets the shader read `PTS[instance_index]`
// and, for an edge, `PTS[EDG[i].x]` — a random gather a vertex buffer cannot do.
// It is WebGPU-core, not an extension; only the WebGL2 downlevel profile forbids
// it, and LAW 4 rules WebGL out of this constellation.
let storage_entry = |binding: u32| wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::VERTEX,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Storage { read_only: true },
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
};
let cloud_bgl = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("graph3d_cloud_bgl"),
entries: &[uniform_entry(0, wgpu::ShaderStages::VERTEX), storage_entry(1), storage_entry(2)],
});
let cloud_pll = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("graph3d_cloud_pll"),
bind_group_layouts: &[Some(&cloud_bgl)],
immediate_size: 0,
});
// Same fragment shaders, same additive blend, same target as the instance pass —
// only the VERTEX stage differs, which is the whole point: the picture is the
// same picture, computed one step earlier.
let mk_cloud_pipeline = |label: &str, vs: &str, fs: &str| {
device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some(label),
layout: Some(&cloud_pll),
vertex: wgpu::VertexState {
module: &shader,
entry_point: Some(vs),
buffers: &[],
compilation_options: Default::default(),
},
fragment: Some(wgpu::FragmentState {
module: &shader,
entry_point: Some(fs),
targets: &[Some(wgpu::ColorTargetState {
format: OFFSCREEN_FORMAT,
blend: Some(additive),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: Default::default(),
}),
primitive: wgpu::PrimitiveState::default(),
depth_stencil: None,
// Through the ONE writer, not a fresh `MultisampleState::default()` —
// `one_msaa_writer.rs` caught exactly that here and is right to: a sample
// count stated independently per pipeline makes any change an all-sites
// edit, and a missed site is a wgpu VALIDATION failure, not a downgrade.
multisample: crate::render::gpu::msaa_state(),
multiview_mask: None,
cache: None,
})
};
let cloud_node_pipeline = mk_cloud_pipeline("graph3d_cloud_node_pipeline", "cloud_node_vs", "node_fs");
let cloud_edge_pipeline = mk_cloud_pipeline("graph3d_cloud_edge_pipeline", "cloud_edge_vs", "edge_fs");
let cloud_u = mkbuf("graph3d_cloud_u", std::mem::size_of::<CloudCamera>() as u64);
let scene_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("graph3d_scene_bg"),
layout: &scene_bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: scene_u.as_entire_binding() }],
});
Self {
node_pipeline,
edge_pipeline,
cloud_node_pipeline,
cloud_edge_pipeline,
cloud_bgl,
cloud_u,
bright_pipeline,
composite_pipeline,
scene_bgl,
sample_bgl,
composite_bgl,
sampler,
scene_u,
post_u: mkbuf("graph3d_post_u", std::mem::size_of::<PostUniforms>() as u64),
scene_bg,
blur: GaussianBlur::new(device, OFFSCREEN_FORMAT),
ldr_format,
}
}
/// Write the viewport uniform (physical px) — the live callback ([`cloud`]) calls
/// this once per frame before [`encode_scene`](Self::encode_scene).
pub fn write_viewport(&self, queue: &wgpu::Queue, w: u32, h: u32) {
queue.write_buffer(&self.scene_u, 0, bytemuck::bytes_of(&SceneUniforms { viewport: [w.max(1) as f32, h.max(1) as f32, 0.0, 0.0] }));
}
/// **Live-lane scene encode** — record the instanced emissive edge+node draw into
/// `color_view` (an [`OFFSCREEN_FORMAT`] HDR target) on the caller's `encoder`, with
/// the additive-blend accumulation the offscreen path uses. This is the SAME
/// node/edge pipelines the headless [`render`](Self::render) drives (L5 reuse — no
/// second renderer); the caller (the `egui_wgpu` callback) then blits `color_view`
/// into egui's pass. Instance buffers are owned by the caller (kept alive until the
/// frame is submitted). Edges draw first (under the nodes); both accumulate.
#[allow(clippy::too_many_arguments)]
pub fn encode_scene(
&self,
encoder: &mut wgpu::CommandEncoder,
color_view: &wgpu::TextureView,
node_buf: Option<&wgpu::Buffer>,
node_count: u32,
edge_buf: Option<&wgpu::Buffer>,
edge_count: u32,
background: [f32; 4],
) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("graph3d_cloud_scene_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: background[0] as f64,
g: background[1] as f64,
b: background[2] as f64,
a: background[3] as f64,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
if let (Some(eb), true) = (edge_buf, edge_count > 0) {
pass.set_pipeline(&self.edge_pipeline);
pass.set_bind_group(0, &self.scene_bg, &[]);
pass.set_vertex_buffer(0, eb.slice(..));
pass.draw(0..6, 0..edge_count);
}
if let (Some(nb), true) = (node_buf, node_count > 0) {
pass.set_pipeline(&self.node_pipeline);
pass.set_bind_group(0, &self.scene_bg, &[]);
pass.set_vertex_buffer(0, nb.slice(..));
pass.draw(0..6, 0..node_count);
}
}
/// **Upload a cloud model to the GPU** — the once-per-change step. Hold the result
/// and re-use it every frame; rebuild it only when `generation` moves.
///
/// The buffers are made WITHOUT `mappedAtCreation` and filled through the queue
/// ([`checked_buffer`]): on SwiftShader (lane L, loki 2026-10-02) Chrome THREW from a
/// mapped 1.6 MB `createBuffer` ("too large for the implementation") and wgpu's
/// browser backend unwraps that throw — the page panicked. `Err(why)` when the
/// device's limits refuse a buffer; the caller draws nothing and says why.
///
/// # Errors
/// A buffer larger than the device's `max_buffer_size` / `max_storage_buffer_binding_size`.
pub fn upload_cloud(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
points: &[CloudPoint],
edges: &[CloudEdge],
generation: u64,
) -> Result<CloudBuffers, String> {
// wgpu refuses a zero-sized binding, so an empty side gets one dead element and
// a count of 0 — it is bound but never drawn.
let dead_pt = [CloudPoint::default()];
let dead_ed: [CloudEdge; 1] = [[0, 0]];
let pts = checked_buffer(
device,
queue,
"graph3d_cloud_pts",
bytemuck::cast_slice(if points.is_empty() { &dead_pt } else { points }),
wgpu::BufferUsages::STORAGE,
)?;
let edg = checked_buffer(
device,
queue,
"graph3d_cloud_edg",
bytemuck::cast_slice(if edges.is_empty() { &dead_ed } else { edges }),
wgpu::BufferUsages::STORAGE,
)?;
let bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("graph3d_cloud_bg"),
layout: &self.cloud_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: self.cloud_u.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: pts.as_entire_binding() },
wgpu::BindGroupEntry { binding: 2, resource: edg.as_entire_binding() },
],
});
Ok(CloudBuffers {
bg,
node_count: points.len() as u32,
edge_count: edges.len() as u32,
generation,
_pts: pts,
_edg: edg,
})
}
/// **Write the per-frame camera** — 48 bytes, whatever the model size. This is the
/// entire per-frame CPU cost of the cloud lane.
pub fn write_cloud_camera(&self, queue: &wgpu::Queue, cam: &CloudCamera) {
queue.write_buffer(&self.cloud_u, 0, bytemuck::bytes_of(cam));
}
/// **Live-lane CLOUD encode** — the resident-model twin of
/// [`encode_scene`](Self::encode_scene). Write the camera first
/// ([`write_cloud_camera`](Self::write_cloud_camera)); the points and edges are
/// already on the GPU, so this frame sends no geometry at all.
pub fn encode_cloud(
&self,
encoder: &mut wgpu::CommandEncoder,
color_view: &wgpu::TextureView,
cloud: &CloudBuffers,
background: [f32; 4],
) {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("graph3d_cloud_model_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: color_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: background[0] as f64,
g: background[1] as f64,
b: background[2] as f64,
a: background[3] as f64,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
if cloud.edge_count > 0 {
pass.set_pipeline(&self.cloud_edge_pipeline);
pass.set_bind_group(0, &cloud.bg, &[]);
pass.draw(0..6, 0..cloud.edge_count);
}
if cloud.node_count > 0 {
pass.set_pipeline(&self.cloud_node_pipeline);
pass.set_bind_group(0, &cloud.bg, &[]);
pass.draw(0..6, 0..cloud.node_count);
}
}
/// Render `scene` at `w×h` px into an LDR texture and read it back. `bloom` tunes
/// the post stack (use [`BloomParams::off`] for the A/B reference). Returns the
/// LDR frame + the instance draw counts.
pub fn render(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
w: u32,
h: u32,
scene: &GraphScene,
bloom: BloomParams,
) -> GraphRender {
self.render_src(device, queue, w, h, Src::Instances(scene), scene.background, bloom)
}
/// The CLOUD twin of [`render`](Self::render): the same offscreen + bloom chain, fed
/// by model-space points already on the GPU. Write the camera first
/// ([`write_cloud_camera`](Self::write_cloud_camera)).
#[allow(clippy::too_many_arguments)] // public signature hosts call: kept as is
pub fn render_cloud(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
w: u32,
h: u32,
cloud: &CloudBuffers,
background: [f32; 4],
bloom: BloomParams,
) -> GraphRender {
self.render_src(device, queue, w, h, Src::Cloud(cloud), background, bloom)
}
/// ONE body, two sources (LAW 5). Everything but the P1 draw is identical between
/// the instance lane and the cloud lane — offscreen, bright pass, blur, composite,
/// readback — so it is written once and the source is a parameter. A second copy of
/// this function is exactly the twin that would let the two lanes drift into
/// rendering different pictures, which is the one thing the parity test must be able
/// to trust.
#[allow(clippy::too_many_arguments)] // the private twin of `render_cloud`'s public signature
fn render_src(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
w: u32,
h: u32,
src: Src<'_>,
background: [f32; 4],
bloom: BloomParams,
) -> GraphRender {
use wgpu::util::DeviceExt;
// never a texture larger than the device can make (a panic otherwise)
let (w, h) = super::texture_extent(device, w, h);
// Uniforms.
queue.write_buffer(&self.scene_u, 0, bytemuck::bytes_of(&SceneUniforms { viewport: [w as f32, h as f32, 0.0, 0.0] }));
queue.write_buffer(
&self.post_u,
0,
bytemuck::bytes_of(&PostUniforms { params: [bloom.threshold, bloom.knee, bloom.intensity, bloom.exposure] }),
);
// Shared HDR scene offscreen (facett-core L0).
let mut offscreen = OffscreenColorDepth::new(device, self.ldr_format);
offscreen.ensure(device, w, h);
let scene_view = offscreen.color_view().expect("offscreen ensured");
// Bright-pass target + LDR present target (owned here).
let bright = device.create_texture(&wgpu::TextureDescriptor {
label: Some("graph3d_bright"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format: OFFSCREEN_FORMAT,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let bright_view = bright.create_view(&Default::default());
let ldr = device.create_texture(&wgpu::TextureDescriptor {
label: Some("graph3d_ldr"),
size: wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
mip_level_count: 1,
sample_count: crate::render::gpu::NO_MSAA_SAMPLES,
dimension: wgpu::TextureDimension::D2,
format: self.ldr_format,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC,
view_formats: &[],
});
let ldr_view = ldr.create_view(&Default::default());
self.blur.ensure(device, queue, w, h, bloom.sigma);
// Instance buffers — only the instance lane has any; the cloud lane's points are
// already resident and were uploaded once.
let scene = match src {
Src::Instances(s) => Some(s),
Src::Cloud(_) => None,
};
let node_buf = scene.is_some_and(|s| !s.nodes.is_empty()).then(|| {
let scene = scene.expect("checked");
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("graph3d_nodes"),
contents: bytemuck::cast_slice(&scene.nodes),
usage: wgpu::BufferUsages::VERTEX,
})
});
let edge_buf = scene.is_some_and(|s| !s.edges.is_empty()).then(|| {
let scene = scene.expect("checked");
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("graph3d_edges"),
contents: bytemuck::cast_slice(&scene.edges),
usage: wgpu::BufferUsages::VERTEX,
})
});
// Bind groups.
let scene_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("graph3d_scene_bg"),
layout: &self.scene_bgl,
entries: &[wgpu::BindGroupEntry { binding: 0, resource: self.scene_u.as_entire_binding() }],
});
let bright_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("graph3d_bright_bg"),
layout: &self.sample_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: self.post_u.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(scene_view) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler) },
],
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("graph3d_enc") });
// ── P1: scene pass — additive emissive nodes + edges into the HDR offscreen ──
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("graph3d_scene_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: scene_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Clear(wgpu::Color {
r: background[0] as f64,
g: background[1] as f64,
b: background[2] as f64,
a: background[3] as f64,
}),
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
// Edges first (under the nodes), then nodes — both accumulate additively, and
// the ORDER is cosmetic only: the blend is `ONE,ONE`, which commutes.
match src {
Src::Instances(sc) => {
if let Some(eb) = &edge_buf {
pass.set_pipeline(&self.edge_pipeline);
pass.set_bind_group(0, &scene_bg, &[]);
pass.set_vertex_buffer(0, eb.slice(..));
pass.draw(0..6, 0..sc.edges.len() as u32);
}
if let Some(nb) = &node_buf {
pass.set_pipeline(&self.node_pipeline);
pass.set_bind_group(0, &scene_bg, &[]);
pass.set_vertex_buffer(0, nb.slice(..));
pass.draw(0..6, 0..sc.nodes.len() as u32);
}
}
Src::Cloud(cb) => {
// No vertex buffer at all: the shader reads PTS[instance_index].
if cb.edge_count > 0 {
pass.set_pipeline(&self.cloud_edge_pipeline);
pass.set_bind_group(0, &cb.bg, &[]);
pass.draw(0..6, 0..cb.edge_count);
}
if cb.node_count > 0 {
pass.set_pipeline(&self.cloud_node_pipeline);
pass.set_bind_group(0, &cb.bg, &[]);
pass.draw(0..6, 0..cb.node_count);
}
}
}
}
// ── P2: bright pass — isolate >threshold radiance into `bright` ──
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("graph3d_bright_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &bright_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.bright_pipeline);
pass.set_bind_group(0, &bright_bg, &[]);
pass.draw(0..3, 0..1);
}
// ── P3: blur the bright pass (separable Gaussian; facett-core L0) ──
let bloom_view = self.blur.blur(device, &mut encoder, &bright_view).expect("blur ensured");
// ── P4: composite — scene + bloom·intensity → tonemap → LDR ──
let composite_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("graph3d_composite_bg"),
layout: &self.composite_bgl,
entries: &[
wgpu::BindGroupEntry { binding: 0, resource: self.post_u.as_entire_binding() },
wgpu::BindGroupEntry { binding: 1, resource: wgpu::BindingResource::TextureView(scene_view) },
wgpu::BindGroupEntry { binding: 2, resource: wgpu::BindingResource::Sampler(&self.sampler) },
wgpu::BindGroupEntry { binding: 3, resource: wgpu::BindingResource::TextureView(bloom_view) },
],
});
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("graph3d_composite_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &ldr_view,
resolve_target: None,
depth_slice: None,
ops: wgpu::Operations { load: wgpu::LoadOp::Clear(wgpu::Color::BLACK), store: wgpu::StoreOp::Store },
})],
depth_stencil_attachment: None,
timestamp_writes: None,
occlusion_query_set: None,
multiview_mask: None,
});
pass.set_pipeline(&self.composite_pipeline);
pass.set_bind_group(0, &composite_bg, &[]);
pass.draw(0..3, 0..1);
}
// Read back the LDR present target.
let bytes_per_pixel = 4u32;
let unpadded = w * bytes_per_pixel;
let align = wgpu::COPY_BYTES_PER_ROW_ALIGNMENT;
let padded = unpadded.div_ceil(align) * align;
let readback = device.create_buffer(&wgpu::BufferDescriptor {
label: Some("graph3d_readback"),
size: (padded * h) as u64,
usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ,
mapped_at_creation: false,
});
encoder.copy_texture_to_buffer(
wgpu::TexelCopyTextureInfo {
texture: &ldr,
mip_level: 0,
origin: wgpu::Origin3d::ZERO,
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyBufferInfo {
buffer: &readback,
layout: wgpu::TexelCopyBufferLayout { offset: 0, bytes_per_row: Some(padded), rows_per_image: Some(h) },
},
wgpu::Extent3d { width: w, height: h, depth_or_array_layers: 1 },
);
queue.submit(Some(encoder.finish()));
let slice = readback.slice(..);
let (tx, rx) = std::sync::mpsc::channel();
slice.map_async(wgpu::MapMode::Read, move |r| { let _ = tx.send(r); });
device.poll(wgpu::PollType::wait_indefinitely()).ok();
rx.recv().ok();
let data = slice.get_mapped_range().expect("wgpu: get_mapped_range on a mapped buffer");
let mut rgba = Vec::with_capacity((w * h * 4) as usize);
for row in 0..h {
let start = (row * padded) as usize;
rgba.extend_from_slice(&data[start..start + unpadded as usize]);
}
drop(data);
readback.unmap();
GraphRender {
width: w,
height: h,
rgba,
// The draw counts, whichever lane produced them — the render-proof asserts on
// these as DATA, so they must mean the same thing on both.
node_instances: match src {
Src::Instances(sc) => sc.nodes.len() as u32,
Src::Cloud(cb) => cb.node_count,
},
edge_instances: match src {
Src::Instances(sc) => sc.edges.len() as u32,
Src::Cloud(cb) => cb.edge_count,
},
}
}
}
/// **Why a `bytes`-long buffer cannot be made under `limits`** (`None` = it can) — the
/// pure half of [`checked_buffer`], testable without a device. A `STORAGE` buffer is
/// bound whole, so it must also fit `max_storage_buffer_binding_size`.
#[must_use]
pub fn buffer_refusal(label: &str, bytes: u64, usage: wgpu::BufferUsages, limits: &wgpu::Limits) -> Option<String> {
if bytes > limits.max_buffer_size {
return Some(format!(
"{label}: {bytes} B is over this device's max_buffer_size ({} B) — nothing drawn",
limits.max_buffer_size
));
}
if usage.contains(wgpu::BufferUsages::STORAGE) && bytes > limits.max_storage_buffer_binding_size {
return Some(format!(
"{label}: {bytes} B is over this device's max_storage_buffer_binding_size ({} B) — nothing drawn",
limits.max_storage_buffer_binding_size
));
}
None
}
/// **A buffer holding `contents`, or why not** — created WITHOUT `mappedAtCreation`
/// (the call Chrome throws from on SwiftShader, which wgpu's browser backend turns into
/// a panic) and filled with `queue.write_buffer`, which lands before the next submit's
/// commands. Checked against the device's limits first ([`buffer_refusal`]).
///
/// # Errors
/// [`buffer_refusal`]'s refusal.
pub fn checked_buffer(
device: &wgpu::Device,
queue: &wgpu::Queue,
label: &str,
contents: &[u8],
usage: wgpu::BufferUsages,
) -> Result<wgpu::Buffer, String> {
let align = wgpu::COPY_BUFFER_ALIGNMENT;
let size = (contents.len() as u64).div_ceil(align).max(1) * align;
if let Some(why) = buffer_refusal(label, size, usage, &device.limits()) {
return Err(why);
}
let buf = device.create_buffer(&wgpu::BufferDescriptor {
label: Some(label),
size,
usage: usage | wgpu::BufferUsages::COPY_DST,
mapped_at_creation: false,
});
if contents.len() as u64 == size {
queue.write_buffer(&buf, 0, contents);
} else {
let mut padded = contents.to_vec();
padded.resize(size as usize, 0);
queue.write_buffer(&buf, 0, &padded);
}
Ok(buf)
}
/// **The LIVE `egui_wgpu` lane** — the follow-up the module header promised: wire the
/// same [`GraphGpu`] instanced node/edge pipelines into an `egui_wgpu` paint callback
/// so a host (korp) draws a 30 000-node cloud as GPU **instances** instead of egui
/// tessellating 30 000 discs + edges on ONE CPU core every frame (the measured
/// single-core rotation wall — see `examples/rotate_bench.rs`). Mirrors
/// `facett_graph3d::logo3d::gpu`: a persistent `*Renderer` in `callback_resources`, installed
/// once via [`install_cloud_renderer`], driven by a [`CallbackTrait`](egui_wgpu::CallbackTrait)
/// that draws into the shared [`OffscreenColorDepth`] colour target and blits it into
/// egui's pass. Behind `with_gpu(true)` opt-in — the CPU painter (the snapshot
/// golden path) is the untouched default fallback.
pub mod cloud {
use super::{GraphGpu, GraphScene};
use egui_wgpu::wgpu;
use crate::render::gpu::offscreen::OffscreenColorDepth;
/// Persistent GPU resources for the live cloud lane: the reused [`GraphGpu`]
/// pipelines + a shared offscreen colour target (+ blit) + the per-frame instance
/// buffers (kept here so they outlive the frame's submit).
pub struct CloudRenderer {
gpu: GraphGpu,
offscreen: OffscreenColorDepth,
node_buf: Option<wgpu::Buffer>,
edge_buf: Option<wgpu::Buffer>,
node_count: u32,
edge_count: u32,
/// **The RESIDENT model** for the cloud lane — points and edge indices that
/// live on the GPU across frames. This is the whole optimisation: the instance
/// buffers above are rebuilt and re-sent every frame, these are not.
cloud: Option<super::CloudBuffers>,
}
impl CloudRenderer {
pub fn new(device: &wgpu::Device, target_format: wgpu::TextureFormat) -> Self {
Self {
gpu: GraphGpu::new(device, target_format),
offscreen: OffscreenColorDepth::new(device, target_format),
node_buf: None,
edge_buf: None,
node_count: 0,
edge_count: 0,
cloud: None,
}
}
}
/// **One live frame of the CLOUD lane** — the camera, and the model only when it
/// changed.
///
/// `model` is `Some` on the frames where the host's `generation` moved and `None`
/// on every other frame, which is nearly all of them: a spin changes the camera and
/// nothing else. So a steady-state frame carries 48 bytes of `cam` and two integers,
/// against the ~96 MB of screen-space instances [`CloudPaintCallback`] rebuilds at a
/// million nodes.
///
/// The host owns the comparison because it owns the model: keep the last
/// `generation` you uploaded, and pass `Some` exactly when it differs.
pub struct CloudModelPaintCallback {
/// The model, present only when it changed. `(points, edge index pairs)`.
pub model: Option<(Vec<super::CloudPoint>, Vec<super::CloudEdge>)>,
/// The version `model` corresponds to — stored on upload so a later frame can
/// tell whether the resident copy is still the right one.
pub generation: u64,
/// The per-frame camera. This is the only thing that moves during a spin.
pub cam: super::CloudCamera,
pub background: [f32; 4],
pub px_w: u32,
pub px_h: u32,
}
impl egui_wgpu::CallbackTrait for CloudModelPaintCallback {
fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
sd: &egui_wgpu::ScreenDescriptor,
encoder: &mut wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(r) = resources.get_mut::<CloudRenderer>() else {
return vec![];
};
let (w, h) = if self.px_w > 0 && self.px_h > 0 {
(self.px_w, self.px_h)
} else {
(sd.size_in_pixels[0], sd.size_in_pixels[1])
};
r.offscreen.ensure(device, w, h);
// UPLOAD ONLY WHEN THE MODEL MOVED. `model` is `None` on a spin frame, and a
// resident copy at the right generation is reused untouched — the entire
// point of the lane. A `Some` with an unchanged generation still re-uploads,
// because the host only sends one when it means it.
if let Some((pts, eds)) = self.model.as_ref() {
// FAIL SOFT: a device that refuses the buffers draws nothing for this
// model and says why in the state (`render::lane::gpu_error`), never a
// panic and never the previous model under the new one's name.
match r.gpu.upload_cloud(device, queue, pts, eds, self.generation) {
Ok(c) => r.cloud = Some(c),
Err(why) => {
r.cloud = None;
crate::render::lane::note_gpu_error(std::any::type_name::<CloudRenderer>(), &why);
}
}
}
let Some(cloud) = r.cloud.as_ref() else {
// No model has ever been supplied — nothing to draw, and drawing the
// instance lane's leftovers instead would be worse than an empty pane.
return vec![];
};
r.gpu.write_cloud_camera(queue, &self.cam);
let Some(color_view) = r.offscreen.color_view() else {
return vec![];
};
r.gpu.encode_cloud(encoder, color_view, cloud, self.background);
crate::render::lane::note_frame(std::any::type_name::<CloudRenderer>());
vec![]
}
fn paint(
&self,
info: egui::PaintCallbackInfo,
pass: &mut wgpu::RenderPass<'static>,
resources: &egui_wgpu::CallbackResources,
) {
let Some(r) = resources.get::<CloudRenderer>() else { return };
if !r.offscreen.ready() {
return;
}
// The same blit the instance lane uses — one offscreen, one composite path.
let _scissor = r.offscreen.blit(&info, pass);
}
}
/// One live cloud frame: the projected [`GraphScene`] + the pane size in physical px.
pub struct CloudPaintCallback {
pub scene: GraphScene,
pub px_w: u32,
pub px_h: u32,
}
impl egui_wgpu::CallbackTrait for CloudPaintCallback {
fn prepare(
&self,
device: &wgpu::Device,
queue: &wgpu::Queue,
sd: &egui_wgpu::ScreenDescriptor,
encoder: &mut wgpu::CommandEncoder,
resources: &mut egui_wgpu::CallbackResources,
) -> Vec<wgpu::CommandBuffer> {
let Some(r) = resources.get_mut::<CloudRenderer>() else {
return vec![];
};
let (w, h) = if self.px_w > 0 && self.px_h > 0 { (self.px_w, self.px_h) } else { (sd.size_in_pixels[0], sd.size_in_pixels[1]) };
r.offscreen.ensure(device, w, h);
r.gpu.write_viewport(queue, w, h);
// (Re)build the instance buffers for THIS frame — kept on the persistent
// renderer so they stay alive until egui submits the encoder.
// Fail soft, as the resident lane: a refused buffer draws nothing and is said.
let make = |label: &str, bytes: &[u8]| {
if bytes.is_empty() {
return None;
}
super::checked_buffer(device, queue, label, bytes, wgpu::BufferUsages::VERTEX)
.map_err(|why| crate::render::lane::note_gpu_error(std::any::type_name::<CloudRenderer>(), &why))
.ok()
};
r.node_buf = make("graph3d_cloud_nodes", bytemuck::cast_slice(&self.scene.nodes));
r.edge_buf = make("graph3d_cloud_edges", bytemuck::cast_slice(&self.scene.edges));
r.node_count = self.scene.nodes.len() as u32;
r.edge_count = self.scene.edges.len() as u32;
let Some(color_view) = r.offscreen.color_view() else {
return vec![];
};
// Immutable co-borrows of distinct fields (gpu / offscreen / *_buf) — the
// instanced draw the CPU no longer tessellates.
r.gpu.encode_scene(
encoder,
color_view,
r.node_buf.as_ref(),
r.node_count,
r.edge_buf.as_ref(),
r.edge_count,
self.scene.background,
);
// LANE ATTRIBUTION (`crate::render::lane`): the instanced scene is encoded
// against a real device, after every fail-safe early-return above. Counted
// so an About box can say IN USE for this lane instead of leaving the whole
// app's answer at `unknown` — korp installs THIS renderer beside the map's,
// and one silent lane is enough to make the honest answer unknowable.
crate::render::lane::note_frame(std::any::type_name::<CloudRenderer>());
vec![]
}
fn paint(
&self,
info: egui::PaintCallbackInfo,
render_pass: &mut wgpu::RenderPass<'static>,
resources: &egui_wgpu::CallbackResources,
) {
let Some(r) = resources.get::<CloudRenderer>() else { return };
if !r.offscreen.ready() {
return;
}
// Blit the instanced HDR cloud into egui's pass, scissored to the widget.
let _scissor = r.offscreen.blit(&info, render_pass);
}
}
/// Has a host installed the cloud renderer in this process?
///
/// This exists because the GPU lane is **fail-dangerous** without it:
/// `Graph3D::draw_gpu_cloud` returning `true` makes the view SKIP the CPU
/// tessellation of the whole node/edge cloud. If a host called
/// `with_gpu(true)` but never called [`install_cloud_renderer`], the paint
/// callback finds no `CloudRenderer`, draws nothing — and the CPU painter has
/// already been skipped, so the pane renders **BLANK**. That is a silent
/// black 3D graph, and neither the pixel nor the state oracle would blame the
/// missing install.
///
/// So the fast path is gated on this flag: opt in *and* install, or the CPU
/// painter draws unchanged. Process-global (the renderer lives in the one
/// `RenderState` a host builds at startup) and monotonic — installing is a
/// startup act, never undone.
///
/// **The flag is the lane registry's**, not a private copy: this lane's
/// [`crate::render::lane::installed_flag`], raised by the shared
/// `crate::render::gpu::install_renderer` like every other lane's — so
/// `lane::is_installed(type_name::<CloudRenderer>())` and this function cannot
/// disagree (it used to be a second `AtomicBool` beside the registry, LAW #5).
/// The flag reference is looked up once and cached, so the per-frame read stays
/// one atomic load.
#[must_use]
pub fn cloud_renderer_installed() -> bool {
installed_flag().load(std::sync::atomic::Ordering::Acquire)
}
/// This lane's installed flag in the [`crate::render::lane`] registry, cached.
fn installed_flag() -> &'static std::sync::atomic::AtomicBool {
static FLAG: std::sync::OnceLock<&'static std::sync::atomic::AtomicBool> =
std::sync::OnceLock::new();
FLAG.get_or_init(|| {
crate::render::lane::installed_flag(std::any::type_name::<CloudRenderer>())
})
}
/// The generation of the model the resident lane last uploaded (`0` = none). The
/// renderer keeps ONE resident model, so a pane must re-send its model whenever this
/// is not its own generation — another pane (another tab) may have replaced it. A
/// pane that only remembered its own last upload would draw the OTHER pane's cloud
/// after a tab switch (2026-10-01: `Graphs` ↔ `lineage` ↔ `Universe` at 100k).
#[must_use]
pub fn resident_generation() -> u64 {
RESIDENT_GENERATION.load(std::sync::atomic::Ordering::Relaxed)
}
/// See [`resident_generation`].
static RESIDENT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
/// Above this many nodes the 2-D panes `facett-graphpan` and `facett-graphview`'s
/// `DecoratedGraphView` paint their GPU cloud on the RESIDENT lane (a [`ResidentSlot`]);
/// at or below it they keep the per-frame instance lane ([`draw_cloud`]), which colours
/// each edge by its own colour. The same cut `facett-graphnav` makes at `SCALE_NODES`.
pub const RESIDENT_ABOVE: usize = 5_000;
static NEXT_GENERATION: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(1);
/// A model generation no other pane in this process holds — for a pane that numbers
/// its own model changes instead of hashing its model. Bit 62 set, so it cannot equal
/// the `0` "nothing uploaded" of [`resident_generation`].
#[must_use]
pub fn fresh_generation() -> u64 {
(1u64 << 62) | NEXT_GENERATION.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// **One pane's claim on the resident model** — the upload-once bookkeeping every
/// resident pane needs, written once (graphnav and the demo's Universe carry their own
/// copy of the same two lines, from before this existed).
///
/// [`draw`](Self::draw) sends the model exactly when this pane's last upload is not
/// `generation`, or when another pane replaced the ONE resident model since
/// ([`resident_generation`]); every other frame sends the 48-byte camera alone. The
/// counters say what happened, so a test (and a pane's `state_json`) can assert
/// "a camera-only frame uploaded nothing" instead of trusting it.
#[derive(Debug, Default)]
pub struct ResidentSlot {
uploaded: std::cell::Cell<u64>,
uploads: std::cell::Cell<u64>,
frames: std::cell::Cell<u64>,
}
impl ResidentSlot {
/// Would a frame at `generation` send the model?
#[must_use]
pub fn needs_upload(&self, generation: u64) -> bool {
self.uploaded.get() != generation || resident_generation() != generation
}
/// Push the resident cloud for `rect`: the camera every frame, `build()` (the
/// model) only when [`needs_upload`](Self::needs_upload). `false` — and `build`
/// never runs — when no renderer is installed or the rect has no pixels, so the
/// caller's CPU painter draws, exactly as with [`draw_cloud`].
pub fn draw(
&self,
ui: &egui::Ui,
rect: egui::Rect,
generation: u64,
build: impl FnOnce() -> (Vec<super::CloudPoint>, Vec<super::CloudEdge>),
cam: super::CloudCamera,
) -> bool {
if !cloud_renderer_installed() {
return false;
}
let ppp = ui.ctx().pixels_per_point();
if !((rect.width() * ppp).round() >= 1.0 && (rect.height() * ppp).round() >= 1.0) {
return false;
}
let upload = self.needs_upload(generation);
let drew = draw_cloud_model(ui, rect, generation, upload.then(build), cam);
if drew {
self.frames.set(self.frames.get() + 1);
if upload {
self.uploaded.set(generation);
self.uploads.set(self.uploads.get() + 1);
}
}
drew
}
/// Models this slot has sent to the GPU.
#[must_use]
pub fn uploads(&self) -> u64 {
self.uploads.get()
}
/// Frames this slot has drawn on the resident lane.
#[must_use]
pub fn frames(&self) -> u64 {
self.frames.get()
}
/// The generation this slot last uploaded (`0` = none).
#[must_use]
pub fn generation(&self) -> u64 {
self.uploaded.get()
}
}
/// **Headless tests only** (feature `cloud-test-gate`): open the installed-renderer
/// gate without a device, so a test can drive a pane's resident lane through plain
/// `egui::Context` frames and count what it sends — the paint callbacks are collected
/// into the frame's shapes and never executed. A host that called this would paint
/// BLANK panes (the trap [`cloud_renderer_installed`] exists to prevent), which is why
/// it is behind a feature that only `[dev-dependencies]` turn on.
///
/// It raises the lane's registry flag only (what [`cloud_renderer_installed`] and
/// `lane::is_installed` read) and registers no lane RECORD — nothing was installed,
/// so an About box must not say a lane is.
#[cfg(feature = "cloud-test-gate")]
pub fn assume_renderer_installed_for_tests() {
installed_flag().store(true, std::sync::atomic::Ordering::Release);
}
/// Install a [`CloudRenderer`] into an egui-wgpu `RenderState`'s callback resources
/// (call once at host startup, like `facett-map`/`logo3d`'s `install_renderer`).
/// Idempotent. Also arms [`cloud_renderer_installed`], which is what unlocks
/// `Graph3D`'s instanced fast path.
/// The install-once body is the shared `crate::render::gpu::install_renderer`
/// (LAW #5 — one writer, not a fourth hand-rolled get-or-insert), which also
/// registers the lane with `crate::render::lane` so an About box can see it.
pub fn install_cloud_renderer(render_state: &egui_wgpu::RenderState) {
// `install_renderer` (last line) raises this lane's registry flag, which is what
// `cloud_renderer_installed` reads — no second flag to set here.
// This lane counts its frames (see `CloudPaintCallback::prepare`), so a zero
// means "installed and never painted" instead of "did not say".
crate::render::lane::note_reports_frames(std::any::type_name::<CloudRenderer>());
// ── AND SAY WHY THE ZERO IS THERE. A freshly installed lane has not painted for
// exactly one reason: nobody has drawn the pane that owns it. `facett-map`'s
// map lane has said so since 2026-08-02; this one stayed silent, and silence
// is not free — `GpuStatus::every_idle_lane_is_undrawn` cannot conclude
// anything from a lane that never spoke, so ONE silent lane was enough to put
// the About box back to "installed but UNUSED … (the CPU painter is doing the
// work)" on a host where nothing had drawn a map OR a graph. MEASURED on korp
// 2026-08-03: `[(CloudRenderer, 0, None), (GpuMapRenderer, 0,
// pane_not_painted_yet)]`.
//
// `Graph3D`/`cloud_gpu_scene` overwrite this the first time they run, so the
// row only says it while it is true.
crate::render::lane::note_idle_reason(
std::any::type_name::<CloudRenderer>(),
Some(crate::render::lane::PANE_NOT_PAINTED_YET),
);
let _ = crate::render::gpu::install_renderer(render_state, CloudRenderer::new);
}
/// **The resident-model twin of [`draw_cloud`]**: push a [`CloudModelPaintCallback`]
/// for `rect` and return `true`, so the caller may skip its CPU cloud. `model` is
/// `Some` only on the frames where the host's `generation` moved; every other frame
/// sends the camera alone. The same fail-safe gates as [`draw_cloud`]: no installed
/// renderer, or a zero-pixel rect, is `false` and the CPU painter draws.
///
/// The renderer keeps ONE resident model, so one pane per `RenderState` should use
/// this lane at a time; a second would re-upload on every frame it differs.
pub fn draw_cloud_model(
ui: &egui::Ui,
rect: egui::Rect,
generation: u64,
model: Option<(Vec<super::CloudPoint>, Vec<super::CloudEdge>)>,
cam: super::CloudCamera,
) -> bool {
if !cloud_renderer_installed() {
return false;
}
let ppp = ui.ctx().pixels_per_point();
// Paint only what is on screen. A pane laid out bigger than its clip (a graph
// inside a scroll area, a canvas sized to its world) handed its WHOLE rect here,
// and the offscreen was made that size — 10 604 px wide on loki, past the
// device's 8 192 limit (a wgpu panic). The callback covers the visible part; the
// camera's centre moves by the same offset, so every point lands where it did.
let (rect, cam) = visible_part(rect, ui.clip_rect(), ppp, cam);
let px_w = (rect.width() * ppp).round();
let px_h = (rect.height() * ppp).round();
if !(px_w >= 1.0 && px_h >= 1.0) {
return false;
}
if model.is_some() {
RESIDENT_GENERATION.store(generation, std::sync::atomic::Ordering::Relaxed);
}
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
rect,
CloudModelPaintCallback {
model,
generation,
cam,
background: [0.02, 0.022, 0.03, 1.0],
px_w: px_w as u32,
px_h: px_h as u32,
},
));
true
}
/// The part of `rect` inside `clip`, and `cam` moved to it: the viewport becomes the
/// visible size, and the centre (pane-local px) shifts by the visible part's offset,
/// so a point projects to the same screen pixel. An empty intersection is an empty
/// rect (the caller draws nothing).
#[must_use]
pub fn visible_part(rect: egui::Rect, clip: egui::Rect, ppp: f32, cam: super::CloudCamera) -> (egui::Rect, super::CloudCamera) {
let vis = rect.intersect(clip);
if !(vis.width() > 0.0 && vis.height() > 0.0) {
return (egui::Rect::NOTHING, cam);
}
let shift = (vis.min - rect.min) * ppp;
let mut cam = cam;
cam.viewport = [vis.width() * ppp, vis.height() * ppp, cam.viewport[2] - shift.x, cam.viewport[3] - shift.y];
(vis, cam)
}
/// **THE one GPU-cloud entry point every graph pane calls.** Returns `true` if the
/// instanced cloud was pushed for `rect` — and therefore, and only therefore, the
/// caller may skip its CPU tessellation of the node/edge cloud.
///
/// It performs, in one place, the whole fail-safe protocol that must not be
/// re-derived per pane:
///
/// 1. `use_gpu` off ⇒ `false`. The CPU painter runs; goldens are untouched.
/// 2. **Renderer not installed ⇒ `false`.** This is the trap. Opting in is not
/// enough: without [`install_cloud_renderer`] the paint callback finds no
/// `CloudRenderer` and draws *nothing*, while the caller has already skipped the
/// CPU painter — a silently **BLANK** pane. That exact shape shipped once in
/// `Graph3D` (proven by tests, installed by no host) and was the whole "the 3D
/// graphs are missing" symptom. Falling back here makes it structurally
/// impossible for a *new* pane to reintroduce it: the pane cannot skip its CPU
/// painter without going through this function, and this function will not say
/// yes until a renderer really exists.
/// 3. A degenerate rect (zero px) ⇒ `false`.
///
/// `build` is only invoked once every gate has passed — so building the instance
/// scene costs nothing on the fallback path. It receives the pane size in
/// **physical px** (`rect` × `pixels_per_point`), which is the space
/// [`NodeInstance::center`] / [`EdgeInstance::a`] live in.
pub fn draw_cloud(
ui: &egui::Ui,
rect: egui::Rect,
use_gpu: bool,
build: impl FnOnce(f32, f32) -> GraphScene,
) -> bool {
if !use_gpu || !cloud_renderer_installed() {
return false;
}
let ppp = ui.ctx().pixels_per_point();
let px_w = (rect.width() * ppp).round();
let px_h = (rect.height() * ppp).round();
if !(px_w >= 1.0 && px_h >= 1.0) {
return false;
}
let scene = build(px_w, px_h);
if scene.nodes.is_empty() && scene.edges.is_empty() {
return false;
}
ui.painter().add(egui_wgpu::Callback::new_paint_callback(
rect,
CloudPaintCallback { scene, px_w: px_w as u32, px_h: px_h as u32 },
));
true
}
}
/// **The shared 2-D lowering** — turn already-projected screen geometry into a
/// [`GraphScene`] of emissive instances. Every 2-D graph pane
/// (`facett-graphpan` / `facett-graphview` / `facett-graphnav`) lowers through this,
/// so the look, the sRGB→linear conversion and the emissive scaling are defined once
/// (L5) rather than re-derived per crate with three slightly different gammas.
///
/// `emissive` scales peak linear radiance; `≈2.5` reads bright over a dark pane with a
/// ~1.0 bright-pass threshold, `1.0` is calm. Edges are emitted first so they sit under
/// the nodes, matching the CPU painters' order.
#[derive(Clone, Copy, Debug)]
pub struct CloudBuilder {
/// Peak emissive multiplier for node instances.
pub emissive: f32,
/// Edge radiance as a fraction of the node peak (crossings still accumulate).
pub edge_gain: f32,
/// Clear colour behind the cloud (linear rgba).
pub background: [f32; 4],
}
impl Default for CloudBuilder {
fn default() -> Self {
Self { emissive: 2.5, edge_gain: 0.6, background: [0.02, 0.022, 0.03, 1.0] }
}
}
impl CloudBuilder {
/// Linear radiance for one `Color32` at `mul × emissive`. sRGB is approximated by a
/// square — the same curve `Graph3D::gpu_scene_at` has always used, kept identical
/// so the 2-D and 3-D clouds cannot drift apart visually.
#[must_use]
pub fn radiance(&self, c: egui::Color32, mul: f32) -> [f32; 4] {
let lin = |b: u8| {
let s = b as f32 / 255.0;
s * s
};
let k = self.emissive * mul;
[lin(c.r()) * k, lin(c.g()) * k, lin(c.b()) * k, 1.0]
}
/// Lower `nodes` (`centre px`, `radius px`, colour) and `edges` (`a px`, `b px`,
/// `half-width px`, colour) into an instanced [`GraphScene`]. Pure — no device, no
/// egui painting — so a headless test can assert the instance counts and positions
/// without a GPU.
#[must_use]
pub fn scene(
&self,
nodes: impl IntoIterator<Item = (egui::Pos2, f32, egui::Color32)>,
edges: impl IntoIterator<Item = (egui::Pos2, egui::Pos2, f32, egui::Color32)>,
) -> GraphScene {
let edges = edges
.into_iter()
.map(|(a, b, w, c)| EdgeInstance {
a: [a.x, a.y],
b: [b.x, b.y],
width: w.max(0.5),
_pad: 0.0,
color: self.radiance(c, self.edge_gain),
})
.collect();
let nodes = nodes
.into_iter()
.map(|(p, r, c)| NodeInstance {
center: [p.x, p.y],
radius: r.max(0.5),
_pad: 0.0,
color: self.radiance(c, 1.0),
})
.collect();
GraphScene { background: self.background, nodes, edges }
}
}
fn uniform_entry(binding: u32, vis: wgpu::ShaderStages) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: vis,
ty: wgpu::BindingType::Buffer { ty: wgpu::BufferBindingType::Uniform, has_dynamic_offset: false, min_binding_size: None },
count: None,
}
}
fn tex_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
}
}
fn sampler_entry(binding: u32) -> wgpu::BindGroupLayoutEntry {
wgpu::BindGroupLayoutEntry {
binding,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
}
}
/// **Headless render-proof entry point.** Spin up an adapter/device, render `scene`
/// at `w×h` through the full instanced + bloom lane, and read back the LDR frame +
/// the instance draw counts. `None` when no wgpu adapter is available (CPU-only CI),
/// so the caller degrades to a skip rather than a false failure.
#[must_use]
pub fn render_graph_offscreen(w: u32, h: u32, scene: &GraphScene, bloom: BloomParams) -> Option<GraphRender> {
pollster::block_on(render_graph_offscreen_async(w, h, scene, bloom))
}
async fn render_graph_offscreen_async(w: u32, h: u32, scene: &GraphScene, bloom: BloomParams) -> Option<GraphRender> {
// THE GPU TURN + the adapter policy, via the one bring-up writer
// (`crate::render::gputurn::probe`). This is the device probe `facett-graph3d`,
// `facett-helix` and `facett-graphview` all render through, so the turn taken here
// serialises those crates' pixel tests too. `OnSoftware::Skip`, not `Refuse`: the
// signature is already `Option` and every caller reads `None` as "no GPU, skip".
let probe = crate::render::gputurn::probe::open(
"graph3d-gpu-offscreen",
crate::render::gputurn::probe::OnSoftware::Skip,
crate::render::gputurn::probe::downlevel,
)?;
let (device, queue) = (&probe.device, &probe.queue);
let mut gpu = GraphGpu::new(device, wgpu::TextureFormat::Rgba8Unorm);
Some(gpu.render(device, queue, w, h, scene, bloom))
}
/// **The CLOUD lane, headless** — upload a model-space cloud, write one camera, render.
/// The A/B partner of [`render_graph_offscreen`]: give both the same graph and they must
/// produce the same picture, which is what `tests/cloud_parity.rs` asserts.
#[must_use]
pub fn render_cloud_offscreen(
w: u32,
h: u32,
points: &[CloudPoint],
edges: &[CloudEdge],
cam: &CloudCamera,
background: [f32; 4],
bloom: BloomParams,
) -> Option<GraphRender> {
pollster::block_on(render_cloud_offscreen_async(w, h, points, edges, cam, background, bloom))
}
async fn render_cloud_offscreen_async(
w: u32,
h: u32,
points: &[CloudPoint],
edges: &[CloudEdge],
cam: &CloudCamera,
background: [f32; 4],
bloom: BloomParams,
) -> Option<GraphRender> {
let probe = crate::render::gputurn::probe::open(
"graph3d-cloud-offscreen",
crate::render::gputurn::probe::OnSoftware::Skip,
crate::render::gputurn::probe::downlevel,
)?;
let (device, queue) = (&probe.device, &probe.queue);
let mut gpu = GraphGpu::new(device, wgpu::TextureFormat::Rgba8Unorm);
let cloud = gpu.upload_cloud(device, queue, points, edges, 0).ok()?;
gpu.write_cloud_camera(queue, cam);
Some(gpu.render_cloud(device, queue, w, h, &cloud, background, bloom))
}
#[cfg(test)]
mod buffer_refusal_tests {
use super::buffer_refusal;
use egui_wgpu::wgpu;
/// The resident cloud at 100k nodes is a 1.6 MB points buffer (lane L's SwiftShader
/// panic). Over a device's limits it is REFUSED with the reason — `upload_cloud`
/// returns it, the paint callback draws nothing and records it — never a panic.
#[test]
fn a_buffer_over_the_device_limits_is_refused_with_the_reason() {
let st = wgpu::BufferUsages::STORAGE;
let mut small = wgpu::Limits::downlevel_defaults();
small.max_buffer_size = 1 << 20;
small.max_storage_buffer_binding_size = 1 << 19;
let why = buffer_refusal("graph3d_cloud_pts", 1_600_000, st, &small).expect("over max_buffer_size");
assert!(why.contains("max_buffer_size") && why.contains("1600000"), "{why}");
let why = buffer_refusal("graph3d_cloud_pts", 600_000, st, &small).expect("over the storage binding");
assert!(why.contains("max_storage_buffer_binding_size"), "{why}");
assert_eq!(buffer_refusal("graph3d_cloud_nodes", 600_000, wgpu::BufferUsages::VERTEX, &small), None);
assert_eq!(buffer_refusal("graph3d_cloud_pts", 1_600_000, st, &wgpu::Limits::default()), None, "fits a default device");
}
}