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:
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);
+}