notedeck

One damus client to rule them all
git clone git://jb55.com/notedeck
Log | Files | Refs | README | LICENSE

commit 14ac7d491d2e40292f9400d70159f4c006eaab6f
parent a1b89cd2ea0c123880491037e322f15bc4d78c12
Author: William Casarin <jb55@jb55.com>
Date:   Mon, 23 Feb 2026 18:02:12 -0800

nostrverse: add outline shader for selected object highlighting

Inverted hull method renders back faces of an inflated mesh in solid
orange to produce a visible outline around the selected object in the
3D viewport. Selection from both viewport clicks and editor panel list
now syncs to the renderer.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

Diffstat:
Mcrates/notedeck_nostrverse/src/lib.rs | 14++++++++++++++
Mcrates/notedeck_nostrverse/src/room_view.rs | 8++++++--
Mcrates/renderbud/src/lib.rs | 80+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Acrates/renderbud/src/outline.wgsl | 56++++++++++++++++++++++++++++++++++++++++++++++++++++++++
4 files changed, 156 insertions(+), 2 deletions(-)

diff --git a/crates/notedeck_nostrverse/src/lib.rs b/crates/notedeck_nostrverse/src/lib.rs @@ -657,6 +657,17 @@ impl NostrverseApp { } } NostrverseAction::SelectObject(selected) => { + // Update renderer outline highlight + if let Some(renderer) = &self.renderer { + let scene_id = selected.as_ref().and_then(|sel_id| { + self.state + .objects + .iter() + .find(|o| &o.id == sel_id) + .and_then(|o| o.scene_object_id) + }); + renderer.renderer.lock().unwrap().set_selected(scene_id); + } self.state.selected_object = selected; } NostrverseAction::SaveRoom => { @@ -671,6 +682,9 @@ impl NostrverseApp { self.state.objects.retain(|o| o.id != id); if self.state.selected_object.as_ref() == Some(&id) { self.state.selected_object = None; + if let Some(renderer) = &self.renderer { + renderer.renderer.lock().unwrap().set_selected(None); + } } self.state.dirty = true; } diff --git a/crates/notedeck_nostrverse/src/room_view.rs b/crates/notedeck_nostrverse/src/room_view.rs @@ -291,8 +291,12 @@ pub fn render_editing_panel(ui: &mut Ui, state: &mut NostrverseState) -> Option< let label = format!("{} ({})", state.objects[i].name, state.objects[i].id); if ui.selectable_label(is_selected, label).clicked() { - let id = state.objects[i].id.clone(); - state.selected_object = if is_selected { None } else { Some(id) }; + let selected = if is_selected { + None + } else { + Some(state.objects[i].id.clone()) + }; + action = Some(NostrverseAction::SelectObject(selected)); } } diff --git a/crates/renderbud/src/lib.rs b/crates/renderbud/src/lib.rs @@ -119,6 +119,7 @@ pub struct Renderer { skybox_pipeline: wgpu::RenderPipeline, grid_pipeline: wgpu::RenderPipeline, shadow_pipeline: wgpu::RenderPipeline, + outline_pipeline: wgpu::RenderPipeline, shadow_view: wgpu::TextureView, shadow_globals_bg: wgpu::BindGroup, @@ -584,6 +585,55 @@ impl Renderer { multiview: None, }); + // Outline pipeline (inverted hull, front-face culling) + let outline_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("outline_shader"), + source: wgpu::ShaderSource::Wgsl(include_str!("outline.wgsl").into()), + }); + + let outline_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("outline_pipeline_layout"), + bind_group_layouts: &[&shadow_globals_bgl, &object_bgl], + push_constant_ranges: &[], + }); + + let outline_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("outline_pipeline"), + cache: None, + layout: Some(&outline_pipeline_layout), + vertex: wgpu::VertexState { + module: &outline_shader, + compilation_options: wgpu::PipelineCompilationOptions::default(), + entry_point: Some("vs_main"), + buffers: &[Vertex::desc()], + }, + fragment: Some(wgpu::FragmentState { + module: &outline_shader, + compilation_options: wgpu::PipelineCompilationOptions::default(), + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: Some(wgpu::Face::Front), + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth24Plus, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::Less, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState::default(), + multiview: None, + }); + let (depth_tex, depth_view) = create_depth(device, width, height); /* TODO: move to example @@ -612,6 +662,7 @@ impl Renderer { skybox_pipeline, grid_pipeline, shadow_pipeline, + outline_pipeline, shadow_view, shadow_globals_bg, globals, @@ -733,6 +784,11 @@ impl Renderer { self.globals.data.set_camera(w, h, &self.world.camera); } + /// Set or clear which object shows a selection outline. + pub fn set_selected(&mut self, id: Option<ObjectId>) { + self.world.selected_object = id; + } + /// Get the axis-aligned bounding box for a loaded model. pub fn model_bounds(&self, model: Model) -> Option<Aabb> { self.models.get(&model).map(|md| md.bounds) @@ -1001,6 +1057,30 @@ impl Renderer { rpass.draw_indexed(0..d.mesh.num_indices, 0, 0..1); } } + + // 4. Draw selection outline for selected object + if let Some(selected_id) = self.world.selected_object + && let Some(sel_idx) = self + .world + .renderables() + .iter() + .position(|&id| id == selected_id) + { + let node = self.world.get_node(selected_id).unwrap(); + let model_handle = node.model.unwrap(); + if let Some(model_data) = self.models.get(&model_handle) { + rpass.set_pipeline(&self.outline_pipeline); + rpass.set_bind_group(0, &self.shadow_globals_bg, &[]); + let dynamic_offset = (sel_idx as u64 * self.object_buf.stride) as u32; + rpass.set_bind_group(1, &self.object_buf.bindgroup, &[dynamic_offset]); + + for d in &model_data.draws { + rpass.set_vertex_buffer(0, d.mesh.vert_buf.slice(..)); + rpass.set_index_buffer(d.mesh.ind_buf.slice(..), wgpu::IndexFormat::Uint16); + rpass.draw_indexed(0..d.mesh.num_indices, 0, 0..1); + } + } + } } } diff --git a/crates/renderbud/src/outline.wgsl b/crates/renderbud/src/outline.wgsl @@ -0,0 +1,56 @@ +// Outline shader: inverted hull method. +// Renders back faces of a slightly inflated mesh in a solid color +// to produce a visible outline around the selected object. + +struct Globals { + time: f32, + _pad0: f32, + resolution: vec2<f32>, + + cam_pos: vec3<f32>, + _pad3: f32, + + light_dir: vec3<f32>, + _pad1: f32, + + light_color: vec3<f32>, + _pad2: f32, + + fill_light_dir: vec3<f32>, + _pad4: f32, + + fill_light_color: vec3<f32>, + _pad5: f32, + + view_proj: mat4x4<f32>, + inv_view_proj: mat4x4<f32>, + light_view_proj: mat4x4<f32>, +}; + +struct Object { + model: mat4x4<f32>, + normal: mat4x4<f32>, +}; + +@group(0) @binding(0) var<uniform> globals: Globals; +@group(1) @binding(0) var<uniform> object: Object; + +struct VSIn { + @location(0) pos: vec3<f32>, + @location(1) normal: vec3<f32>, + @location(2) uv: vec2<f32>, + @location(3) tangent: vec4<f32>, +}; + +@vertex +fn vs_main(v: VSIn) -> @builtin(position) vec4<f32> { + let outline_width = 0.03; + let inflated = v.pos + v.normal * outline_width; + let world4 = object.model * vec4<f32>(inflated, 1.0); + return globals.view_proj * world4; +} + +@fragment +fn fs_main() -> @location(0) vec4<f32> { + return vec4<f32>(1.0, 0.6, 0.15, 1.0); +}