forked from 1b-agent/opencadstudio-upstream
The crash in #358 was not the out-of-memory itself. wgpu reports OOM as recoverable and the app already installs a handler that logs and keeps the session alive. What killed the process was what happened next: create_buffer_init maps the new buffer unconditionally, and mapping the invalid buffer an OOM device returns panics. That panic unwinds through iced's main-thread redraw, trips the wgpu-hal swapchain assertion in a destructor, and becomes a non-unwinding abort -- no chance to autosave. Every allocation whose size scales with the drawing now goes through src/scene/pipeline/gpu_upload.rs, which creates the buffer unmapped and fills it with Queue::write_buffer. A failed allocation then surfaces as a validation error on the write, which reaches install_gpu_error_handler and degrades the frame -- exactly what that handler's comment promised: "A bad frame must degrade, not end the session." Converted: block-wire vertices and instances, the wire const storage buffer, all three wire-arena allocators, face3d vertices and instances, block-text instances and text vertices, both hatch backends, and the silhouette buffers. mesh_gpu already allocated this way, which is why the mesh path never produced this panic; the rest now match it. No mapped_at_creation:true remains in the renderer. The create_buffer_init calls left are fixed-size or bounded-small -- uniforms, the viewcube's static geometry, per-image and per-wipeout quads -- where an allocation failure is not a realistic outcome. Two trade-offs worth stating: * write_buffer stages through a copy, which mapping at creation avoided. Chunking bounds that transient cost, and the measured block-wire upload got faster with chunking, not slower. * `queue` had to be threaded through the wire, face3d, text and hatch builders and their callers. Mechanical, but it widens the diff. This touches every upload path, so it needs a visual check against a real drawing before it goes upstream. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
422 lines
16 KiB
Rust
422 lines
16 KiB
Rust
// Texture-backed hatch renderer for devices without storage buffers. Boundary
|
|
// geometry comes from the same kernel mesh as the storage backend.
|
|
|
|
use crate::scene::model::hatch_model::{HatchModel, HatchPattern};
|
|
use iced::wgpu;
|
|
use iced::wgpu::util::DeviceExt;
|
|
|
|
/// Width (in texels) of the RGBA32F data texture. Height grows to fit; a hatch
|
|
/// with N total texels uses ceil(N / WIDTH) rows.
|
|
const DATA_TEX_WIDTH: u32 = 1024;
|
|
|
|
// ── Vertex ────────────────────────────────────────────────────────────────
|
|
|
|
#[repr(C)]
|
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
|
struct HatchVertex {
|
|
pos: [f32; 3],
|
|
_pad: f32,
|
|
}
|
|
|
|
pub(super) fn vertex_layout<'a>() -> wgpu::VertexBufferLayout<'a> {
|
|
wgpu::VertexBufferLayout {
|
|
array_stride: std::mem::size_of::<HatchVertex>() as u64,
|
|
step_mode: wgpu::VertexStepMode::Vertex,
|
|
attributes: &[wgpu::VertexAttribute {
|
|
offset: 0,
|
|
shader_location: 0,
|
|
format: wgpu::VertexFormat::Float32x3,
|
|
}],
|
|
}
|
|
}
|
|
|
|
#[repr(C)]
|
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
|
pub(super) struct TextureHatchPlacement {
|
|
translation: [f32; 2],
|
|
translation_low: [f32; 2],
|
|
draw_depth: f32,
|
|
_pad: [f32; 3],
|
|
}
|
|
|
|
impl TextureHatchPlacement {
|
|
pub(super) fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
|
|
wgpu::VertexBufferLayout {
|
|
array_stride: std::mem::size_of::<Self>() as u64,
|
|
step_mode: wgpu::VertexStepMode::Instance,
|
|
attributes: &[
|
|
wgpu::VertexAttribute { offset: 0, shader_location: 1, format: wgpu::VertexFormat::Float32x2 },
|
|
wgpu::VertexAttribute { offset: 8, shader_location: 2, format: wgpu::VertexFormat::Float32x2 },
|
|
wgpu::VertexAttribute { offset: 16, shader_location: 3, format: wgpu::VertexFormat::Float32 },
|
|
],
|
|
}
|
|
}
|
|
}
|
|
|
|
// ── Per-hatch uniform (binding 0) — 96 bytes, matches HatchUniforms in
|
|
// hatch_texture.wgsl. ──────────────────────────────────────────────────────
|
|
|
|
#[repr(C)]
|
|
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
|
|
struct TextureHatchUniform {
|
|
color: [f32; 4], // 0
|
|
color2: [f32; 4], // 16
|
|
mode: u32, // 32
|
|
_reserved: u32, // 36
|
|
angle_offset: f32, // 40
|
|
scale: f32, // 44
|
|
grad_cos: f32, // 48
|
|
grad_sin: f32, // 52
|
|
grad_min: f32, // 56
|
|
grad_range: f32, // 60
|
|
origin: [f32; 2], // 64
|
|
origin_low: [f32; 2], // 72
|
|
n_families: u32, // 80
|
|
fam_off: u32, // 84: texel offset of the family section
|
|
dash_off: u32, // 88: texel offset of the dash section
|
|
tex_width: u32, // 92
|
|
}
|
|
|
|
// ── Per-hatch GPU handle ────────────────────────────────────────────────────
|
|
|
|
pub(super) struct TextureHatch {
|
|
pub(super) vertex_buffer: wgpu::Buffer,
|
|
pub(super) index_buffer: wgpu::Buffer,
|
|
pub(super) placement_buffer: wgpu::Buffer,
|
|
pub(super) index_count: u32,
|
|
pub(super) instance_count: u32,
|
|
pub(super) bind_group: wgpu::BindGroup,
|
|
/// Reserved for per-frame AABB LOD (mirrors `WipeoutGpu`); not yet wired
|
|
/// into the web hatch draw loop — the native batched path doesn't do
|
|
/// per-hatch LOD either.
|
|
#[allow(dead_code)]
|
|
pub world_aabb: [f32; 4],
|
|
_uniform_buf: wgpu::Buffer,
|
|
_data_tex: wgpu::Texture,
|
|
}
|
|
|
|
impl TextureHatch {
|
|
pub(super) fn from_models(
|
|
device: &wgpu::Device,
|
|
queue: &wgpu::Queue,
|
|
models: &[HatchModel],
|
|
bgl1: &wgpu::BindGroupLayout,
|
|
) -> Vec<Self> {
|
|
let mut slots = rustc_hash::FxHashMap::default();
|
|
let mut groups: Vec<Vec<&HatchModel>> = Vec::new();
|
|
for (index, model) in models.iter().enumerate() {
|
|
let key = model
|
|
.render_instance
|
|
.map(|instance| (true, instance.source_id))
|
|
.unwrap_or((false, index as u64));
|
|
let slot = *slots.entry(key).or_insert_with(|| {
|
|
let slot = groups.len();
|
|
groups.push(Vec::new());
|
|
slot
|
|
});
|
|
groups[slot].push(model);
|
|
}
|
|
groups
|
|
.into_iter()
|
|
.filter_map(|group| Self::new(device, queue, &group, bgl1))
|
|
.collect()
|
|
}
|
|
|
|
/// Group-1 layout: uniform header (binding 0) + non-filterable float data
|
|
/// texture (binding 1).
|
|
pub(super) fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout {
|
|
device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
|
|
label: Some("hatch.texture.bgl1"),
|
|
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: None,
|
|
},
|
|
count: None,
|
|
},
|
|
wgpu::BindGroupLayoutEntry {
|
|
binding: 1,
|
|
visibility: wgpu::ShaderStages::FRAGMENT,
|
|
ty: wgpu::BindingType::Texture {
|
|
sample_type: wgpu::TextureSampleType::Float { filterable: false },
|
|
view_dimension: wgpu::TextureViewDimension::D2,
|
|
multisampled: false,
|
|
},
|
|
count: None,
|
|
},
|
|
],
|
|
})
|
|
}
|
|
|
|
pub(super) fn new(
|
|
device: &wgpu::Device,
|
|
queue: &wgpu::Queue,
|
|
models: &[&HatchModel],
|
|
bgl1: &wgpu::BindGroupLayout,
|
|
) -> Option<Self> {
|
|
let model = models[0];
|
|
// ── Decode pattern mode (mirrors WipeoutGpu::new) ─────────────────
|
|
// The gradient shape (kind + invert bit) rides in the mode's high
|
|
// bits — the 96-byte uniform has no spare slot.
|
|
let (mode, color2, grad_cos, grad_sin) = match &model.pattern {
|
|
HatchPattern::Solid => (1u32, [0.0f32; 4], 0.0f32, 0.0f32),
|
|
HatchPattern::Pattern(_) => (0u32, [0.0f32; 4], 0.0f32, 0.0f32),
|
|
HatchPattern::Gradient { angle_deg, color2, kind, invert, .. } => {
|
|
let gk = (kind.shader_kind() | if *invert { 16 } else { 0 }) << 8;
|
|
if kind.radial() {
|
|
// Radial: centre is the local origin; grad_cos/sin unused.
|
|
(3u32 | gk, *color2, 0.0, 0.0)
|
|
} else {
|
|
let r = angle_deg.to_radians();
|
|
(2u32 | gk, *color2, r.cos(), r.sin())
|
|
}
|
|
}
|
|
};
|
|
|
|
// ── Bounding box ─────────────────────────────────────────────────
|
|
let (mut min_x, mut max_x, mut min_y, mut max_y) =
|
|
(f32::INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::NEG_INFINITY);
|
|
for &[x, y] in model.boundary.iter() {
|
|
if !x.is_finite() || !y.is_finite() {
|
|
continue;
|
|
}
|
|
min_x = min_x.min(x);
|
|
max_x = max_x.max(x);
|
|
min_y = min_y.min(y);
|
|
max_y = max_y.max(y);
|
|
}
|
|
|
|
let origin = model.world_origin;
|
|
let (mesh_points, indices) = model.fill_mesh();
|
|
if mesh_points.is_empty() || indices.is_empty() {
|
|
return None;
|
|
}
|
|
let vertices: Vec<HatchVertex> = mesh_points
|
|
.into_iter()
|
|
.map(|[x, y]| HatchVertex { pos: [x, y, 0.0], _pad: 0.0 })
|
|
.collect();
|
|
let vertex_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
|
|
device,
|
|
queue,
|
|
"hatch.texture.vbuf",
|
|
&vertices,
|
|
wgpu::BufferUsages::VERTEX,
|
|
);
|
|
let index_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
|
|
device,
|
|
queue,
|
|
"hatch.texture.ibuf",
|
|
&indices,
|
|
wgpu::BufferUsages::INDEX,
|
|
);
|
|
let base = model
|
|
.render_instance
|
|
.map_or([0.0; 3], |instance| instance.translation);
|
|
let placements: Vec<TextureHatchPlacement> = models
|
|
.iter()
|
|
.map(|model| {
|
|
let translation = model
|
|
.render_instance
|
|
.map_or([0.0; 3], |instance| instance.translation);
|
|
let delta = [translation[0] - base[0], translation[1] - base[1]];
|
|
let high = [delta[0] as f32, delta[1] as f32];
|
|
TextureHatchPlacement {
|
|
translation: high,
|
|
translation_low: [
|
|
(delta[0] - high[0] as f64) as f32,
|
|
(delta[1] - high[1] as f64) as f32,
|
|
],
|
|
draw_depth: model.draw_depth,
|
|
_pad: [0.0; 3],
|
|
}
|
|
})
|
|
.collect();
|
|
let placement_buffer = crate::scene::pipeline::gpu_upload::upload_buffer(
|
|
device,
|
|
queue,
|
|
"hatch.texture.placements",
|
|
&placements,
|
|
wgpu::BufferUsages::VERTEX,
|
|
);
|
|
|
|
// ── Gradient projection range (snapped-local space) ───────────────
|
|
let base_mode = mode & 0xFF;
|
|
let frame = match &model.pattern {
|
|
HatchPattern::Gradient {
|
|
angle_deg, shift, ..
|
|
} => model.gradient_frame(*angle_deg, *shift),
|
|
_ => None,
|
|
};
|
|
let (grad_min, grad_range, radial_center) = if base_mode == 2 {
|
|
frame.map_or((0.0, 1.0, [0.0, 0.0]), |frame| {
|
|
(
|
|
frame.projection_min as f32,
|
|
frame.projection_span as f32,
|
|
[0.0, 0.0],
|
|
)
|
|
})
|
|
} else if base_mode == 3 {
|
|
frame.map_or((0.0, 1.0, [0.0, 0.0]), |frame| {
|
|
(
|
|
0.0,
|
|
frame.radius as f32,
|
|
[frame.center[0] as f32, frame.center[1] as f32],
|
|
)
|
|
})
|
|
} else {
|
|
(0.0, 1.0, [0.0, 0.0])
|
|
};
|
|
|
|
// ── Pack the data texture: families | dashes ─────────────────────
|
|
let mut texels: Vec<[f32; 4]> = Vec::new();
|
|
// Family section (3 texels each) + a flat dash pool.
|
|
let fam_off = texels.len() as u32;
|
|
let mut n_families = 0u32;
|
|
let mut dash_pool: Vec<f32> = Vec::new();
|
|
if let HatchPattern::Pattern(families) = &model.pattern {
|
|
for fam in families.iter() {
|
|
let dash_rel = dash_pool.len() as u32;
|
|
dash_pool.extend_from_slice(&fam.dashes);
|
|
let n_dashes = fam.dashes.len() as u32;
|
|
let period: f32 = if n_dashes > 0 {
|
|
fam.dashes.iter().map(|d| d.abs()).sum()
|
|
} else {
|
|
0.0
|
|
};
|
|
let angle_r = fam.angle_deg.to_radians();
|
|
// PAT local frame: perpendicular spacing and along-line phase.
|
|
texels.push([angle_r.cos(), angle_r.sin(), fam.x0, fam.y0]);
|
|
texels.push([fam.dx, fam.dy, fam.dy, fam.dx]);
|
|
// Counts as exact f32 (small integers → no denormal/bitcast risk).
|
|
texels.push([
|
|
model.line_weight_px.max(1.0),
|
|
period,
|
|
n_dashes as f32,
|
|
dash_rel as f32,
|
|
]);
|
|
n_families += 1;
|
|
}
|
|
}
|
|
|
|
// Dash section (4 values per texel).
|
|
let dash_off = texels.len() as u32;
|
|
let mut i = 0usize;
|
|
while i < dash_pool.len() {
|
|
let mut t = [0.0f32; 4];
|
|
for (c, slot) in t.iter_mut().enumerate() {
|
|
if let Some(&d) = dash_pool.get(i + c) {
|
|
*slot = d;
|
|
}
|
|
}
|
|
texels.push(t);
|
|
i += 4;
|
|
}
|
|
|
|
// Upload the texture (min 1 texel; pad the last row).
|
|
if texels.is_empty() {
|
|
texels.push([0.0; 4]);
|
|
}
|
|
let width = DATA_TEX_WIDTH;
|
|
let height = ((texels.len() as u32).div_ceil(width)).max(1);
|
|
texels.resize((width * height) as usize, [0.0; 4]);
|
|
let data_tex = device.create_texture(&wgpu::TextureDescriptor {
|
|
label: Some("hatch.texture.data_tex"),
|
|
size: wgpu::Extent3d {
|
|
width,
|
|
height,
|
|
depth_or_array_layers: 1,
|
|
},
|
|
mip_level_count: 1,
|
|
sample_count: 1,
|
|
dimension: wgpu::TextureDimension::D2,
|
|
format: wgpu::TextureFormat::Rgba32Float,
|
|
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
|
|
view_formats: &[],
|
|
});
|
|
queue.write_texture(
|
|
data_tex.as_image_copy(),
|
|
bytemuck::cast_slice(&texels),
|
|
wgpu::TexelCopyBufferLayout {
|
|
offset: 0,
|
|
bytes_per_row: Some(width * 16),
|
|
rows_per_image: Some(height),
|
|
},
|
|
wgpu::Extent3d {
|
|
width,
|
|
height,
|
|
depth_or_array_layers: 1,
|
|
},
|
|
);
|
|
let tex_view = data_tex.create_view(&wgpu::TextureViewDescriptor::default());
|
|
|
|
// ── Uniform header ────────────────────────────────────────────────
|
|
let uniform_data = TextureHatchUniform {
|
|
color: model.color,
|
|
color2,
|
|
mode,
|
|
_reserved: 0,
|
|
angle_offset: model.angle_offset,
|
|
// Clamp like the desktop renderer so scale==0 can't make perp_step 0
|
|
// → round(perp/0)=NaN → an invisible hatch.
|
|
scale: model.scale.max(1e-6),
|
|
grad_cos: if base_mode == 3 { radial_center[0] } else { grad_cos },
|
|
grad_sin: if base_mode == 3 { radial_center[1] } else { grad_sin },
|
|
grad_min,
|
|
grad_range,
|
|
origin: [origin[0] as f32, origin[1] as f32],
|
|
origin_low: [
|
|
(origin[0] - origin[0] as f32 as f64) as f32,
|
|
(origin[1] - origin[1] as f32 as f64) as f32,
|
|
],
|
|
n_families,
|
|
fam_off,
|
|
dash_off,
|
|
tex_width: width,
|
|
};
|
|
let _uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
|
|
label: Some("hatch.texture.uniform"),
|
|
contents: bytemuck::bytes_of(&uniform_data),
|
|
usage: wgpu::BufferUsages::UNIFORM,
|
|
});
|
|
|
|
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
|
|
label: Some("hatch.texture.bind_group1"),
|
|
layout: bgl1,
|
|
entries: &[
|
|
wgpu::BindGroupEntry {
|
|
binding: 0,
|
|
resource: _uniform_buf.as_entire_binding(),
|
|
},
|
|
wgpu::BindGroupEntry {
|
|
binding: 1,
|
|
resource: wgpu::BindingResource::TextureView(&tex_view),
|
|
},
|
|
],
|
|
});
|
|
|
|
let ox = model.world_origin[0] as f32;
|
|
let oy = model.world_origin[1] as f32;
|
|
let world_aabb = if min_x.is_finite() && min_y.is_finite() {
|
|
[min_x + ox, min_y + oy, max_x + ox, max_y + oy]
|
|
} else {
|
|
[min_x, min_y, max_x, max_y]
|
|
};
|
|
|
|
Some(Self {
|
|
vertex_buffer,
|
|
index_buffer,
|
|
placement_buffer,
|
|
index_count: indices.len() as u32,
|
|
instance_count: placements.len() as u32,
|
|
bind_group,
|
|
world_aabb,
|
|
_uniform_buf,
|
|
_data_tex: data_tex,
|
|
})
|
|
}
|
|
}
|