From a4fc34c6a29db4c81891ffdc7235eeb1c0b21f9d Mon Sep 17 00:00:00 2001 From: Hakan Seven Date: Tue, 14 Jul 2026 00:53:51 +0300 Subject: [PATCH] feat(web): render hatches on WebGL2 via a texture-backed renderer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer hatch renderer (hatch_gpu) is disabled on wasm and real hatch fills never rendered on the web build — the pick highlighted the boundary and the command then appeared to cancel (issue #204). Add a wasm-only per-hatch renderer (hatch_web_gpu + hatch_web.wgsl) that packs the variable-length boundary / family / dash arrays into one RGBA32F data texture read with textureLoad, instead of the fixed-size uniforms of the wipeout path. This removes the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS (1024) / MAX_DASHES (128) caps, so every hatch — solid, gradient, and arbitrarily complex line patterns — renders on the web. The fill algorithm (in_polygon, per-family line/dash/dot, gradient, LOD, double-single vertex transform) is identical to the desktop path; only the array ACCESS changes. Pattern phase is anchored at world_origin with the boundary stored raw, and scale / gradient-range clamped, to match the desktop batched renderer exactly. Native (hatch_gpu + hatch.wgsl) and the wipeout mask renderer are untouched; the new module and pipeline are gated on target_arch = "wasm32". Also silence the resulting (and pre-existing) wasm-only dead-code warnings for native-only paths so both the native and web builds compile clean. Closes #204. Co-Authored-By: Claude Opus 4.8 --- src/app/mod.rs | 2 + src/io/file_association.rs | 1 + src/io/mod.rs | 1 + src/io/print_to_printer.rs | 1 + src/io/thumbnail.rs | 1 + src/scene/entity.rs | 1 + src/scene/pipeline/hatch_gpu.rs | 4 + src/scene/pipeline/hatch_web_gpu.rs | 338 ++++++++++++++++++++++++++++ src/scene/pipeline/mod.rs | 147 +++++++++++- src/scene/view/render.rs | 4 +- src/shaders/hatch_web.wgsl | 258 +++++++++++++++++++++ 11 files changed, 745 insertions(+), 13 deletions(-) create mode 100644 src/scene/pipeline/hatch_web_gpu.rs create mode 100644 src/shaders/hatch_web.wgsl diff --git a/src/app/mod.rs b/src/app/mod.rs index de8a2db7c..e7cedeed4 100644 --- a/src/app/mod.rs +++ b/src/app/mod.rs @@ -2448,6 +2448,7 @@ impl OpenCADStudio { } /// Install `cmd` as the active interactive command for tab `tab`. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn set_active_command( &mut self, tab: usize, @@ -2459,6 +2460,7 @@ impl OpenCADStudio { } /// Push an error message from the plugin runtime to the command line. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub(crate) fn push_plugin_error(&mut self, msg: &str) { self.command_line.push_error(msg); } diff --git a/src/io/file_association.rs b/src/io/file_association.rs index 641dbdc56..8d2514e51 100644 --- a/src/io/file_association.rs +++ b/src/io/file_association.rs @@ -86,6 +86,7 @@ pub fn unregister_handler() -> Result<(), String> { /// freedesktop `.thumbnailer` (pointing at this binary's `--dwg-thumbnail` mode) /// plus the mimetype icons under `$XDG_DATA_HOME`; on Windows it registers the /// bundled thumbnail-provider DLL. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn install_thumbnailer() { #[cfg(target_os = "linux")] { diff --git a/src/io/mod.rs b/src/io/mod.rs index 999d137f9..b53014a22 100644 --- a/src/io/mod.rs +++ b/src/io/mod.rs @@ -346,6 +346,7 @@ pub fn dropped_on_save_count( /// Before overwriting `path`, copy the existing file to a sibling `.bak` /// so a faulty or accidental save can be recovered (#205). Best-effort: a /// failed backup never blocks the save itself. +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn write_backup(path: &std::path::Path) { if path.exists() { let _ = std::fs::copy(path, path.with_extension("bak")); diff --git a/src/io/print_to_printer.rs b/src/io/print_to_printer.rs index c597b6152..cb9956805 100644 --- a/src/io/print_to_printer.rs +++ b/src/io/print_to_printer.rs @@ -18,6 +18,7 @@ use crate::scene::WireModel; /// "printto" shell verb); copies / quality / colour need the driver DEVMODE /// and are ignored there. #[derive(Debug, Clone, Default)] +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub struct PrintOptions { /// Target printer name, or `None` for the system default. pub printer: Option, diff --git a/src/io/thumbnail.rs b/src/io/thumbnail.rs index f3dca5ae9..878cad051 100644 --- a/src/io/thumbnail.rs +++ b/src/io/thumbnail.rs @@ -123,6 +123,7 @@ fn to_rgb(c: [f32; 4]) -> [u8; 3] { /// generic icon. Backs the hidden `--dwg-thumbnail` mode the installed /// freedesktop `.thumbnailer` invokes. Extraction lives in the shared /// [`dwg_thumbnailer`] core (also used by the Windows/macOS handlers). +#[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn extract_to_png(input: &std::path::Path, output: &std::path::Path, size: u32) -> bool { match dwg_thumbnailer::extract(input, size) { Some(mut img) => { diff --git a/src/scene/entity.rs b/src/scene/entity.rs index bf2cfd239..22d131b29 100644 --- a/src/scene/entity.rs +++ b/src/scene/entity.rs @@ -145,6 +145,7 @@ impl Scene { /// entity has that handle. This is the in-place counterpart to /// [`add_entity`](Self::add_entity) used to commit a plugin's edit of an /// existing entity. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] pub fn update_entity(&mut self, mut entity: EntityType) -> bool { let handle = entity.common().handle; let Some(existing) = self.document.get_entity(handle) else { diff --git a/src/scene/pipeline/hatch_gpu.rs b/src/scene/pipeline/hatch_gpu.rs index 5644b786d..8500ee5ff 100644 --- a/src/scene/pipeline/hatch_gpu.rs +++ b/src/scene/pipeline/hatch_gpu.rs @@ -1,3 +1,7 @@ +// The whole batched renderer is native-only: WebGL2 lacks storage buffers, so +// on wasm hatches go through `hatch_web_gpu` and nothing here is called. +#![cfg_attr(target_arch = "wasm32", allow(dead_code))] + // Hatch rendering — the single, canonical GPU hatch renderer. One draw // call for all hatches, per-instance data fetched from storage buffers in // the vertex shader. There is NO per-family cap here: every pattern line diff --git a/src/scene/pipeline/hatch_web_gpu.rs b/src/scene/pipeline/hatch_web_gpu.rs new file mode 100644 index 000000000..be1327270 --- /dev/null +++ b/src/scene/pipeline/hatch_web_gpu.rs @@ -0,0 +1,338 @@ +// WebGL2 hatch renderer — texture-backed, UNCAPPED (wasm only). +// +// WebGL2 has no storage buffers, so the batched storage-buffer renderer +// (hatch_gpu.rs + hatch.wgsl) is disabled on wasm and real hatch fills never +// rendered on the web build (issue #204). This per-hatch renderer reuses the +// WebGL2-safe hatch algorithm (see wipeout.wgsl / hatch_web.wgsl) but packs the +// variable-length boundary / family / dash arrays into ONE RGBA32F data texture +// read via textureLoad — removing the MAX_FAMILIES / MAX_HATCH_BOUNDARY_VERTS / +// MAX_DASHES caps of the uniform (WipeoutGpu) path. Every hatch type — solid, +// gradient, and arbitrarily complex line patterns — renders on the web. +// +// Native (hatch_gpu.rs) and the wipeout mask renderer (wipeout_gpu.rs) are +// untouched; this module is compiled only for wasm32. + +use crate::scene::model::hatch_model::{HatchModel, HatchPattern}; +use iced::wgpu; +use iced::wgpu::util::DeviceExt; + +/// Width (in texels) of the RGBA32F data texture. Height grows to fit; a hatch +/// with N total texels uses ceil(N / WIDTH) rows. +const DATA_TEX_WIDTH: u32 = 1024; + +// ── Vertex ──────────────────────────────────────────────────────────────── + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct HatchVertex { + pos: [f32; 3], + _pad: f32, +} + +// ── Per-hatch uniform (binding 0) — 96 bytes, matches HatchUniforms in +// hatch_web.wgsl. ───────────────────────────────────────────────────────── + +#[repr(C)] +#[derive(Copy, Clone, bytemuck::Pod, bytemuck::Zeroable)] +struct HatchWebUniform { + color: [f32; 4], // 0 + color2: [f32; 4], // 16 + mode: u32, // 32 + vcount: u32, // 36 + angle_offset: f32, // 40 + scale: f32, // 44 + grad_cos: f32, // 48 + grad_sin: f32, // 52 + grad_min: f32, // 56 + grad_range: f32, // 60 + origin: [f32; 2], // 64 + origin_low: [f32; 2], // 72 + n_families: u32, // 80 + fam_off: u32, // 84: texel offset of the family section + dash_off: u32, // 88: texel offset of the dash section + tex_width: u32, // 92 +} + +// ── Per-hatch GPU handle ──────────────────────────────────────────────────── + +pub struct HatchWebGpu { + pub vertex_buffer: wgpu::Buffer, + pub bind_group: wgpu::BindGroup, + /// Reserved for viewport-clip scissor / per-frame AABB LOD (mirrors + /// `WipeoutGpu`); not yet wired into the web hatch draw loop — the native + /// batched path doesn't do per-hatch scissor/LOD either. + #[allow(dead_code)] + pub vp_scissor: Option<[f32; 4]>, + #[allow(dead_code)] + pub world_aabb: [f32; 4], + _uniform_buf: wgpu::Buffer, + _data_tex: wgpu::Texture, +} + +impl HatchWebGpu { + /// Group-1 layout: uniform header (binding 0) + non-filterable float data + /// texture (binding 1). + pub fn bind_group_layout(device: &wgpu::Device) -> wgpu::BindGroupLayout { + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("hatch_web.bgl1"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX | wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: None, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: false }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + ], + }) + } + + pub fn new( + device: &wgpu::Device, + queue: &wgpu::Queue, + model: &HatchModel, + bgl1: &wgpu::BindGroupLayout, + ) -> Self { + // ── Decode pattern mode (mirrors WipeoutGpu::new) ───────────────── + let (mode, color2, grad_cos, grad_sin) = match &model.pattern { + HatchPattern::Solid => (1u32, [0.0f32; 4], 0.0f32, 0.0f32), + HatchPattern::Pattern(_) => (0u32, [0.0f32; 4], 0.0f32, 0.0f32), + HatchPattern::Gradient { angle_deg, color2 } => { + let r = angle_deg.to_radians(); + (2u32, *color2, r.cos(), r.sin()) + } + }; + + // ── Bounding box ───────────────────────────────────────────────── + let (mut min_x, mut max_x, mut min_y, mut max_y) = + (f32::INFINITY, f32::NEG_INFINITY, f32::INFINITY, f32::NEG_INFINITY); + for &[x, y] in model.boundary.iter() { + if !x.is_finite() || !y.is_finite() { + continue; + } + min_x = min_x.min(x); + max_x = max_x.max(x); + min_y = min_y.min(y); + max_y = max_y.max(y); + } + + let max_spacing = match &model.pattern { + HatchPattern::Pattern(families) => { + families.iter().map(|f| f.dy.abs()).fold(0.0f32, f32::max) + } + _ => 5.0, + }; + let diag = ((max_x - min_x).powi(2) + (max_y - min_y).powi(2)).sqrt(); + let pad = (diag * 0.8 + max_spacing * 2.0 * model.scale).max(1.0); + + // Anchor pattern phase at `world_origin` with the boundary stored raw, + // matching the desktop batched renderer (hatch_gpu.rs) — NOT WipeoutGpu, + // whose f64 origin grid-snap is dead code (wipeouts are always solid) + // and would phase-shift every line pattern relative to desktop. No drift. + let origin = model.world_origin; + let drift = [0.0f32, 0.0f32]; + let (x0, x1, y0, y1) = ( + min_x + drift[0] - pad, + max_x + drift[0] + pad, + min_y + drift[1] - pad, + max_y + drift[1] + pad, + ); + + let quad = [ + HatchVertex { pos: [x0, y0, 0.0], _pad: 0.0 }, + HatchVertex { pos: [x1, y0, 0.0], _pad: 0.0 }, + HatchVertex { pos: [x1, y1, 0.0], _pad: 0.0 }, + HatchVertex { pos: [x0, y0, 0.0], _pad: 0.0 }, + HatchVertex { pos: [x1, y1, 0.0], _pad: 0.0 }, + HatchVertex { pos: [x0, y1, 0.0], _pad: 0.0 }, + ]; + let vertex_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hatch_web.vbuf"), + contents: bytemuck::cast_slice(&quad), + usage: wgpu::BufferUsages::VERTEX, + }); + + // ── Gradient projection range (snapped-local space) ─────────────── + let (grad_min, grad_range) = if mode == 2 { + let projs: Vec = model + .boundary + .iter() + .filter(|v| v[0].is_finite() && v[1].is_finite()) + .map(|&[x, y]| (x + drift[0]) * grad_cos + (y + drift[1]) * grad_sin) + .collect(); + if projs.is_empty() { + (0.0, 1.0) + } else { + let proj_min = projs.iter().cloned().fold(f32::INFINITY, f32::min); + let proj_max = projs.iter().cloned().fold(f32::NEG_INFINITY, f32::max); + // Floor matches the desktop hatch renderer (hatch_gpu.rs). + (proj_min, (proj_max - proj_min).max(1.0)) + } + } else { + (0.0, 1.0) + }; + + // ── Pack the data texture: boundary | families | dashes ─────────── + let mut texels: Vec<[f32; 4]> = Vec::new(); + // Boundary section (texels 0..vcount). NaN separators preserved (they + // survive the RGBA32F fetch byte-for-byte). + for &[x, y] in model.boundary.iter() { + if x.is_finite() && y.is_finite() { + texels.push([x + drift[0], y + drift[1], 0.0, 0.0]); + } else { + texels.push([f32::NAN, f32::NAN, 0.0, 0.0]); + } + } + let vcount = texels.len() as u32; + + // Family section (3 texels each) + a flat dash pool. + let fam_off = texels.len() as u32; + let mut n_families = 0u32; + let mut dash_pool: Vec = Vec::new(); + if let HatchPattern::Pattern(families) = &model.pattern { + for fam in families.iter() { + let dash_rel = dash_pool.len() as u32; + dash_pool.extend_from_slice(&fam.dashes); + let n_dashes = fam.dashes.len() as u32; + let period: f32 = if n_dashes > 0 { + fam.dashes.iter().map(|d| d.abs()).sum() + } else { + 0.0 + }; + let angle_r = fam.angle_deg.to_radians(); + // QCAD PAT convention: perp_step = dy, along_step = dx. + texels.push([angle_r.cos(), angle_r.sin(), fam.x0, fam.y0]); + texels.push([fam.dx, fam.dy, fam.dy, fam.dx]); + // Counts as exact f32 (small integers → no denormal/bitcast risk). + texels.push([0.0, period, n_dashes as f32, dash_rel as f32]); + n_families += 1; + } + } + + // Dash section (4 values per texel). + let dash_off = texels.len() as u32; + let mut i = 0usize; + while i < dash_pool.len() { + let mut t = [0.0f32; 4]; + for (c, slot) in t.iter_mut().enumerate() { + if let Some(&d) = dash_pool.get(i + c) { + *slot = d; + } + } + texels.push(t); + i += 4; + } + + // Upload the texture (min 1 texel; pad the last row). + if texels.is_empty() { + texels.push([0.0; 4]); + } + let width = DATA_TEX_WIDTH; + let height = ((texels.len() as u32).div_ceil(width)).max(1); + texels.resize((width * height) as usize, [0.0; 4]); + let data_tex = device.create_texture(&wgpu::TextureDescriptor { + label: Some("hatch_web.data_tex"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba32Float, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + queue.write_texture( + data_tex.as_image_copy(), + bytemuck::cast_slice(&texels), + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(width * 16), + rows_per_image: Some(height), + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + let tex_view = data_tex.create_view(&wgpu::TextureViewDescriptor::default()); + + // ── Uniform header ──────────────────────────────────────────────── + let uniform_data = HatchWebUniform { + color: model.color, + color2, + mode, + vcount, + angle_offset: model.angle_offset, + // Clamp like the desktop renderer so scale==0 can't make perp_step 0 + // → round(perp/0)=NaN → an invisible hatch. + scale: model.scale.max(1e-6), + grad_cos, + grad_sin, + grad_min, + grad_range, + origin: [origin[0] as f32, origin[1] as f32], + origin_low: [ + (origin[0] - origin[0] as f32 as f64) as f32, + (origin[1] - origin[1] as f32 as f64) as f32, + ], + n_families, + fam_off, + dash_off, + tex_width: width, + }; + let _uniform_buf = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { + label: Some("hatch_web.uniform"), + contents: bytemuck::bytes_of(&uniform_data), + usage: wgpu::BufferUsages::UNIFORM, + }); + + let bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("hatch_web.bind_group1"), + layout: bgl1, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: _uniform_buf.as_entire_binding(), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::TextureView(&tex_view), + }, + ], + }); + + let ox = model.world_origin[0] as f32; + let oy = model.world_origin[1] as f32; + let world_aabb = if min_x.is_finite() && min_y.is_finite() { + [min_x + ox, min_y + oy, max_x + ox, max_y + oy] + } else { + [min_x, min_y, max_x, max_y] + }; + + Self { + vertex_buffer, + bind_group, + vp_scissor: model.vp_scissor, + world_aabb, + _uniform_buf, + _data_tex: data_tex, + } + } +} diff --git a/src/scene/pipeline/mod.rs b/src/scene/pipeline/mod.rs index f12b1b799..d70b53f6a 100644 --- a/src/scene/pipeline/mod.rs +++ b/src/scene/pipeline/mod.rs @@ -1,5 +1,9 @@ pub mod face3d_gpu; pub mod hatch_gpu; +/// WebGL2 texture-backed hatch renderer (wasm only) — the storage-buffer +/// `hatch_gpu` path is unavailable on WebGL2, so hatches render through this. +#[cfg(target_arch = "wasm32")] +pub mod hatch_web_gpu; pub mod wipeout_gpu; pub mod image_gpu; pub mod mesh_gpu; @@ -47,6 +51,12 @@ pub struct Pipeline { /// hatch in the frame. `None` on WebGL2 (wasm), which lacks /// vertex-stage storage buffers. hatch_pipeline: Option, + /// WebGL2 (wasm) texture-backed hatch pipeline + its group-1 layout. Used + /// where `hatch_pipeline` is `None` because WebGL2 lacks storage buffers. + #[cfg(target_arch = "wasm32")] + hatch_web_bgl1: wgpu::BindGroupLayout, + #[cfg(target_arch = "wasm32")] + hatch_web_pipeline: wgpu::RenderPipeline, image_pipeline: wgpu::RenderPipeline, /// SDF text-quad pipeline (Phase 2b): draws per-glyph quads sampling the /// shared glyph atlas. Fed only when `OCS_TEXT_SDF` is set (else no verts). @@ -74,7 +84,9 @@ pub struct Pipeline { uniform_bind_group: wgpu::BindGroup, wipeout_bgl1: wgpu::BindGroupLayout, /// Group-1 layout for the batched hatch pipeline (storage buffers - /// for instances / boundary / families / dashes). `None` on WebGL2. + /// for instances / boundary / families / dashes). `None` on WebGL2, where + /// hatches render through `hatch_web_gpu` and this is never read. + #[cfg_attr(target_arch = "wasm32", allow(dead_code))] hatch_bgl1: Option, image_bgl1: wgpu::BindGroupLayout, /// Group-1 layout for the text pipeline (atlas texture + sampler). @@ -137,6 +149,10 @@ pub struct Pipeline { /// call with per-instance visibility masking the rest. All pattern line /// families are uploaded (no cap). `None` on WebGL2. gpu_hatch: Option, + /// WebGL2 hatch fills — one texture-backed `HatchWebGpu` per hatch, drawn in + /// the hatch pass where the batched `gpu_hatch` is `None` (wasm). + #[cfg(target_arch = "wasm32")] + gpu_hatches_web: Vec, /// Wipeout masks — solid fills rendered after wires in a separate pass via /// the legacy per-primitive `WipeoutGpu` renderer. gpu_wipeouts: Vec, @@ -579,6 +595,77 @@ impl Pipeline { (Some(hatch_bgl1), Some(hatch_pipeline)) }; + // WebGL2 hatch pipeline: texture-backed, replaces the storage-buffer + // batched path (disabled above on wasm). Same frame layout / depth / + // MSAA state as the wipeout pass. + #[cfg(target_arch = "wasm32")] + let (hatch_web_bgl1, hatch_web_pipeline) = { + let bgl1 = hatch_web_gpu::HatchWebGpu::bind_group_layout(device); + let layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some("hatch_web.pipeline_layout"), + bind_group_layouts: &[&frame_bgl, &bgl1], + push_constant_ranges: &[], + }); + let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("hatch_web.shader"), + source: wgpu::ShaderSource::Wgsl(std::borrow::Cow::Borrowed(include_str!( + "../../shaders/hatch_web.wgsl" + ))), + }); + let pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some("hatch_web.pipeline"), + layout: Some(&layout), + vertex: wgpu::VertexState { + module: &shader, + entry_point: Some("vs_main"), + buffers: &[wgpu::VertexBufferLayout { + array_stride: 16, + step_mode: wgpu::VertexStepMode::Vertex, + attributes: &[wgpu::VertexAttribute { + offset: 0, + shader_location: 0, + format: wgpu::VertexFormat::Float32x3, + }], + }], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + primitive: wgpu::PrimitiveState { + topology: wgpu::PrimitiveTopology::TriangleList, + cull_mode: None, + ..Default::default() + }, + depth_stencil: Some(wgpu::DepthStencilState { + format: wgpu::TextureFormat::Depth32Float, + depth_write_enabled: true, + depth_compare: wgpu::CompareFunction::LessEqual, + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState { + constant: 1, + slope_scale: 1.0, + clamp: 0.0, + }, + }), + multisample: wgpu::MultisampleState { + count: MSAA_SAMPLES, + mask: !0, + alpha_to_coverage_enabled: false, + }, + fragment: Some(wgpu::FragmentState { + module: &shader, + entry_point: Some("fs_main"), + targets: &[Some(wgpu::ColorTargetState { + format, + blend: Some(wgpu::BlendState::ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + multiview: None, + cache: None, + }); + (bgl1, pipeline) + }; + // ── Mesh pipeline ────────────────────────────────────────────────── let mesh_shader = device.create_shader_module(wgpu::ShaderModuleDescriptor { label: Some("mesh.shader"), @@ -1167,6 +1254,10 @@ impl Pipeline { wire_const_bgl: None, wipeout_pipeline, hatch_pipeline, + #[cfg(target_arch = "wasm32")] + hatch_web_bgl1, + #[cfg(target_arch = "wasm32")] + hatch_web_pipeline, image_pipeline, text_pipeline, text_atlas_bgl, @@ -1208,6 +1299,8 @@ impl Pipeline { gpu_selected_wires: vec![], gpu_preview_wires: vec![], gpu_hatch: None, + #[cfg(target_arch = "wasm32")] + gpu_hatches_web: vec![], gpu_wipeouts: vec![], wipeout_skip_flags: vec![], wipeout_pixel_scissors: vec![], @@ -1628,16 +1721,34 @@ impl Pipeline { .collect(); } - pub fn upload_hatches(&mut self, device: &wgpu::Device, hatches: &[HatchModel]) { - // No batched pipeline (WebGL2) → nothing to upload. - let Some(bgl1) = &self.hatch_bgl1 else { - self.gpu_hatch = None; - return; - }; - let renderable: Vec = - hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect(); - self.gpu_hatch = - hatch_gpu::HatchGpu::build(device, bgl1, &renderable); + pub fn upload_hatches( + &mut self, + device: &wgpu::Device, + queue: &wgpu::Queue, + hatches: &[HatchModel], + ) { + // WebGL2: no storage-buffer batched pipeline — render each hatch through + // the texture-backed per-hatch renderer instead (issue #204). + #[cfg(target_arch = "wasm32")] + { + let webs: Vec = hatches + .iter() + .filter(|h| h.boundary.len() >= 3) + .map(|h| hatch_web_gpu::HatchWebGpu::new(device, queue, h, &self.hatch_web_bgl1)) + .collect(); + self.gpu_hatches_web = webs; + } + #[cfg(not(target_arch = "wasm32"))] + { + let _ = queue; + let Some(bgl1) = &self.hatch_bgl1 else { + self.gpu_hatch = None; + return; + }; + let renderable: Vec = + hatches.iter().filter(|h| h.boundary.len() >= 3).cloned().collect(); + self.gpu_hatch = hatch_gpu::HatchGpu::build(device, bgl1, &renderable); + } } pub fn upload_wipeouts(&mut self, device: &wgpu::Device, wipeouts: &[HatchModel]) { @@ -1794,6 +1905,20 @@ impl Pipeline { pass.draw(0..batch.vertex_count, 0..1); } } + + // WebGL2: the batched `gpu_hatch` is always None (no storage + // buffers), so draw the texture-backed per-hatch renderer in this + // same pass (before wires, so wires stay on top). (#204) + #[cfg(target_arch = "wasm32")] + if !self.skip_hatch_frame { + pass.set_pipeline(&self.hatch_web_pipeline); + pass.set_bind_group(0, &self.uniform_bind_group, &[]); + for hatch in &self.gpu_hatches_web { + pass.set_bind_group(1, &hatch.bind_group, &[]); + pass.set_vertex_buffer(0, hatch.vertex_buffer.slice(..)); + pass.draw(0..6, 0..1); + } + } } // ── Pass 2: raster images ───────────────────────────────────────── diff --git a/src/scene/view/render.rs b/src/scene/view/render.rs index 5d39fa9b8..71d1d9c9c 100644 --- a/src/scene/view/render.rs +++ b/src/scene/view/render.rs @@ -319,10 +319,10 @@ impl shader::Primitive for Primitive { let sel_changed = vp.selected_sig != inner.cached_epoch.2; if geo_changed || sel_changed { if fill_mode { - inner.upload_hatches(device, &vp.hatches[..]); + inner.upload_hatches(device, queue, &vp.hatches[..]); inner.upload_wipeouts(device, &vp.wipeout_hatches[..]); } else { - inner.upload_hatches(device, &[]); + inner.upload_hatches(device, queue, &[]); inner.upload_wipeouts(device, &[]); } } diff --git a/src/shaders/hatch_web.wgsl b/src/shaders/hatch_web.wgsl new file mode 100644 index 000000000..6c6d2282d --- /dev/null +++ b/src/shaders/hatch_web.wgsl @@ -0,0 +1,258 @@ +// Hatch shader (WebGL2) — texture-backed, UNCAPPED variant of wipeout.wgsl. +// +// WebGL2 has no vertex/fragment storage buffers, so the batched storage-buffer +// renderer (hatch.wgsl) is disabled on wasm. This shader keeps the exact +// wipeout.wgsl hatch algorithm (in_polygon + per-family line/dash/dot + solid + +// gradient) but reads the variable-length boundary / family / dash arrays from a +// single RGBA32F data texture via textureLoad instead of fixed-size uniforms — +// removing the MAX_FAMILIES (16) / MAX_HATCH_BOUNDARY_VERTS (1024) / MAX_DASHES +// (128) caps of the uniform path. Native (hatch.wgsl) is untouched. +// +// Data texture layout (row-major, width = h.tex_width, one vec4 per texel): +// [ 0 .. vcount ) boundary verts, .xy = local XY, NaN = break +// [ fam_off .. fam_off+3*n_fam ) families, 3 texels each (see load_family) +// [ dash_off .. ) dash values, 4 per texel (RGBA) + +// ── Group 0: frame uniforms (shared) ────────────────────────────────────── + +struct Uniforms { + viewport_size: vec2, + world_per_pixel: f32, + lwdisplay_enable: f32, + flat_shade: f32, + transparency_enable: f32, + _pad: vec2, + view_rot: mat4x4, + eye_high: vec3, + _pad_eh: f32, + eye_low: vec3, + _pad_el: f32, +} +@group(0) @binding(0) var u: Uniforms; + +// ── Group 1: per-hatch data ──────────────────────────────────────────────── + +struct HatchUniforms { + color: vec4, // 0 + color2: vec4, // 16 + mode: u32, // 32: 0=pattern, 1=solid, 2=gradient + vcount: u32, // 36: boundary vertex count + angle_offset: f32, // 40 + scale: f32, // 44 + grad_cos: f32, // 48 + grad_sin: f32, // 52 + grad_min: f32, // 56 + grad_range: f32, // 60 + origin: vec2, // 64 + origin_low: vec2, // 72 + n_families: u32, // 80 + fam_off: u32, // 84: texel offset of the family section + dash_off: u32, // 88: texel offset of the dash section + tex_width: u32, // 92: data texture width in texels +} +@group(1) @binding(0) var h: HatchUniforms; + +@group(1) @binding(1) var data_tex: texture_2d; + +// One line family, reconstructed from 3 texels. +struct LineFamily { + cos_a: f32, + sin_a: f32, + x0: f32, + y0: f32, + dx: f32, + dy: f32, + perp_step: f32, + along_step: f32, + line_width: f32, + period: f32, + n_dashes: u32, + dash_off: u32, +} + +fn texel(i: u32) -> vec4 { + let w = h.tex_width; + return textureLoad(data_tex, vec2(i % w, i / w), 0); +} + +fn load_family(fi: u32) -> LineFamily { + let base = h.fam_off + fi * 3u; + let t0 = texel(base); + let t1 = texel(base + 1u); + let t2 = texel(base + 2u); + var f: LineFamily; + f.cos_a = t0.x; f.sin_a = t0.y; f.x0 = t0.z; f.y0 = t0.w; + f.dx = t1.x; f.dy = t1.y; f.perp_step = t1.z; f.along_step = t1.w; + f.line_width = t2.x; f.period = t2.y; + // Counts stored as exact f32 (small integers, so no denormal/bitcast hazard + // through the float texture fetch). + f.n_dashes = u32(t2.z); f.dash_off = u32(t2.w); + return f; +} + +// ── Vertex shader (identical to wipeout.wgsl) ────────────────────────────── + +struct VIn { @location(0) pos: vec3 } +struct VOut { + @builtin(position) clip: vec4, + @location(0) xz: vec2, +} + +@vertex fn vs_main(v: VIn) -> VOut { + var o: VOut; + // Double-single relative-to-eye: anchor high cancels eye_high (Sterbenz); + // boundary-local v.pos + anchor low + (−eye_low) carry the residual. + let hi = vec3(h.origin.x - u.eye_high.x, + h.origin.y - u.eye_high.y, + -u.eye_high.z); + let lo = vec3(v.pos.x + h.origin_low.x - u.eye_low.x, + v.pos.y + h.origin_low.y - u.eye_low.y, + v.pos.z - u.eye_low.z); + o.clip = u.view_rot * vec4(hi + lo, 1.0); + o.xz = vec2(v.pos.x, v.pos.y); + return o; +} + +// ── Point-in-polygon (ray casting) ──────────────────────────────────────── + +fn valid_vertex(p: vec2) -> bool { + return p.x == p.x && p.y == p.y; +} + +fn edge_crosses(p: vec2, a: vec2, c: vec2) -> bool { + if (a.y > p.y) != (c.y > p.y) { + let x_int = (c.x - a.x) * (p.y - a.y) / (c.y - a.y) + a.x; + return p.x < x_int; + } + return false; +} + +fn in_polygon(p: vec2) -> bool { + var inside = false; + let n = h.vcount; + var prev = vec2(0.0, 0.0); + var first = vec2(0.0, 0.0); + var have_prev = false; + for (var i = 0u; i < n; i++) { + let vi = texel(i).xy; + if !valid_vertex(vi) { + // NaN sentinel closes the current sub-loop (last → first edge). (#140) + if have_prev && edge_crosses(p, prev, first) { + inside = !inside; + } + have_prev = false; + continue; + } + if have_prev { + if edge_crosses(p, prev, vi) { + inside = !inside; + } + } else { + first = vi; + } + prev = vi; + have_prev = true; + } + if have_prev && edge_crosses(p, prev, first) { + inside = !inside; + } + return inside; +} + +// ── Per-family hatch test (identical math to wipeout.wgsl) ───────────────── + +fn check_family( + xz: vec2, + fam: LineFamily, + cos_off: f32, + sin_off: f32, + scale: f32, +) -> bool { + let cos_a = fam.cos_a * cos_off - fam.sin_a * sin_off; + let sin_a = fam.sin_a * cos_off + fam.cos_a * sin_off; + + let ox = (fam.x0 * cos_off - fam.y0 * sin_off) * scale; + let oz = (fam.x0 * sin_off + fam.y0 * cos_off) * scale; + + let px = xz.x - ox; + let pz = xz.y - oz; + + let perp_step = fam.perp_step * scale; + + let perp = -px * sin_a + pz * cos_a; + let k = round(perp / perp_step); + let dperp = perp - k * perp_step; + let d = abs(dperp); + let half_px = length(vec2(dpdx(perp), dpdy(perp))) * 0.5; + + let wpx = length(vec2(dpdx(xz.x), dpdy(xz.x))); + let wpy = length(vec2(dpdx(xz.y), dpdy(xz.y))); + + if d > half_px * 2.0 { return false; } + + if fam.n_dashes == 0u { return d <= half_px; } + + let along_step = fam.along_step * scale; + let period = fam.period * scale; + let along = px * cos_a + pz * sin_a; + let t = along - k * along_step; + let t_mod = ((t % period) + period) % period; + + var pos = 0.0; + for (var j = 0u; j < fam.n_dashes; j++) { + let idx = fam.dash_off + j; + let sv = texel(h.dash_off + idx / 4u)[idx % 4u] * scale; + if sv > 0.0 { + if d <= half_px && t_mod >= pos && t_mod < pos + sv { return true; } + pos = pos + sv; + } else if sv < 0.0 { + pos = pos - sv; + } else { + let dtv = (t - pos) - round((t - pos) / period) * period; + let owx = -dtv * cos_a + dperp * sin_a; + let owy = -dtv * sin_a - dperp * cos_a; + if abs(owx / wpx) <= 0.5 && abs(owy / wpy) <= 0.5 { return true; } + } + } + return false; +} + +// ── Fragment shader ──────────────────────────────────────────────────────── + +@fragment fn fs_main(v: VOut) -> @location(0) vec4 { + if !in_polygon(v.xz) { discard; } + + if h.mode == 1u { + return h.color; + } else if h.mode == 2u { + let proj = v.xz.x * h.grad_cos + v.xz.y * h.grad_sin; + let t = clamp((proj - h.grad_min) / h.grad_range, 0.0, 1.0); + return mix(h.color, h.color2, t); + } + + // Pattern LOD: when the densest family projects below 2 px, lines blur into + // a solid fill — return solid instead of looping every family. + if u.world_per_pixel > 0.0 && h.n_families > 0u { + var min_spacing_world: f32 = 1.0e30; + for (var i = 0u; i < h.n_families; i++) { + let s = abs(load_family(i).perp_step) * h.scale; + if s > 0.0 && s < min_spacing_world { + min_spacing_world = s; + } + } + if min_spacing_world / u.world_per_pixel < 2.0 { + return h.color; + } + } + + let cos_off = cos(h.angle_offset); + let sin_off = sin(h.angle_offset); + for (var i = 0u; i < h.n_families; i++) { + if check_family(v.xz, load_family(i), cos_off, sin_off, h.scale) { + return h.color; + } + } + discard; + // Unreachable, but FXC/DX12 requires a return after every terminal discard. + return vec4(0.0); +}