tawasuyu/llimphi
Sovereign retained-mode GPU UI framework in Rust (wgpu+vello+taffy+parley), Elm loop, 40+ widgets.
chore: refresco desde el monorepo — vello 0.9 / wgpu 29, 120 miembros
1//! `Lines3d` — líneas 3D como `LineList`, depth-test pero **sin escribir**
2//! profundidad, con alpha-blend y un atenuado por profundidad opcional para dar
3//! volumen (el frente brilla, el fondo se apaga). Llena el hueco del crate, que
4//! sólo tenía triángulos: sirve para anillos (círculos máximos), rejillas,
5//! ejes y figuras de constelaciones — cualquier "alambre" 3D.
6//!
7//! Se dibuja en un pase ya abierto con depth attachment (el de
8//! [`PostFx::scene_pass`](crate::PostFx) o [`Scene3d`](crate::Scene3d)), DESPUÉS
9//! de la geometría sólida que deba ocluirlo:
10//!
11//! ```ignore
12//! lines.set_lines(device, &verts); // pares de vértices = segmentos
13//! lines.upload(queue, view_proj);
14//! lines.draw(&mut pass);
15//! ```
16
17use glam::Mat4;
18
19use crate::scene::DEPTH_FORMAT;
20
21/// Vértice de línea: posición en mundo + color RGBA lineal.
22#[derive(Debug, Clone, Copy)]
23pub struct LineVertex {
24 pub pos: [f32; 3],
25 pub color: [f32; 4],
26}
27
28impl LineVertex {
29 /// `pos.xyz (12) + color.rgba (16)`.
30 pub const STRIDE: usize = 7 * 4;
31
32 fn write_to(&self, out: &mut Vec<u8>) {
33 for v in self.pos {
34 out.extend_from_slice(&v.to_ne_bytes());
35 }
36 for v in self.color {
37 out.extend_from_slice(&v.to_ne_bytes());
38 }
39 }
40}
41
42/// Renderer de líneas 3D reutilizable. Sin vértices, [`Self::draw`] es no-op.
43pub struct Lines3d {
44 pipeline: wgpu::RenderPipeline,
45 uniform_buf: wgpu::Buffer,
46 uniform_bg: wgpu::BindGroup,
47 verts: Option<wgpu::Buffer>,
48 count: u32,
49}
50
51impl Lines3d {
52 pub fn new(device: &wgpu::Device, color_format: wgpu::TextureFormat) -> Self {
53 let uniform_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
54 label: Some("llimphi-3d-lines-uniform-layout"),
55 entries: &[wgpu::BindGroupLayoutEntry {
56 binding: 0,
57 visibility: wgpu::ShaderStages::VERTEX_FRAGMENT,
58 ty: wgpu::BindingType::Buffer {
59 ty: wgpu::BufferBindingType::Uniform,
60 has_dynamic_offset: false,
61 min_binding_size: None,
62 },
63 count: None,
64 }],
65 });
66 let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
67 label: Some("llimphi-3d-lines-shader"),
68 source: wgpu::ShaderSource::Wgsl(LINES_WGSL.into()),
69 });
70 let pl = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
71 label: Some("llimphi-3d-lines-pl"),
72 bind_group_layouts: &[Some(&uniform_layout)],
73 immediate_size: 0,
74 });
75 let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
76 label: Some("llimphi-3d-lines-pipeline"),
77 layout: Some(&pl),
78 vertex: wgpu::VertexState {
79 module: &shader,
80 entry_point: Some("vs"),
81 compilation_options: Default::default(),
82 buffers: &[wgpu::VertexBufferLayout {
83 array_stride: LineVertex::STRIDE as u64,
84 step_mode: wgpu::VertexStepMode::Vertex,
85 attributes: &[
86 wgpu::VertexAttribute {
87 format: wgpu::VertexFormat::Float32x3,
88 offset: 0,
89 shader_location: 0,
90 },
91 wgpu::VertexAttribute {
92 format: wgpu::VertexFormat::Float32x4,
93 offset: 12,
94 shader_location: 1,
95 },
96 ],
97 }],
98 },
99 primitive: wgpu::PrimitiveState {
100 topology: wgpu::PrimitiveTopology::LineList,
101 cull_mode: None,
102 ..Default::default()
103 },
104 depth_stencil: Some(wgpu::DepthStencilState {
105 format: DEPTH_FORMAT,
106 depth_write_enabled: Some(false),
107 depth_compare: Some(wgpu::CompareFunction::LessEqual),
108 stencil: Default::default(),
109 bias: Default::default(),
110 }),
111 multisample: Default::default(),
112 fragment: Some(wgpu::FragmentState {
113 module: &shader,
114 entry_point: Some("fs"),
115 compilation_options: Default::default(),
116 targets: &[Some(wgpu::ColorTargetState {
117 format: color_format,
118 blend: Some(wgpu::BlendState::ALPHA_BLENDING),
119 write_mask: wgpu::ColorWrites::ALL,
120 })],
121 }),
122 multiview_mask: None,
123 cache: None,
124 });
125 let uniform_buf = device.create_buffer(&wgpu::BufferDescriptor {
126 label: Some("llimphi-3d-lines-uniform"),
127 size: 64, // view_proj mat4
128 usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST,
129 mapped_at_creation: false,
130 });
131 let uniform_bg = device.create_bind_group(&wgpu::BindGroupDescriptor {
132 label: Some("llimphi-3d-lines-uniform-bg"),
133 layout: &uniform_layout,
134 entries: &[wgpu::BindGroupEntry {
135 binding: 0,
136 resource: uniform_buf.as_entire_binding(),
137 }],
138 });
139 Self {
140 pipeline,
141 uniform_buf,
142 uniform_bg,
143 verts: None,
144 count: 0,
145 }
146 }
147
148 /// Reemplaza los vértices. Cada **par** consecutivo es un segmento; para una
149 /// polilínea, repetir el vértice interior (a,b, b,c, c,d…).
150 pub fn set_lines(&mut self, device: &wgpu::Device, verts: &[LineVertex]) {
151 if verts.is_empty() {
152 self.count = 0;
153 self.verts = None;
154 return;
155 }
156 let mut bytes = Vec::with_capacity(verts.len() * LineVertex::STRIDE);
157 for v in verts {
158 v.write_to(&mut bytes);
159 }
160 let buf = device.create_buffer(&wgpu::BufferDescriptor {
161 label: Some("llimphi-3d-lines-vbuf"),
162 size: bytes.len() as u64,
163 usage: wgpu::BufferUsages::VERTEX | wgpu::BufferUsages::COPY_DST,
164 mapped_at_creation: true,
165 });
166 buf.slice(..).get_mapped_range_mut().copy_from_slice(&bytes);
167 buf.unmap();
168 self.verts = Some(buf);
169 self.count = verts.len() as u32;
170 }
171
172 /// Sube la `view_proj` del frame. Llamar antes de [`Self::draw`].
173 pub fn upload(&self, queue: &wgpu::Queue, view_proj: Mat4) {
174 let mut b = Vec::with_capacity(64);
175 for v in view_proj.to_cols_array() {
176 b.extend_from_slice(&v.to_ne_bytes());
177 }
178 queue.write_buffer(&self.uniform_buf, 0, &b);
179 }
180
181 /// Dibuja en un pase ya abierto (con depth attachment). No-op sin vértices.
182 pub fn draw<'a>(&'a self, pass: &mut wgpu::RenderPass<'a>) {
183 let Some(vb) = self.verts.as_ref() else {
184 return;
185 };
186 if self.count < 2 {
187 return;
188 }
189 pass.set_pipeline(&self.pipeline);
190 pass.set_bind_group(0, &self.uniform_bg, &[]);
191 pass.set_vertex_buffer(0, vb.slice(..));
192 pass.draw(0..self.count, 0..1);
193 }
194}
195
196/// Atenúa el alpha por profundidad NDC (el fondo de la esfera se apaga → da
197/// volumen). El color va tal cual; sólo el alpha se modula.
198const LINES_WGSL: &str = r#"
199struct U { view_proj: mat4x4<f32> };
200@group(0) @binding(0) var<uniform> u: U;
201
202struct VIn {
203 @location(0) pos: vec3<f32>,
204 @location(1) color: vec4<f32>,
205};
206struct VOut {
207 @builtin(position) clip: vec4<f32>,
208 @location(0) color: vec4<f32>,
209 @location(1) ndc_z: f32,
210};
211
212@vertex
213fn vs(in: VIn) -> VOut {
214 var o: VOut;
215 o.clip = u.view_proj * vec4<f32>(in.pos, 1.0);
216 o.color = in.color;
217 o.ndc_z = o.clip.z / max(o.clip.w, 1e-6);
218 return o;
219}
220
221@fragment
222fn fs(in: VOut) -> @location(0) vec4<f32> {
223 // ndc_z ~0 = cerca, ~1 = lejos. El fondo se apaga hasta 0.55 (da volumen
224 // sin que el hemisferio trasero desaparezca).
225 let depth_fade = mix(1.0, 0.55, clamp(in.ndc_z, 0.0, 1.0));
226 return vec4<f32>(in.color.rgb, in.color.a * depth_fade);
227}
228"#;
229
230#[cfg(test)]
231mod tests {
232 use super::*;
233
234 #[test]
235 fn lines_wgsl_valida() {
236 let module = naga::front::wgsl::parse_str(LINES_WGSL).expect("LINES_WGSL no parsea");
237 naga::valid::Validator::new(
238 naga::valid::ValidationFlags::all(),
239 naga::valid::Capabilities::all(),
240 )
241 .validate(&module)
242 .expect("LINES_WGSL no valida");
243 }
244}