feat: raster image render pipeline (IMAGE command + GPU textured quad)

- Add IMAGE / IMAGEATTACH / IM command with async file-picker (rfd)
- src/scene/image_model.rs: ImageModel with RGBA pixel data + quad corners
- src/scene/pipeline/image_gpu.rs: ImageGpu with wgpu texture, sampler, opacity uniform
- src/scene/pipeline/mod.rs: image render pipeline (pass 2, between hatches and meshes)
- src/scene/render.rs: include images in Primitive, call upload_images in prepare
- src/scene/mod.rs: images HashMap, populate_images_from_document, cache on add_entity
- src/modules/home/draw/raster_image.rs: ImageCommand (two-click: origin + width, aspect-correct preview)
- src/io/mod.rs: pick_image_file() async helper (file dialog + dimension decode)
- app: ImagePick / ImagePickResult messages, populate on file open and undo/redo

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Hakan Seven 2026-04-06 23:19:08 +03:00
commit 4312b6c3a5
39 changed files with 702 additions and 15 deletions

View file

@ -0,0 +1,169 @@
// Image GPU buffers — renders raster images as textured quads.
//
// Group 1 bindings per image:
// binding 0 — texture_2d<f32> (RGBA image texture)
// binding 1 — sampler (bilinear filtering)
// binding 2 — ImageParams (opacity uniform, 16 bytes)
use crate::scene::image_model::ImageModel;
use iced::wgpu;
use iced::wgpu::util::DeviceExt;
// ── Vertex ────────────────────────────────────────────────────────────────
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
pub struct ImageVertex {
pub pos: [f32; 3],
pub uv: [f32; 2],
}
impl ImageVertex {
pub fn layout<'a>() -> wgpu::VertexBufferLayout<'a> {
wgpu::VertexBufferLayout {
array_stride: std::mem::size_of::<ImageVertex>() as u64,
step_mode: wgpu::VertexStepMode::Vertex,
attributes: &[
wgpu::VertexAttribute {
offset: 0,
shader_location: 0,
format: wgpu::VertexFormat::Float32x3,
},
wgpu::VertexAttribute {
offset: 12,
shader_location: 1,
format: wgpu::VertexFormat::Float32x2,
},
],
}
}
}
// ── Uniform ───────────────────────────────────────────────────────────────
#[repr(C)]
#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)]
struct ImageParams {
opacity: f32,
_pad: [f32; 3],
} // 16 bytes
// ── Per-image GPU handle ──────────────────────────────────────────────────
pub struct ImageGpu {
pub vertex_buffer: wgpu::Buffer,
pub bind_group: wgpu::BindGroup,
_texture: wgpu::Texture,
_sampler: wgpu::Sampler,
_params_buf: wgpu::Buffer,
}
impl ImageGpu {
pub fn new(
device: &wgpu::Device,
queue: &wgpu::Queue,
model: &ImageModel,
bgl1: &wgpu::BindGroupLayout,
) -> Option<Self> {
if model.pixels.is_empty() || model.width == 0 || model.height == 0 {
return None;
}
// ── Upload texture ────────────────────────────────────────────────
let texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("image.texture"),
size: wgpu::Extent3d {
width: model.width,
height: model.height,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8UnormSrgb,
usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST,
view_formats: &[],
});
queue.write_texture(
texture.as_image_copy(),
&model.pixels,
wgpu::TexelCopyBufferLayout {
offset: 0,
bytes_per_row: Some(4 * model.width),
rows_per_image: Some(model.height),
},
wgpu::Extent3d {
width: model.width,
height: model.height,
depth_or_array_layers: 1,
},
);
let tex_view = texture.create_view(&wgpu::TextureViewDescriptor::default());
// ── Sampler ───────────────────────────────────────────────────────
let _sampler = device.create_sampler(&wgpu::SamplerDescriptor {
label: Some("image.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()
});
// ── Opacity uniform ───────────────────────────────────────────────
let params = ImageParams { opacity: model.opacity.clamp(0.0, 1.0), _pad: [0.0; 3] };
let _params_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("image.params"),
contents: bytemuck::bytes_of(&params),
usage: wgpu::BufferUsages::UNIFORM,
});
// ── Bind group ────────────────────────────────────────────────────
let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("image.bind_group1"),
layout: bgl1,
entries: &[
wgpu::BindGroupEntry {
binding: 0,
resource: wgpu::BindingResource::TextureView(&tex_view),
},
wgpu::BindGroupEntry {
binding: 1,
resource: wgpu::BindingResource::Sampler(&_sampler),
},
wgpu::BindGroupEntry {
binding: 2,
resource: _params_buf.as_entire_binding(),
},
],
});
// ── Vertex buffer — two triangles ─────────────────────────────────
// corners: [p0=BL, p1=BR, p2=TR, p3=TL]
// UV: BL=(0,1), BR=(1,1), TR=(1,0), TL=(0,0)
let [p0, p1, p2, p3] = model.corners;
let verts = [
ImageVertex { pos: p0, uv: [0.0, 1.0] },
ImageVertex { pos: p1, uv: [1.0, 1.0] },
ImageVertex { pos: p2, uv: [1.0, 0.0] },
ImageVertex { pos: p0, uv: [0.0, 1.0] },
ImageVertex { pos: p2, uv: [1.0, 0.0] },
ImageVertex { pos: p3, uv: [0.0, 0.0] },
];
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("image.vbuf"),
contents: bytemuck::cast_slice(&verts),
usage: wgpu::BufferUsages::VERTEX,
});
Some(Self {
vertex_buffer,
bind_group,
_texture: texture,
_sampler,
_params_buf,
})
}
}

