diff --git a/src-tauri/render3d/src/bin/spike3d.rs b/src-tauri/render3d/src/bin/spike3d.rs index 4b0aee7..848037a 100644 --- a/src-tauri/render3d/src/bin/spike3d.rs +++ b/src-tauri/render3d/src/bin/spike3d.rs @@ -13,13 +13,14 @@ use std::sync::Arc; -use render3d::gpu::Renderer; +use render3d::gpu::{RenderStyle, Renderer}; use render3d::math::orbit_eye; use render3d::types::{Camera, Projection, WallInput}; use winit::application::ApplicationHandler; -use winit::event::{ElementState, MouseButton, MouseScrollDelta, WindowEvent}; +use winit::event::{ElementState, KeyEvent, MouseButton, MouseScrollDelta, WindowEvent}; use winit::event_loop::{ActiveEventLoop, EventLoop}; +use winit::keyboard::{KeyCode, PhysicalKey}; use winit::window::{Window, WindowId}; /// Demo-Szene: ein rechteckiger Raum (4 Aussenwaende) plus eine Innenwand. Achsen @@ -196,6 +197,9 @@ struct App { orbit: Orbit, dragging: bool, last_cursor: (f64, f64), + /// Darstellung: false = Shaded (Default, unveraendert), true = Textured + /// (prozedurales Schachbrett auf den Wandflaechen). Per Taste `T` umschaltbar. + textured: bool, } impl ApplicationHandler for App { @@ -223,6 +227,27 @@ impl ApplicationHandler for App { state.resize(size.width, size.height); state.window.request_redraw(); } + WindowEvent::KeyboardInput { + event: + KeyEvent { + physical_key: PhysicalKey::Code(KeyCode::KeyT), + state: ElementState::Pressed, + repeat: false, + .. + }, + .. + } => { + // `T` schaltet Shaded <-> Textured um (Laufzeit, kein Re-Meshing: + // der Renderer haelt beide Vertex-Puffer bereit). + self.textured = !self.textured; + let style = if self.textured { + RenderStyle::Textured + } else { + RenderStyle::Shaded + }; + state.renderer.set_render_style(style); + state.window.request_redraw(); + } WindowEvent::MouseInput { state: s, button, .. } => { if button == MouseButton::Left { self.dragging = s == ElementState::Pressed; diff --git a/src-tauri/render3d/src/gpu.rs b/src-tauri/render3d/src/gpu.rs index 1d040ce..756af82 100644 --- a/src-tauri/render3d/src/gpu.rs +++ b/src-tauri/render3d/src/gpu.rs @@ -15,11 +15,13 @@ use wgpu::util::DeviceExt; use crate::edges::{build_mesh_edges, EDGE_FLOATS_PER_VERTEX}; use crate::grid::{build_ground_grid, GRID_FLOATS_PER_VERTEX}; use crate::math::{view_projection, Mat4}; -use crate::mesh::{build_scene_mesh, build_walls_mesh}; +use crate::mesh::{build_scene_mesh, build_walls_mesh, textured_from_mesh}; use crate::section::SectionPlane; use crate::section_fill::{build_cut_caps, CAP_FLOATS_PER_VERTEX}; -use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_WGSL}; -use crate::types::{Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX}; +use crate::shaders::{CAP_WGSL, GRID_WGSL, MESH_TEXTURED_WGSL, MESH_WGSL}; +use crate::types::{ + Camera, Mesh, MeshInput, SlabInput, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX, +}; /// Darstellungsart des 3D-Renderers (Oberleiste: shaded/white/textured/ /// wireframe/hidden). Bestimmt, welche Pipelines je Frame gezeichnet werden und @@ -30,7 +32,9 @@ pub enum RenderStyle { Shaded, /// Clay-Look: einheitlich helles Material, beleuchtet. White, - /// Mangels Textur-Pipeline VORERST identisch zu `Shaded` behandelt (kein Fake). + /// Echte Bild-Textur (prozedurales Schachbrett) auf den Wandflaechen, + /// beleuchtet wie `Shaded` (eigene Pipeline + Textur-Bind-Group group 1, + /// siehe `textured_pipeline`). Textured, /// Nur Modell-Kanten (Feature-Edges), keine gefuellten Flaechen. Wireframe, @@ -132,6 +136,55 @@ struct MeshBuffers { index_count: u32, } +/// GPU-seitige Puffer des TEXTURIERTEN Wand-Meshes (`RenderStyle::Textured`). +/// Eigenes Vertex-Layout `[pos vec3, normal vec3, uv vec2]` +/// (`TEXTURED_FLOATS_PER_VERTEX`), sonst wie `MeshBuffers`. Zweiter, additiver +/// Puffer neben `MeshBuffers` — der Alt-Pfad bleibt bitgleich unangetastet. +struct TexturedMeshBuffers { + vbo: wgpu::Buffer, + ibo: wgpu::Buffer, + index_count: u32, +} + +/// Kantenlaenge der prozeduralen Test-Textur (Schachbrett), in Texeln. +const TEXTURE_SIZE: u32 = 256; + +/// Erzeugt ein 256x256-RGBA-Schachbrett IM CODE (kein Asset, keine `image`-Crate): +/// 8x8 Felder abwechselnd hell/dunkel, mit einem duennen Raster als Kachel-Grenze. +/// Rueckgabe = flaches RGBA8-Byte-Array (`4 * TEXTURE_SIZE * TEXTURE_SIZE`), direkt +/// fuer `queue.write_texture` (Zeilen sind `4 * TEXTURE_SIZE` Byte breit, ohne +/// Padding, weil `256 * 4 = 1024` bereits ein Vielfaches von 256 ist). Bewusst +/// selbststaendig, damit der Spike ohne Dateipfade/Asset-Pipeline laeuft. +fn build_checker_texture() -> Vec { + let size = TEXTURE_SIZE as usize; + // 8 Felder je Kante -> jedes Feld 32 Texel breit. Das macht 1 Textur-Kachel + // (= 1 m Welt, siehe UV-Massstab) in ein 8x8-Schachbrett sichtbar. + let cell = size / 8; + let mut data = vec![0u8; size * size * 4]; + // Zwei Grautoene als Schachbrett + eine dunkle Rasterlinie an den Feldgrenzen. + let light = [220u8, 220, 224, 255]; + let dark = [150u8, 152, 160, 255]; + let line = [70u8, 72, 80, 255]; + for y in 0..size { + for x in 0..size { + let checker = ((x / cell) + (y / cell)) % 2 == 0; + // Duenne Rasterlinie (2 Texel) exakt auf den Feldkanten — betont die + // weltmassstaebliche Kachelung an Gehrungen/Ecken sichtbar. + let on_line = (x % cell) < 2 || (y % cell) < 2; + let px = if on_line { + line + } else if checker { + light + } else { + dark + }; + let o = (y * size + x) * 4; + data[o..o + 4].copy_from_slice(&px); + } + } + data +} + /// GPU-seitiger Vertex-Buffer einer LineList (kein Indexpuffer). Format /// [pos vec3, color vec3] — genutzt fuer das Bodengitter UND die Modell-Kanten /// (identisches Layout/Shader, siehe grid.rs / edges.rs). @@ -172,9 +225,29 @@ pub struct Renderer { /// Depth-Bias zur Kamera hin gegen Z-Fighting mit den an der Ebene /// per-`discard` gekappten Wandflaechen. Backface-Culling AUS (CullMode::None). cap_pipeline: wgpu::RenderPipeline, + /// Texturierte Wand-Pipeline (`RenderStyle::Textured`, TriangleList, + /// MESH_TEXTURED_WGSL, Vertex-Layout [pos vec3, normal vec3, uv vec2]). Bindet + /// group 0 (Globals) UND group 1 (Bild-Textur). Depth-/MSAA-/Farbziel + /// BITIDENTISCH zur Haupt-`pipeline` (sonst inkompatibler Render-Pass), nur + /// Shader/Layout unterscheiden sich. + textured_pipeline: wgpu::RenderPipeline, + /// Bind-Group der prozeduralen Test-Textur (group 1): Texture-View + Sampler. + /// Konstant ueber die Lebensdauer des Renderers (eine Textur fuer alle Waende). + texture_bind_group: wgpu::BindGroup, + /// Die (prozedurale) Textur selbst — nur festgehalten, um ihre Texel beim + /// ersten `render` einmalig hochzuladen (`texture_uploaded`), da `new` keine + /// Queue bekommt. + texture: wgpu::Texture, + /// Ob die Textur-Texel schon per `queue.write_texture` geladen wurden. Beim + /// ersten `render` einmalig gesetzt (lazy Upload). + texture_uploaded: bool, bind_group: wgpu::BindGroup, uniform: wgpu::Buffer, mesh: Option, + /// Texturiertes Wand-Mesh (`[pos, normal, uv]`) — zweiter, additiver Puffer + /// neben `mesh`. Nur im Stil `Textured` gezeichnet; bei `upload_*` zusammen mit + /// `mesh` erzeugt. None = kein Mesh geladen. + textured_mesh: Option, /// Modell-Kanten (Feature-Edges, edges.rs) als LineList. Einmalig bei /// `set_model`/`upload_*` erzeugt und gecacht (nicht je Frame). Gezeichnet in /// den Stilen wireframe/hidden. @@ -462,6 +535,144 @@ impl Renderer { cache: None, }); + // ── Texturierter Wand-Pfad (RenderStyle::Textured) ────────────────────── + // Bild-Textur-Bind-Group-Layout (group 1): sampelbare 2D-Float-Textur + + // Filter-Sampler. `Globals` bleibt group 0 (oben) UND unveraendert. + let texture_bind_group_layout = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("texture.layout"), + entries: &[ + 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, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + // Pipeline-Layout des texturierten Pfades: group 0 = Globals (wie oben), + // group 1 = Textur. Reihenfolge = Bindungs-Index, deshalb beide hier. + let textured_pipeline_layout = + device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("3d.textured.layout"), + bind_group_layouts: &[Some(&bind_group_layout), Some(&texture_bind_group_layout)], + immediate_size: 0, + }); + let textured_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("mesh_textured.wgsl"), + source: wgpu::ShaderSource::Wgsl(MESH_TEXTURED_WGSL.into()), + }); + // Vertex-Layout: [pos vec3, normal vec3, uv vec2], stride 8*4. + let textured_vertex_layout = wgpu::VertexBufferLayout { + array_stride: (TEXTURED_FLOATS_PER_VERTEX * 4) as u64, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &wgpu::vertex_attr_array![0 => Float32x3, 1 => Float32x3, 2 => Float32x2], + }; + // WICHTIG: Depth-Format, MSAA (`SAMPLE_COUNT`), Color-Target-Format, + // Topologie und Culling BITIDENTISCH zur Haupt-`pipeline` (make_mesh_pipeline) + // — die texturierten Waende laufen im GLEICHEN Render-Pass, deshalb muss der + // Pipeline-State kompatibel sein (sonst wgpu-Validierungspanik). Einziger + // Unterschied: Shader/Vertex-Layout und das zusaetzliche group-1-Layout. + let textured_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("mesh.textured.pipeline"), + layout: Some(&textured_pipeline_layout), + vertex: wgpu::VertexState { + module: &textured_module, + entry_point: Some("vs_main"), + buffers: &[textured_vertex_layout], + compilation_options: Default::default(), + }, + fragment: Some(wgpu::FragmentState { + module: &textured_module, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format: color_format, + blend: Some(wgpu::BlendState::REPLACE), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: Default::default(), + }), + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + front_face: wgpu::FrontFace::Ccw, + cull_mode: Some(wgpu::Face::Back), + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: DEPTH_FORMAT, + depth_write_enabled: Some(true), + depth_compare: Some(wgpu::CompareFunction::Less), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }), + multisample: wgpu::MultisampleState { + count: SAMPLE_COUNT, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }); + + // Prozedurale 256x256-Schachbrett-Textur ANLEGEN (kein Asset). RGBA8-srgb, + // damit die im Shader gesampelten Werte im linearen Raum landen (die + // Beleuchtung rechnet linear) — analog zum srgb-Surface-Format. Das + // Hochladen der Texel (queue.write_texture) passiert einmalig beim ersten + // `render` (siehe `texture_uploaded`), weil `new` bewusst KEINE Queue + // bekommt — die Signatur bleibt fuer die bestehenden Aufrufer (web.rs, + // spike3d) unveraendert. + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("checker.texture"), + size: wgpu::Extent3d { + width: TEXTURE_SIZE, + height: TEXTURE_SIZE, + 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: &[], + }); + let texture_view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + // Repeat (weltmassstaebliches UV kachelt) + Linear (weiche Filterung). + let sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("checker.sampler"), + address_mode_u: wgpu::AddressMode::Repeat, + address_mode_v: wgpu::AddressMode::Repeat, + address_mode_w: wgpu::AddressMode::Repeat, + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + mipmap_filter: wgpu::MipmapFilterMode::Nearest, + ..Default::default() + }); + let texture_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("texture.bind"), + layout: &texture_bind_group_layout, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(&texture_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&sampler), + }, + ], + }); + let uniform = device.create_buffer(&wgpu::BufferDescriptor { label: Some("globals.buffer"), size: std::mem::size_of::() as u64, @@ -483,9 +694,14 @@ impl Renderer { grid_pipeline, highlight_pipeline, cap_pipeline, + textured_pipeline, + texture_bind_group, + texture, + texture_uploaded: false, bind_group, uniform, mesh: None, + textured_mesh: None, edges: None, grid: None, highlight: None, @@ -529,6 +745,7 @@ impl Renderer { fn upload_mesh(&mut self, device: &wgpu::Device, mesh: Mesh) { if mesh.indices.is_empty() { self.mesh = None; + self.textured_mesh = None; self.edges = None; return; } @@ -564,6 +781,27 @@ impl Renderer { ibo, index_count: mesh.indices.len() as u32, }); + + // Texturiertes Pendant AUS DEMSELBEN `Mesh` ableiten (planare Welt-UV je + // Vertex statt Farbe, siehe `textured_from_mesh`) und in einen zweiten + // Vertex-Puffer hochladen. So bleibt die Geometrie bitgleich zum Alt-Pfad, + // und der Stil `Textured` hat ohne Re-Meshing seine Puffer bereit. + let tex_mesh = textured_from_mesh(&mesh); + let tvbo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh.textured.vbo"), + contents: bytemuck::cast_slice(&tex_mesh.verts), + usage: wgpu::BufferUsages::VERTEX, + }); + let tibo = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("mesh.textured.ibo"), + contents: bytemuck::cast_slice(&tex_mesh.indices), + usage: wgpu::BufferUsages::INDEX, + }); + self.textured_mesh = Some(TexturedMeshBuffers { + vbo: tvbo, + ibo: tibo, + index_count: tex_mesh.indices.len() as u32, + }); } /// Setzt die Richtung ZUM Directional-Light (Sonne, world). Das hemisphaerische @@ -783,6 +1021,33 @@ impl Renderer { self.globals.mode[0] = self.style.mesh_mode(); queue.write_buffer(&self.uniform, 0, bytemuck::bytes_of(&self.globals)); + // Prozedurale Schachbrett-Textur einmalig hochladen (lazy, weil `new` keine + // Queue bekommt — Signatur unveraendert). Danach steht sie fuer den Stil + // `Textured` bereit; kostet ab dem zweiten Frame nichts mehr. + if !self.texture_uploaded { + let texel_data = build_checker_texture(); + queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &self.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + &texel_data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(4 * TEXTURE_SIZE), + rows_per_image: Some(TEXTURE_SIZE), + }, + wgpu::Extent3d { + width: TEXTURE_SIZE, + height: TEXTURE_SIZE, + depth_or_array_layers: 1, + }, + ); + self.texture_uploaded = true; + } + self.ensure_depth(device, w, h); self.ensure_msaa(device, w, h); let depth_view = &self.depth.as_ref().unwrap().view; @@ -825,7 +1090,20 @@ impl Renderer { // Hidden-Line-Stil die leicht nach hinten gebiaste Pipeline, damit // die folgenden Kanten sauber obenauf liegen. if self.style.draws_faces() { - if let Some(m) = &self.mesh { + if self.style == RenderStyle::Textured { + // Texturierter Pfad: eigene Pipeline + zweiter Vertex-Puffer + // ([pos, normal, uv]) + zusaetzliche Textur-Bind-Group (group 1). + // Globals bleiben group 0. Fehlt der texturierte Puffer (kein + // Mesh geladen), wird nichts gezeichnet. + if let Some(t) = &self.textured_mesh { + pass.set_pipeline(&self.textured_pipeline); + pass.set_bind_group(0, &self.bind_group, &[]); + pass.set_bind_group(1, &self.texture_bind_group, &[]); + pass.set_vertex_buffer(0, t.vbo.slice(..)); + pass.set_index_buffer(t.ibo.slice(..), wgpu::IndexFormat::Uint32); + pass.draw_indexed(0..t.index_count, 0, 0..1); + } + } else if let Some(m) = &self.mesh { let face_pipeline = if self.style == RenderStyle::Hidden { &self.hidden_face_pipeline } else { diff --git a/src-tauri/render3d/src/lib.rs b/src-tauri/render3d/src/lib.rs index 7bbab5d..b89b78f 100644 --- a/src-tauri/render3d/src/lib.rs +++ b/src-tauri/render3d/src/lib.rs @@ -38,23 +38,27 @@ pub use math::{ view_matrix, view_projection, Mat4, }; pub use mesh::{ - append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh, extrude_slab, - extrude_wall, triangulate, + append_context_mesh, build_model_mesh, build_scene_mesh, build_walls_mesh, + build_walls_mesh_textured, extrude_slab, extrude_wall, textured_from_mesh, triangulate, }; pub use section::{ cut_section, ComponentKind, ComponentRef, CutPolygon, SectionEdge, SectionOutput, SectionPlane, }; pub use types::{ - Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput, WallInput, - FLOATS_PER_VERTEX, + Camera, CameraPreset, Mesh, MeshInput, MeshKind, Point2, Projection, Rgb, SlabInput, + TexturedMesh, WallInput, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX, }; // --- Tests: Mesh-Erzeugung (Muster wie render2d/tessellate) ------------------- #[cfg(test)] mod tests { - use super::mesh::{build_walls_mesh, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX}; - use super::types::{Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX}; + use super::mesh::{ + build_walls_mesh, build_walls_mesh_textured, extrude_wall, INDICES_PER_BOX, VERTS_PER_BOX, + }; + use super::types::{ + Hole, Mesh, Opening, WallInput, WallLayer, FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX, + }; /// Bequemer Bau einer achsparallelen Wand entlang +X. fn wall_x(len: f32, thickness: f32, height: f32) -> WallInput { @@ -80,6 +84,67 @@ mod tests { ) } + #[test] + fn texturierter_pfad_ist_geometrisch_deckungsgleich_mit_shaded() { + // Der texturierte Wand-Pfad (`build_walls_mesh_textured`, [pos,normal,uv]) + // muss GEOMETRISCH bitgleich zum Alt-Pfad (`build_walls_mesh`, + // [pos,normal,color]) sein: gleiche Vertex-/Index-Zahl und -Reihenfolge, + // identische Positionen und Normalen. Nur der letzte Kanal (Farbe -> UV) + // unterscheidet sich. So ist belegt, dass der additive UV-Kanal den + // Alt-Pfad nicht beruehrt. + let walls = [wall_x(3.0, 0.2, 2.5)]; + let shaded = build_walls_mesh(&walls); + let tex = build_walls_mesh_textured(&walls); + assert_eq!(tex.indices, shaded.indices, "Index-Puffer identisch"); + assert_eq!( + tex.vertex_count(), + shaded.vertex_count(), + "gleiche Vertex-Zahl" + ); + for i in 0..shaded.vertex_count() { + let sb = i * FLOATS_PER_VERTEX; + let tb = i * TEXTURED_FLOATS_PER_VERTEX; + // Position (0..3) und Normale (3..6) bitgleich uebernommen. + for k in 0..6 { + assert_eq!( + shaded.verts[sb + k], + tex.verts[tb + k], + "Vertex {i} Kanal {k}: pos/normal muss identisch sein" + ); + } + } + } + + #[test] + fn texturierte_uv_ist_weltmassstaeblich() { + // UV-Massstab: `u`/`v` in Metern (1 Kachel = 1 m). Fuer eine Wand entlang +X + // liegt die +Z-Mantelflaeche bei Normale (0,0,1); dort ist die Tangente + // t=(-n.z,0,n.x)=(-1,0,0), also u = -x und v = Hoehe. Die Deckflaeche + // (Normale +Y) projiziert auf u=x, v=z. Wir pruefen, dass es UEBERHAUPT + // Vertices mit v == Wandhoehe (2.5) bzw. v == 0 gibt (Hoehe wandert als v + // mit) — belegt den weltmassstaeblichen Hoehen-Kanal. + let tex = build_walls_mesh_textured(&[wall_x(3.0, 0.2, 2.5)]); + let n = tex.vertex_count(); + let mut saw_top = false; + let mut saw_bottom = false; + for i in 0..n { + let b = i * TEXTURED_FLOATS_PER_VERTEX; + let ny = tex.verts[b + 4]; + let v = tex.verts[b + 7]; + // Nur Mantelflaechen (ny ~ 0) tragen die Hoehe als v. + if ny.abs() < 0.5 { + if (v - 2.5).abs() < 1e-5 { + saw_top = true; + } + if v.abs() < 1e-5 { + saw_bottom = true; + } + } + } + assert!(saw_top, "Mantel-Vertex mit v == Wandhoehe (2.5 m) erwartet"); + assert!(saw_bottom, "Mantel-Vertex mit v == 0 (Sockel) erwartet"); + } + #[test] fn eine_wand_hat_quader_zaehlung() { // Eine Wand -> ein Quader: 24 Vertices, 36 Indizes (12 Dreiecke). @@ -927,5 +992,14 @@ mod tests { Validator::new(ValidationFlags::all(), Capabilities::all()) .validate(&cap_module) .unwrap_or_else(|e| panic!("cap: WGSL-Validierung fehlgeschlagen: {e:?}")); + + // Texturierter Wand-Shader (RenderStyle::Textured, Bild-Textur in group 1) + // ebenso headless validieren — faengt Textur-/Sampler-Bindungsfehler ab. + let tex_src = super::shaders::MESH_TEXTURED_WGSL; + let tex_module = naga::front::wgsl::parse_str(tex_src) + .unwrap_or_else(|e| panic!("textured: WGSL-Parse-Fehler: {e:?}")); + Validator::new(ValidationFlags::all(), Capabilities::all()) + .validate(&tex_module) + .unwrap_or_else(|e| panic!("textured: WGSL-Validierung fehlgeschlagen: {e:?}")); } } diff --git a/src-tauri/render3d/src/mesh.rs b/src-tauri/render3d/src/mesh.rs index a424eb1..1605f69 100644 --- a/src-tauri/render3d/src/mesh.rs +++ b/src-tauri/render3d/src/mesh.rs @@ -59,8 +59,8 @@ use std::collections::HashMap; use crate::openings::{split_range_by_voids, MIN_SPAN}; use crate::types::{ - Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, WallInput, WallLayer, - FLOATS_PER_VERTEX, + Hole, Mesh, MeshInput, Opening, Point2, Rgb, SlabInput, TexturedMesh, WallInput, WallLayer, + FLOATS_PER_VERTEX, TEXTURED_FLOATS_PER_VERTEX, }; /// Ein Quader-Mesh besteht aus 6 Seiten (Boden, Deckel, 4 Waende) zu je 2 @@ -917,6 +917,72 @@ pub fn build_walls_mesh(walls: &[WallInput]) -> Mesh { mesh } +/// TEXTURIERTER Wand-Pfad (`RenderStyle::Textured`): liefert DIESELBE Geometrie wie +/// `build_walls_mesh` — Positionen und Normalen bitgleich, gleiche Vertex-/Dreiecks- +/// Reihenfolge — aber mit einer planaren, WELTMASSSTAEBLICHEN UV je Vertex statt der +/// Bauteilfarbe. Bewusst ALS ABLEITUNG aus dem fertigen `Mesh` implementiert (nicht +/// als parallele Re-Extrusion): so bleibt die paritaets-/reihenfolgesensible +/// Geometrie (Gehrungen, Oeffnungen, Loecher, Schichten) die EINE Quelle der +/// Wahrheit und der Alt-Pfad `[pos, normal, color]` bleibt bitgleich unangetastet — +/// wir tauschen nur den color-Kanal gegen den aus Position+Normale abgeleiteten +/// UV-Kanal aus. +/// +/// UV-PROJEKTION (planar, in Metern, damit das Textur-Raster weltmassstaeblich ist, +/// z. B. 1 Kachel = 1 m; kein Verzug an Gehrungen/Ecken, weil jede Flaeche planar +/// projiziert wird und benachbarte Waende dieselbe Welt-Projektion fortsetzen — +/// die Kacheln stossen im Weltraum sauber aneinander): +/// - Senkrechte Flaechen (Wand-Mantel, Normale ~ horizontal): `u` = Weglaenge +/// entlang der horizontalen Flaechen-Tangente `t = (-n.z, 0, n.x)` +/// (`u = dot(pos.xz, t.xz)`), `v` = Hoehe `pos.y`. +/// - Waagerechte Flaechen (Deckel/Boden, Normale ~ +/-Y): direkt aus dem +/// Grundriss `u = pos.x`, `v = pos.z`. +/// Die Wahl der Achse allein aus der Flaechen-Normale (statt aus der Wand-Achse) +/// haelt die Projektion rein lokal je Vertex — das ist genau der Grund, warum sie +/// sich verlustfrei aus dem fertigen `Mesh` ableiten laesst. +pub fn build_walls_mesh_textured(walls: &[WallInput]) -> TexturedMesh { + textured_from_mesh(&build_walls_mesh(walls)) +} + +/// Rechnet ein `Mesh` (`[pos, normal, color]`) in einen `TexturedMesh` +/// (`[pos, normal, uv]`) um: Positionen/Normalen/Indizes werden 1:1 uebernommen, +/// die Farbe je Vertex wird durch die planare Welt-UV ersetzt (siehe +/// `build_walls_mesh_textured` fuer die Achswahl). Indizes bleiben unveraendert, +/// weil sich die Vertex-Anzahl/-Reihenfolge nicht aendert. `pub`, damit die +/// GPU-Schicht (`gpu.rs`) das texturierte Pendant AUS DEMSELBEN `Mesh` ableiten +/// kann, das sie ohnehin schon gebaut/hochgeladen hat (eine Geometrie-Quelle). +pub fn textured_from_mesh(mesh: &Mesh) -> TexturedMesh { + let n = mesh.vertex_count(); + let mut verts: Vec = Vec::with_capacity(n * TEXTURED_FLOATS_PER_VERTEX); + for i in 0..n { + let b = i * FLOATS_PER_VERTEX; + let px = mesh.verts[b]; + let py = mesh.verts[b + 1]; + let pz = mesh.verts[b + 2]; + let nx = mesh.verts[b + 3]; + let ny = mesh.verts[b + 4]; + let nz = mesh.verts[b + 5]; + // Waagerecht (Deckel/Boden) vs. senkrecht (Mantel) an der Vertikal- + // Komponente der Normale unterscheiden. Waende sind vertikal extrudiert, + // ihre Mantelnormale ist streng horizontal (ny == 0) — die Schwelle 0.5 + // trennt sauber Deckel/Boden (|ny| == 1) von Mantelflaechen (ny == 0). + let (u, v) = if ny.abs() > 0.5 { + // Grundriss-Projektion: u = x, v = z (Meter). + (px, pz) + } else { + // Horizontale Flaechen-Tangente t = (-n.z, 0, n.x): u = Weglaenge + // entlang t, v = Hoehe. `t` ist ein Einheitsvektor, weil (n.x, n.z) + // fuer Mantelflaechen normiert ist. + let u = px * (-nz) + pz * nx; + (u, py) + }; + verts.extend_from_slice(&[px, py, pz, nx, ny, nz, u, v]); + } + TexturedMesh { + verts, + indices: mesh.indices.clone(), + } +} + /// Baut das volle Modell-Mesh: erst die Waende (Quader), dann die Deckenplatten /// (extrudierte Polygone) — alles in EINEN Puffer. Die Wand-Reihenfolge bleibt /// vorne (deterministische Zaehlung fuer die Wand-Tests). diff --git a/src-tauri/render3d/src/shaders.rs b/src-tauri/render3d/src/shaders.rs index 89a1c8c..cb7b21f 100644 --- a/src-tauri/render3d/src/shaders.rs +++ b/src-tauri/render3d/src/shaders.rs @@ -125,6 +125,89 @@ fn fs_main(in : VsOut) -> @location(0) vec4 { } "#; +/// WGSL des TEXTURIERTEN Wand-Pfades (`RenderStyle::Textured`, gpu::textured_pipeline). +/// GLEICHE Beleuchtung wie `MESH_WGSL` (hemisphaerisches Ambient Himmel/Boden + +/// Lambert-Directional + Gegen-Fuelllicht + Kantenbetonung) — nur die ALBEDO kommt +/// aus einer Bild-Textur (`textureSample`) statt aus der Vertexfarbe. Vertex-Layout +/// [pos vec3, normal vec3, uv vec2]; `Globals` bleibt group(0) binding(0) unveraendert, +/// die Textur haengt in group(1). Die Weiss-/Hidden-Modi (`mode.x`) sind hier nicht +/// relevant: `Textured` reicht `mode.x == 0` (Material) durch (siehe gpu.rs). +pub const MESH_TEXTURED_WGSL: &str = r#" +struct Globals { + view_proj : mat4x4, + light_dir : vec4, + sky_color : vec4, + ground_color : vec4, + sun_color : vec4, + mode : vec4, + section_plane : vec4, +}; +@group(0) @binding(0) var globals : Globals; + +// Bild-Textur + Sampler (group 1). Sampler mit Repeat/Linear (siehe gpu.rs) -> +// das weltmassstaebliche UV-Raster kachelt sich ueber die Wandflaechen. +@group(1) @binding(0) var tex : texture_2d; +@group(1) @binding(1) var samp : sampler; + +struct VsIn { + @location(0) position : vec3, + @location(1) normal : vec3, + @location(2) uv : vec2, +}; + +struct VsOut { + @builtin(position) clip_pos : vec4, + @location(0) world_normal : vec3, + @location(1) uv : vec2, + @location(2) world_pos : vec3, +}; + +@vertex +fn vs_main(in : VsIn) -> VsOut { + var out : VsOut; + out.clip_pos = globals.view_proj * vec4(in.position, 1.0); + out.world_normal = in.normal; + out.uv = in.uv; + out.world_pos = in.position; + return out; +} + +@fragment +fn fs_main(in : VsOut) -> @location(0) vec4 { + // Live-Schnitt-Kappung identisch zur Mesh-Pipeline (mode.y > 0.5 = aktiv). + if (globals.mode.y > 0.5) { + if (dot(in.world_pos, globals.section_plane.xyz) + globals.section_plane.w > 0.0) { + discard; + } + } + let n = normalize(in.world_normal); + let l = normalize(globals.light_dir.xyz); + + // Hemisphaerisches Ambient (identisch zu MESH_WGSL). + let hemi_t = clamp(n.y * 0.5 + 0.5, 0.0, 1.0); + let ambient = mix(globals.ground_color.rgb, globals.sky_color.rgb, hemi_t); + + // Gerichteter Lambert-Anteil (Sonne). + let diffuse = max(dot(n, l), 0.0) * globals.sun_color.rgb; + + // Gegen-Fuelllicht (identisch zu MESH_WGSL). + let fill_l = normalize(vec3(-l.x, abs(l.y) * 0.35 + 0.15, -l.z)); + let fill = max(dot(n, fill_l), 0.0) * globals.sun_color.rgb + * vec3(0.24, 0.27, 0.30); + + // EINZIGER Unterschied zu MESH_WGSL: Albedo aus der Bild-Textur statt Vertexfarbe. + let albedo = textureSample(tex, samp, in.uv).rgb; + + var shaded = albedo * (ambient + diffuse + fill); + + // Sanfte Kantenbetonung (identisch zu MESH_WGSL). + let edge = 0.90 + 0.10 * abs(n.y); + shaded = shaded * edge; + + return vec4(shaded, 1.0); +} +"#; + /// WGSL des Referenz-Bodengitters (grid.rs). Eigene, schlanke `LineList`-Pipeline: /// KONSTANTE Farbe (unlit — unabhaengig von Normalen/Licht), nur View-Projektion /// aus demselben `Globals`-Uniform (group(0) binding(0)) wie die Mesh-Pipeline. diff --git a/src-tauri/render3d/src/types.rs b/src-tauri/render3d/src/types.rs index 1a6e5e6..032184e 100644 --- a/src-tauri/render3d/src/types.rs +++ b/src-tauri/render3d/src/types.rs @@ -333,6 +333,30 @@ pub struct Mesh { /// Anzahl f32 je Vertex im interleaved Puffer: 3 Position + 3 Normale + 3 Farbe. pub const FLOATS_PER_VERTEX: usize = 9; +/// Ausgabe der TEXTURIERTEN Mesh-Erzeugung (`RenderStyle::Textured`): interleaved +/// [pos.xyz, normal.xyz, uv.xy] je Vertex plus Indexpuffer. Ein ZWEITER, additiver +/// Vertex-Pfad neben `Mesh` — der bestehende `[pos, normal, color]`-Pfad (`Mesh`) +/// bleibt dadurch bitgleich unangetastet. Genutzt nur mit den GPU-Features +/// (`render`/`window`); serde-frei (reine GPU-Zwischenform), damit der Default-Bau +/// ohne GPU nichts davon zieht. +#[derive(Debug, Clone, Default)] +pub struct TexturedMesh { + /// Interleaved Vertices: je 8 f32 = [px,py,pz, nx,ny,nz, u,v]. + pub verts: Vec, + /// Dreiecks-Indizes (0-basiert auf die Vertices). + pub indices: Vec, +} + +/// Anzahl f32 je Vertex im texturierten Puffer: 3 Position + 3 Normale + 2 UV. +pub const TEXTURED_FLOATS_PER_VERTEX: usize = 8; + +impl TexturedMesh { + /// Anzahl Vertices im Puffer. + pub fn vertex_count(&self) -> usize { + self.verts.len() / TEXTURED_FLOATS_PER_VERTEX + } +} + impl Mesh { /// Anzahl Vertices im Puffer. pub fn vertex_count(&self) -> usize {