Implement elite style scanner

This commit is contained in:
Greg Shuflin
2026-02-14 20:18:13 -08:00
parent 75eeb1f944
commit 0367607789
7 changed files with 588 additions and 53 deletions
+1 -1
View File
@@ -1 +1 @@
- [ ] Implement a 3D map interface kind of like the one in the classic 80s game Elite
- [x] Implement a 3D map interface kind of like the one in the classic 80s game Elite
+14 -1
View File
@@ -87,7 +87,7 @@
#coord-bar {
position: fixed;
bottom: 0;
bottom: 25%;
left: 0;
width: 100%;
z-index: 10;
@@ -97,6 +97,17 @@
pointer-events: none;
}
#scanner-separator {
position: fixed;
bottom: 25%;
left: 0;
width: 100%;
height: 2px;
background: rgba(0, 180, 0, 0.6);
z-index: 10;
pointer-events: none;
}
#coord-bar .coord-inner {
background: rgba(0, 0, 0, 0.6);
border: 1px solid rgba(255, 255, 255, 0.15);
@@ -157,6 +168,8 @@
</div>
</div>
<div id="scanner-separator"></div>
<div id="coord-bar">
<div class="coord-inner">
<span><span class="coord-label">X</span><span id="coord-x">10.0</span></span>
+13 -2
View File
@@ -2,6 +2,7 @@ mod camera;
mod input;
mod mesh;
mod renderer;
mod scanner;
mod scene;
use std::sync::Arc;
@@ -10,6 +11,7 @@ use camera::Camera;
use glam::Vec3;
use input::InputState;
use renderer::Renderer;
use scanner::Scanner;
#[cfg(target_arch = "wasm32")]
use wasm_bindgen::JsCast;
use winit::{
@@ -29,6 +31,7 @@ struct App {
scene_meshes_and_objects: Option<(Vec<mesh::Mesh>, Vec<scene::SceneObject>)>,
start_time: Option<web_time::Instant>,
last_frame: Option<web_time::Instant>,
scanner: Scanner,
help_visible: bool,
focused: bool,
}
@@ -49,6 +52,7 @@ impl App {
},
input: InputState::new(),
scene_meshes_and_objects: Some((meshes, objects)),
scanner: Scanner::new(60.0),
start_time: None,
last_frame: None,
help_visible: true,
@@ -286,7 +290,8 @@ impl ApplicationHandler for App {
WindowEvent::Resized(size) => {
if let Some(r) = &mut self.renderer {
r.resize(size.width, size.height);
self.camera.aspect = size.width as f32 / size.height.max(1) as f32;
let game_h = size.height as f32 - r.scanner_strip_height();
self.camera.aspect = size.width as f32 / game_h.max(1.0);
}
}
@@ -325,7 +330,13 @@ impl ApplicationHandler for App {
let renderer = self.renderer.as_ref().unwrap();
let (_, objects) = self.scene_meshes_and_objects.as_ref().unwrap();
match renderer.render(&self.camera, objects, time) {
let scanner_blips = self.scanner.compute_blips(
self.camera.position,
self.camera.yaw,
objects,
);
match renderer.render(&self.camera, objects, time, &scanner_blips) {
Ok(_) => {}
Err(wgpu::SurfaceError::Lost | wgpu::SurfaceError::Outdated) => {
let size = self.window.as_ref().unwrap().inner_size();
+226 -47
View File
@@ -45,6 +45,92 @@ pub struct Mesh {
pub vertices: Vec<Vertex>,
}
/// Push a box defined by min/max corners with a given color and correct normals.
fn push_box(verts: &mut Vec<Vertex>, min: Vec3, max: Vec3, color: [f32; 3]) {
let corners = [
Vec3::new(min.x, min.y, min.z), // 0: left-bottom-back
Vec3::new(max.x, min.y, min.z), // 1: right-bottom-back
Vec3::new(max.x, max.y, min.z), // 2: right-top-back
Vec3::new(min.x, max.y, min.z), // 3: left-top-back
Vec3::new(min.x, min.y, max.z), // 4: left-bottom-front
Vec3::new(max.x, min.y, max.z), // 5: right-bottom-front
Vec3::new(max.x, max.y, max.z), // 6: right-top-front
Vec3::new(min.x, max.y, max.z), // 7: left-top-front
];
// 6 faces, 2 triangles each, CCW winding from outside
let faces: &[([usize; 3], Vec3)] = &[
// front (+Z)
([4, 5, 6], Vec3::Z),
([4, 6, 7], Vec3::Z),
// back (-Z)
([1, 0, 3], Vec3::NEG_Z),
([1, 3, 2], Vec3::NEG_Z),
// right (+X)
([5, 1, 2], Vec3::X),
([5, 2, 6], Vec3::X),
// left (-X)
([0, 4, 7], Vec3::NEG_X),
([0, 7, 3], Vec3::NEG_X),
// top (+Y)
([3, 7, 6], Vec3::Y),
([3, 6, 2], Vec3::Y),
// bottom (-Y)
([0, 1, 5], Vec3::NEG_Y),
([0, 5, 4], Vec3::NEG_Y),
];
for (idx, normal) in faces {
for &i in idx {
verts.push(Vertex {
position: corners[i].into(),
normal: (*normal).into(),
color,
});
}
}
}
/// Build a mesh from line segments as thin flat quads in the XY plane.
fn line_segments_mesh(
segments: &[([f32; 2], [f32; 2])],
thickness: f32,
z: f32,
color: [f32; 3],
) -> Mesh {
let mut vertices = Vec::new();
let normal = [0.0, 0.0, 1.0];
let ht = thickness / 2.0;
for &([x0, y0], [x1, y1]) in segments {
let dx = x1 - x0;
let dy = y1 - y0;
let len = (dx * dx + dy * dy).sqrt();
if len < 1e-6 {
continue;
}
// Perpendicular (90 deg CCW from direction)
let px = -dy / len * ht;
let py = dx / len * ht;
// v0=start+perp, v1=start-perp, v2=end-perp, v3=end+perp
let v0 = [x0 + px, y0 + py, z];
let v1 = [x0 - px, y0 - py, z];
let v2 = [x1 - px, y1 - py, z];
let v3 = [x1 + px, y1 + py, z];
vertices.push(Vertex { position: v0, normal, color });
vertices.push(Vertex { position: v1, normal, color });
vertices.push(Vertex { position: v2, normal, color });
vertices.push(Vertex { position: v0, normal, color });
vertices.push(Vertex { position: v2, normal, color });
vertices.push(Vertex { position: v3, normal, color });
}
Mesh { vertices }
}
impl Mesh {
/// Create an octahedron with the given color.
pub fn octahedron(color: [f32; 3]) -> Self {
@@ -125,53 +211,6 @@ impl Mesh {
let y_neg = desat(y_pos);
let z_neg = desat(z_pos);
// Helper: push a box defined by min/max corners with a given color
let push_box =
|verts: &mut Vec<Vertex>, min: Vec3, max: Vec3, color: [f32; 3]| {
let corners = [
Vec3::new(min.x, min.y, min.z), // 0: left-bottom-back
Vec3::new(max.x, min.y, min.z), // 1: right-bottom-back
Vec3::new(max.x, max.y, min.z), // 2: right-top-back
Vec3::new(min.x, max.y, min.z), // 3: left-top-back
Vec3::new(min.x, min.y, max.z), // 4: left-bottom-front
Vec3::new(max.x, min.y, max.z), // 5: right-bottom-front
Vec3::new(max.x, max.y, max.z), // 6: right-top-front
Vec3::new(min.x, max.y, max.z), // 7: left-top-front
];
// 6 faces, 2 triangles each, CCW winding from outside
let faces: &[([usize; 3], Vec3)] = &[
// front (+Z)
([4, 5, 6], Vec3::Z),
([4, 6, 7], Vec3::Z),
// back (-Z)
([1, 0, 3], Vec3::NEG_Z),
([1, 3, 2], Vec3::NEG_Z),
// right (+X)
([5, 1, 2], Vec3::X),
([5, 2, 6], Vec3::X),
// left (-X)
([0, 4, 7], Vec3::NEG_X),
([0, 7, 3], Vec3::NEG_X),
// top (+Y)
([3, 7, 6], Vec3::Y),
([3, 6, 2], Vec3::Y),
// bottom (-Y)
([0, 1, 5], Vec3::NEG_Y),
([0, 5, 4], Vec3::NEG_Y),
];
for (idx, normal) in faces {
for &i in idx {
verts.push(Vertex {
position: corners[i].into(),
normal: (*normal).into(),
color,
});
}
}
};
// Axis lines — split at origin into positive and negative halves
// X axis
push_box(&mut vertices, Vec3::new(0.0, -axis_hw, -axis_hw), Vec3::new(extent, axis_hw, axis_hw), x_pos);
@@ -371,4 +410,144 @@ impl Mesh {
Self { vertices }
}
/// Filled circle in XY plane (triangle fan from center), normals pointing +Z.
pub fn disc(segments: u32, radius: f32, color: [f32; 3]) -> Self {
let mut vertices = Vec::new();
let normal = [0.0, 0.0, 1.0];
let center = [0.0, 0.0, 0.0];
for i in 0..segments {
let a0 = (i as f32 / segments as f32) * std::f32::consts::TAU;
let a1 = ((i + 1) as f32 / segments as f32) * std::f32::consts::TAU;
vertices.push(Vertex { position: center, normal, color });
vertices.push(Vertex {
position: [a0.cos() * radius, a0.sin() * radius, 0.0],
normal,
color,
});
vertices.push(Vertex {
position: [a1.cos() * radius, a1.sin() * radius, 0.0],
normal,
color,
});
}
Self { vertices }
}
/// Thin annulus in XY plane at given z height.
pub fn ring(segments: u32, inner_radius: f32, outer_radius: f32, z: f32, color: [f32; 3]) -> Self {
let mut vertices = Vec::new();
let normal = [0.0, 0.0, 1.0];
for i in 0..segments {
let a0 = (i as f32 / segments as f32) * std::f32::consts::TAU;
let a1 = ((i + 1) as f32 / segments as f32) * std::f32::consts::TAU;
let in0 = [inner_radius * a0.cos(), inner_radius * a0.sin(), z];
let in1 = [inner_radius * a1.cos(), inner_radius * a1.sin(), z];
let out0 = [outer_radius * a0.cos(), outer_radius * a0.sin(), z];
let out1 = [outer_radius * a1.cos(), outer_radius * a1.sin(), z];
vertices.push(Vertex { position: in0, normal, color });
vertices.push(Vertex { position: out0, normal, color });
vertices.push(Vertex { position: out1, normal, color });
vertices.push(Vertex { position: in0, normal, color });
vertices.push(Vertex { position: out1, normal, color });
vertices.push(Vertex { position: in1, normal, color });
}
Self { vertices }
}
/// Concentric rings + two perpendicular cross lines for scanner grid overlay.
pub fn scanner_grid(num_rings: u32, radius: f32, line_width: f32, color: [f32; 3]) -> Self {
let mut vertices = Vec::new();
let normal = [0.0, 0.0, 1.0];
let z = 0.001;
let hw = line_width / 2.0;
let segments = 64u32;
// Concentric rings
for ring in 1..=num_rings {
let r = radius * (ring as f32 / (num_rings + 1) as f32);
let inner = r - hw;
let outer = r + hw;
for i in 0..segments {
let a0 = (i as f32 / segments as f32) * std::f32::consts::TAU;
let a1 = ((i + 1) as f32 / segments as f32) * std::f32::consts::TAU;
let p0_in = [inner * a0.cos(), inner * a0.sin(), z];
let p1_in = [inner * a1.cos(), inner * a1.sin(), z];
let p0_out = [outer * a0.cos(), outer * a0.sin(), z];
let p1_out = [outer * a1.cos(), outer * a1.sin(), z];
vertices.push(Vertex { position: p0_in, normal, color });
vertices.push(Vertex { position: p0_out, normal, color });
vertices.push(Vertex { position: p1_out, normal, color });
vertices.push(Vertex { position: p0_in, normal, color });
vertices.push(Vertex { position: p1_out, normal, color });
vertices.push(Vertex { position: p1_in, normal, color });
}
}
// Cross lines (flat quads through center)
// X-axis line
vertices.push(Vertex { position: [-radius, -hw, z], normal, color });
vertices.push(Vertex { position: [radius, -hw, z], normal, color });
vertices.push(Vertex { position: [radius, hw, z], normal, color });
vertices.push(Vertex { position: [-radius, -hw, z], normal, color });
vertices.push(Vertex { position: [radius, hw, z], normal, color });
vertices.push(Vertex { position: [-radius, hw, z], normal, color });
// Y-axis line
vertices.push(Vertex { position: [-hw, -radius, z], normal, color });
vertices.push(Vertex { position: [hw, -radius, z], normal, color });
vertices.push(Vertex { position: [hw, radius, z], normal, color });
vertices.push(Vertex { position: [-hw, -radius, z], normal, color });
vertices.push(Vertex { position: [hw, radius, z], normal, color });
vertices.push(Vertex { position: [-hw, radius, z], normal, color });
Self { vertices }
}
/// Vertical box from z=0 to z=height, centered on XY at origin.
pub fn thin_box(half_width: f32, height: f32, color: [f32; 3]) -> Self {
let mut vertices = Vec::new();
push_box(
&mut vertices,
Vec3::new(-half_width, -half_width, 0.0),
Vec3::new(half_width, half_width, height),
color,
);
Self { vertices }
}
/// Compound axis label glyph: sign (+/-) on left, letter (X/Y) on right.
pub fn axis_label(positive: bool, is_x: bool, size: f32, thickness: f32, color: [f32; 3]) -> Self {
let mut segments: Vec<([f32; 2], [f32; 2])> = Vec::new();
let s = size;
// Sign (left side, centered at x = -1.5*s)
segments.push(([-2.2 * s, 0.0], [-0.8 * s, 0.0])); // horizontal bar
if positive {
segments.push(([-1.5 * s, -0.7 * s], [-1.5 * s, 0.7 * s])); // vertical bar
}
// Letter (right side, centered at x = 0.5*s)
if is_x {
segments.push(([-0.5 * s, -s], [1.5 * s, s]));
segments.push(([-0.5 * s, s], [1.5 * s, -s]));
} else {
segments.push(([0.5 * s, 0.0], [-0.5 * s, s]));
segments.push(([0.5 * s, 0.0], [1.5 * s, s]));
segments.push(([0.5 * s, 0.0], [0.5 * s, -s]));
}
line_segments_mesh(&segments, thickness, 0.0, color)
}
}
+265 -2
View File
@@ -3,8 +3,11 @@ use std::sync::Arc;
use wgpu::util::DeviceExt;
use wgpu::ExperimentalFeatures;
use glam::{Mat4, Vec3};
use crate::camera::{Camera, CameraUniform};
use crate::mesh::{Mesh, Vertex};
use crate::scanner::ScannerBlip;
use crate::scene::{ModelUniform, SceneObject};
/// GPU-side mesh: vertex buffer + vertex count
@@ -29,6 +32,9 @@ pub struct Renderer {
pub model_bind_group: wgpu::BindGroup,
pub model_uniform_alignment: u64,
pub gpu_meshes: Vec<GpuMesh>,
pub scanner_camera_buffer: wgpu::Buffer,
pub scanner_camera_bind_group: wgpu::BindGroup,
pub scanner_mesh_start: usize,
}
impl Renderer {
@@ -136,6 +142,32 @@ impl Renderer {
}],
});
// --- Scanner camera (orthographic, tilted top-down view of the scanner disc) ---
let scanner_eye = Vec3::new(0.0, -0.8, 1.5);
let scanner_view = Mat4::look_at_rh(scanner_eye, Vec3::ZERO, Vec3::Z);
let scanner_proj = Mat4::orthographic_rh(-1.4, 1.4, -1.0, 1.8, 0.1, 5.0);
let scanner_cam_uniform = CameraUniform {
view_proj: (scanner_proj * scanner_view).to_cols_array_2d(),
eye_pos: scanner_eye.into(),
_padding: 0.0,
};
let scanner_camera_buffer =
device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("scanner_camera_buf"),
contents: bytemuck::bytes_of(&scanner_cam_uniform),
usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
});
let scanner_camera_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor {
label: Some("scanner_camera_bg"),
layout: &camera_bind_group_layout,
entries: &[wgpu::BindGroupEntry {
binding: 0,
resource: scanner_camera_buffer.as_entire_binding(),
}],
});
// --- Model uniform (dynamic offsets for per-object transforms) ---
let model_uniform_alignment =
device.limits().min_uniform_buffer_offset_alignment as u64;
@@ -224,7 +256,7 @@ impl Renderer {
});
// --- Upload meshes ---
let gpu_meshes = meshes
let mut gpu_meshes: Vec<GpuMesh> = meshes
.iter()
.map(|m| {
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
@@ -239,6 +271,32 @@ impl Renderer {
})
.collect();
// --- Scanner meshes ---
let scanner_mesh_start = gpu_meshes.len();
let scanner_meshes = [
Mesh::disc(64, 1.0, [0.02, 0.05, 0.02]), // 0: scanner disc
Mesh::scanner_grid(3, 1.0, 0.008, [0.0, 0.25, 0.0]), // 1: grid
Mesh::ring(64, 0.97, 1.0, 0.002, [0.0, 0.5, 0.0]), // 2: border ring
Mesh::octahedron([0.0, 1.0, 0.0]), // 3: blip dot
Mesh::thin_box(0.015, 1.0, [0.0, 0.5, 0.0]), // 4: blip stalk
Mesh::disc(32, 1.0, [0.0, 0.0, 0.0]), // 5: black panel bg
Mesh::axis_label(true, true, 1.0, 0.25, [0.7, 0.2, 0.2]), // 6: +X label
Mesh::axis_label(false, true, 1.0, 0.25, [0.7, 0.2, 0.2]), // 7: -X label
Mesh::axis_label(true, false, 1.0, 0.25, [0.2, 0.7, 0.2]), // 8: +Y label
Mesh::axis_label(false, false, 1.0, 0.25, [0.2, 0.7, 0.2]), // 9: -Y label
];
for m in &scanner_meshes {
let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor {
label: Some("scanner_mesh_vb"),
contents: bytemuck::cast_slice(&m.vertices),
usage: wgpu::BufferUsages::VERTEX,
});
gpu_meshes.push(GpuMesh {
vertex_buffer,
vertex_count: m.vertices.len() as u32,
});
}
Self {
surface,
device,
@@ -250,11 +308,19 @@ impl Renderer {
camera_bind_group,
model_buffer,
model_bind_group,
model_uniform_alignment: model_uniform_alignment,
model_uniform_alignment,
gpu_meshes,
scanner_camera_buffer,
scanner_camera_bind_group,
scanner_mesh_start,
}
}
/// Height in pixels of the scanner strip at the bottom of the screen.
pub fn scanner_strip_height(&self) -> f32 {
(self.config.height as f32 * 0.25).max(180.0)
}
pub fn resize(&mut self, width: u32, height: u32) {
let max_dim = self.device.limits().max_texture_dimension_2d;
if width > 0 && height > 0 {
@@ -270,6 +336,7 @@ impl Renderer {
camera: &Camera,
objects: &[SceneObject],
time: f32,
scanner_blips: &[ScannerBlip],
) -> Result<(), wgpu::SurfaceError> {
// Update camera uniform
let cam_uniform = CameraUniform::from_camera(camera);
@@ -329,7 +396,11 @@ impl Renderer {
..Default::default()
});
let strip_h = self.scanner_strip_height();
let game_h = self.config.height as f32 - strip_h;
pass.set_pipeline(&self.pipeline);
pass.set_viewport(0.0, 0.0, self.config.width as f32, game_h, 0.0, 1.0);
pass.set_bind_group(0, &self.camera_bind_group, &[]);
for (i, obj) in objects.iter().enumerate() {
@@ -342,6 +413,198 @@ impl Renderer {
}
}
// --- Scanner pass ---
{
let scene_count = objects.len();
// Slots: 0=panel, 1-3=disc/grid/border, 4-7=labels(+X,-X,+Y,-Y), 8+2*bi=dot, 8+2*bi+1=stalk
let max_blips = ((MAX_OBJECTS - scene_count - 8) / 2).min(scanner_blips.len());
// Panel background: large black disc behind everything
{
let slot = scene_count;
let panel_transform = Mat4::from_translation(Vec3::new(0.0, 0.0, -0.5))
* Mat4::from_scale(Vec3::splat(5.0));
let uniform = ModelUniform {
transform: panel_transform.to_cols_array_2d(),
};
self.queue.write_buffer(
&self.model_buffer,
slot as u64 * slot_size,
bytemuck::bytes_of(&uniform),
);
}
// Write scanner fixed geometry transforms (identity)
for i in 0..3 {
let slot = scene_count + 1 + i;
let uniform = ModelUniform {
transform: Mat4::IDENTITY.to_cols_array_2d(),
};
let offset = slot as u64 * slot_size;
self.queue
.write_buffer(&self.model_buffer, offset, bytemuck::bytes_of(&uniform));
}
// Axis label transforms — rotate with camera yaw to show world axis directions
// After the 90° CCW rotation in scanner.rs, world directions map to scanner disc as:
// +X -> ( sin(yaw), cos(yaw))
// -X -> (-sin(yaw), -cos(yaw))
// +Y -> (-cos(yaw), sin(yaw))
// -Y -> ( cos(yaw), -sin(yaw))
let yaw = camera.yaw;
let label_radius = 1.12;
let label_scale = 0.09;
let label_positions = [
Vec3::new( yaw.sin() * label_radius, yaw.cos() * label_radius, 0.003), // +X
Vec3::new(-yaw.sin() * label_radius, -yaw.cos() * label_radius, 0.003), // -X
Vec3::new(-yaw.cos() * label_radius, yaw.sin() * label_radius, 0.003), // +Y
Vec3::new( yaw.cos() * label_radius, -yaw.sin() * label_radius, 0.003), // -Y
];
for (i, pos) in label_positions.iter().enumerate() {
let transform = Mat4::from_translation(*pos)
* Mat4::from_scale(Vec3::splat(label_scale));
let uniform = ModelUniform {
transform: transform.to_cols_array_2d(),
};
self.queue.write_buffer(
&self.model_buffer,
(scene_count + 4 + i) as u64 * slot_size,
bytemuck::bytes_of(&uniform),
);
}
// Write blip transforms
for (bi, blip) in scanner_blips[..max_blips].iter().enumerate() {
// Dot: small octahedron at blip position + altitude
let dot_slot = scene_count + 8 + bi * 2;
let dot_transform = Mat4::from_translation(Vec3::new(
blip.disc_pos.x,
blip.disc_pos.y,
blip.altitude,
)) * Mat4::from_scale(Vec3::splat(0.06));
let uniform = ModelUniform {
transform: dot_transform.to_cols_array_2d(),
};
self.queue.write_buffer(
&self.model_buffer,
dot_slot as u64 * slot_size,
bytemuck::bytes_of(&uniform),
);
// Stalk: thin box from disc surface to altitude
let stalk_slot = scene_count + 8 + bi * 2 + 1;
let (stalk_z, stalk_h) = if blip.altitude >= 0.0 {
(0.0_f32, blip.altitude.max(0.001))
} else {
(blip.altitude, (-blip.altitude).max(0.001))
};
let stalk_transform = Mat4::from_translation(Vec3::new(
blip.disc_pos.x,
blip.disc_pos.y,
stalk_z,
)) * Mat4::from_scale(Vec3::new(1.0, 1.0, stalk_h));
let uniform = ModelUniform {
transform: stalk_transform.to_cols_array_2d(),
};
self.queue.write_buffer(
&self.model_buffer,
stalk_slot as u64 * slot_size,
bytemuck::bytes_of(&uniform),
);
}
// Second render pass: scanner strip
let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
label: Some("scanner_pass"),
color_attachments: &[Some(wgpu::RenderPassColorAttachment {
view: &view,
resolve_target: None,
ops: wgpu::Operations {
load: wgpu::LoadOp::Load,
store: wgpu::StoreOp::Store,
},
depth_slice: None,
})],
depth_stencil_attachment: Some(wgpu::RenderPassDepthStencilAttachment {
view: &self.depth_view,
depth_ops: Some(wgpu::Operations {
load: wgpu::LoadOp::Clear(1.0),
store: wgpu::StoreOp::Store,
}),
stencil_ops: None,
}),
..Default::default()
});
let strip_h = self.scanner_strip_height();
let game_h = self.config.height as f32 - strip_h;
pass.set_pipeline(&self.pipeline);
pass.set_bind_group(0, &self.scanner_camera_bind_group, &[]);
// Draw black panel background across the full strip
pass.set_viewport(0.0, game_h, self.config.width as f32, strip_h, 0.0, 1.0);
{
let dynamic_offset = (scene_count as u64 * slot_size) as u32;
pass.set_bind_group(1, &self.model_bind_group, &[dynamic_offset]);
let panel_mesh = &self.gpu_meshes[self.scanner_mesh_start + 5];
pass.set_vertex_buffer(0, panel_mesh.vertex_buffer.slice(..));
pass.draw(0..panel_mesh.vertex_count, 0..1);
}
// Switch to centered scanner viewport
let scanner_size = (strip_h - 16.0).min(self.config.width as f32 * 0.4);
let vp_x = (self.config.width as f32 - scanner_size) / 2.0;
let vp_y = game_h + (strip_h - scanner_size) / 2.0;
pass.set_viewport(vp_x, vp_y, scanner_size, scanner_size, 0.0, 1.0);
// Fixed geometry: disc, grid, border ring
for i in 0..3 {
let slot = scene_count + 1 + i;
let dynamic_offset = (slot as u64 * slot_size) as u32;
pass.set_bind_group(1, &self.model_bind_group, &[dynamic_offset]);
let mesh = &self.gpu_meshes[self.scanner_mesh_start + i];
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
pass.draw(0..mesh.vertex_count, 0..1);
}
// Axis labels (+X, -X, +Y, -Y positioned at disc edge)
for i in 0..4 {
let slot = scene_count + 4 + i;
let dynamic_offset = (slot as u64 * slot_size) as u32;
pass.set_bind_group(1, &self.model_bind_group, &[dynamic_offset]);
let mesh = &self.gpu_meshes[self.scanner_mesh_start + 6 + i];
pass.set_vertex_buffer(0, mesh.vertex_buffer.slice(..));
pass.draw(0..mesh.vertex_count, 0..1);
}
// Blip dots and stalks
let dot_mesh = &self.gpu_meshes[self.scanner_mesh_start + 3];
let stalk_mesh = &self.gpu_meshes[self.scanner_mesh_start + 4];
for bi in 0..max_blips {
// Dot
let dot_slot = scene_count + 8 + bi * 2;
pass.set_bind_group(
1,
&self.model_bind_group,
&[(dot_slot as u64 * slot_size) as u32],
);
pass.set_vertex_buffer(0, dot_mesh.vertex_buffer.slice(..));
pass.draw(0..dot_mesh.vertex_count, 0..1);
// Stalk
let stalk_slot = scene_count + 8 + bi * 2 + 1;
pass.set_bind_group(
1,
&self.model_bind_group,
&[(stalk_slot as u64 * slot_size) as u32],
);
pass.set_vertex_buffer(0, stalk_mesh.vertex_buffer.slice(..));
pass.draw(0..stalk_mesh.vertex_count, 0..1);
}
}
self.queue.submit(std::iter::once(encoder.finish()));
output.present();
+65
View File
@@ -0,0 +1,65 @@
use glam::{Vec2, Vec3};
use crate::scene::SceneObject;
pub struct Scanner {
pub range: f32,
}
pub struct ScannerBlip {
pub disc_pos: Vec2,
pub altitude: f32,
pub color: [f32; 3],
}
impl Scanner {
pub fn new(range: f32) -> Self {
Self { range }
}
pub fn compute_blips(
&self,
camera_pos: Vec3,
camera_yaw: f32,
objects: &[SceneObject],
) -> Vec<ScannerBlip> {
let mut blips = Vec::new();
let cos_yaw = (-camera_yaw).cos();
let sin_yaw = (-camera_yaw).sin();
let disc_scale = 1.0 / self.range;
for obj in objects {
let rel = obj.position - camera_pos;
let horiz_dist = (rel.x * rel.x + rel.y * rel.y).sqrt();
if horiz_dist > self.range {
continue;
}
// Rotate by negative yaw so scanner is view-relative,
// then apply 90° CCW so forward maps to scanner +Y (top of disc)
let rotated_x = rel.x * cos_yaw - rel.y * sin_yaw;
let rotated_y = rel.x * sin_yaw + rel.y * cos_yaw;
let rx = -rotated_y;
let ry = rotated_x;
let disc_x = rx * disc_scale;
let disc_y = ry * disc_scale;
// Skip if outside the disc
if disc_x * disc_x + disc_y * disc_y > 1.0 {
continue;
}
let altitude = (rel.z * disc_scale).clamp(-0.6, 0.6);
blips.push(ScannerBlip {
disc_pos: Vec2::new(disc_x, disc_y),
altitude,
color: obj.color,
});
}
blips
}
}
+4
View File
@@ -11,6 +11,7 @@ pub struct SceneObject {
pub rotation_speed: f32,
pub scale: f32,
pub base_rotation: f32,
pub color: [f32; 3],
}
impl SceneObject {
@@ -72,6 +73,7 @@ pub fn build_scene() -> (Vec<Mesh>, Vec<SceneObject>) {
rotation_speed: 0.0,
scale: 1.0,
base_rotation: 0.0,
color: [0.5, 0.5, 0.5],
});
// Rotating spiked ball at origin
@@ -84,6 +86,7 @@ pub fn build_scene() -> (Vec<Mesh>, Vec<SceneObject>) {
rotation_speed: 0.5,
scale: 0.8,
base_rotation: 0.0,
color: [0.3, 0.5, 1.0],
});
let num_objects = 30;
@@ -115,6 +118,7 @@ pub fn build_scene() -> (Vec<Mesh>, Vec<SceneObject>) {
rotation_speed,
scale,
base_rotation,
color: COLORS[mesh_index],
});
}