View file

@ -1,4 +1,5 @@
pub mod hatch_gpu;
pub mod image_gpu;
pub mod mesh_gpu;
pub mod uniforms;
pub mod viewcube;
@ -8,26 +9,31 @@ use iced::wgpu;
use iced::{Rectangle, Size};
pub use hatch_gpu::HatchGpu;
pub use image_gpu::ImageGpu;
pub use mesh_gpu::MeshGpu;
pub use uniforms::Uniforms;
pub use viewcube::ViewCubePipeline;
pub use wire_gpu::WireGpu;
use crate::scene::hatch_model::HatchModel;
use crate::scene::image_model::ImageModel;
use crate::scene::mesh_model::MeshModel;
use crate::scene::wire_model::WireModel;
pub struct Pipeline {
wire_pipeline: wgpu::RenderPipeline,
hatch_pipeline: wgpu::RenderPipeline,
image_pipeline: wgpu::RenderPipeline,
mesh_pipeline: wgpu::RenderPipeline,
uniform_buffer: wgpu::Buffer,
uniform_bind_group: wgpu::BindGroup,
hatch_bgl1: wgpu::BindGroupLayout,
image_bgl1: wgpu::BindGroupLayout,
depth_texture_size: Size<u32>,
depth_view: wgpu::TextureView,
gpu_wires: Vec<WireGpu>,
gpu_hatches: Vec<HatchGpu>,
gpu_images: Vec<ImageGpu>,
gpu_meshes: Vec<MeshGpu>,
pub viewcube: ViewCubePipeline,
}
@ -246,19 +252,111 @@ impl Pipeline {
cache: None,
});
// ── Image pipeline ─────────────────────────────────────────────────
let image_bgl1 = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
label: Some("image.bgl1"),
entries: &[
// binding 0: texture
wgpu::BindGroupLayoutEntry {
binding: 0,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Texture {
sample_type: wgpu::TextureSampleType::Float { filterable: true },
view_dimension: wgpu::TextureViewDimension::D2,
multisampled: false,
},
count: None,
},
// binding 1: sampler
wgpu::BindGroupLayoutEntry {
binding: 1,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
count: None,
},
// binding 2: ImageParams uniform
wgpu::BindGroupLayoutEntry {
binding: 2,
visibility: wgpu::ShaderStages::FRAGMENT,
ty: wgpu::BindingType::Buffer {
ty: wgpu::BufferBindingType::Uniform,
has_dynamic_offset: false,
min_binding_size: None,
},
count: None,
},
],
});
let image_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
label: Some("image.pipeline_layout"),
bind_group_layouts: &[&frame_bgl, &image_bgl1],
push_constant_ranges: &[],
});
let image_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
label: Some("image.shader"),
source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!(
"../../shaders/image.wgsl"
))),
});
let image_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
label: Some("image.pipeline"),
layout: Some(&image_layout),
vertex: wgpu::VertexState {
module: &image_shader,
entry_point: Some("vs_main"),
buffers: &[image_gpu::ImageVertex::layout()],
compilation_options: wgpu::PipelineCompilationOptions::default(),
},
primitive: wgpu::PrimitiveState {
topology: wgpu::PrimitiveTopology::TriangleList,
cull_mode: None,
..Default::default()
},
depth_stencil: Some(wgpu::DepthStencilState {
format: wgpu::TextureFormat::Depth32Float,
depth_write_enabled: true,
depth_compare: wgpu::CompareFunction::LessEqual,
stencil: wgpu::StencilState::default(),
bias: wgpu::DepthBiasState::default(),
}),
multisample: wgpu::MultisampleState {
count: 1,
mask: !0,
alpha_to_coverage_enabled: false,
},
fragment: Some(wgpu::FragmentState {
module: &image_shader,
entry_point: Some("fs_main"),
targets: &[Some(wgpu::ColorTargetState {
format,
blend: Some(wgpu::BlendState::ALPHA_BLENDING),
write_mask: wgpu::ColorWrites::ALL,
})],
compilation_options: wgpu::PipelineCompilationOptions::default(),
}),
multiview: None,
cache: None,
});
let viewcube = ViewCubePipeline::new(device, queue, format);
Self {
wire_pipeline,
hatch_pipeline,
image_pipeline,
mesh_pipeline,
uniform_buffer,
uniform_bind_group,
hatch_bgl1,
image_bgl1,
depth_texture_size: Size::new(1, 1),
depth_view,
gpu_wires: vec![],
gpu_hatches: vec![],
gpu_images: vec![],
gpu_meshes: vec![],
viewcube,
}
@ -284,6 +382,18 @@ impl Pipeline {
.collect();
}
pub fn upload_images(
&mut self,
device: &wgpu::Device,
queue: &wgpu::Queue,
images: &[ImageModel],
) {
self.gpu_images = images
.iter()
.filter_map(|m| ImageGpu::new(device, queue, m, &self.image_bgl1))
.collect();
}
pub fn upload_uniforms(&self, queue: &wgpu::Queue, uniforms: &Uniforms) {
queue.write_buffer(&self.uniform_buffer, 0, bytemuck::bytes_of(uniforms));
}
@ -339,7 +449,48 @@ impl Pipeline {
}
}
// ── Pass 2: solid meshes ───────────────────────────────────────────
// ── Pass 2: raster images ─────────────────────────────────────────
if !self.gpu_images.is_empty() {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("image.render_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: target,
depth_slice: None,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
timestamp_writes: None,
occlusion_query_set: None,
});
pass.set_viewport(
vp.x as f32,
vp.y as f32,
vp.width as f32,
vp.height as f32,
0.0,
1.0,
);
pass.set_pipeline(&self.image_pipeline);
pass.set_bind_group(0, &self.uniform_bind_group, &[]);
for img in &self.gpu_images {
pass.set_bind_group(1, &img.bind_group, &[]);
pass.set_vertex_buffer(0, img.vertex_buffer.slice(..));
pass.draw(0..6, 0..1);
}
}
// ── Pass 4: solid meshes ──────────────────────────────────────────
if !self.gpu_meshes.is_empty() {
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("mesh.render_pass"),
@ -382,7 +533,7 @@ impl Pipeline {
}
}
// ── Pass 3: wires ──────────────────────────────────────────────────
// ── Pass 5: wires ─────────────────────────────────────────────────
{
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("wire.render_pass"),