forked from 1b-agent/opencadstudio-upstream
feat(web): render hatches on WebGL2 via a texture-backed renderer
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 <noreply@anthropic.com>
This commit is contained in:
1 parent
9b1a495549
commit
a4fc34c6a2
11 files changed
+745
-13
No files matched your search
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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")]
|
||||
{
|
||||
|
||||
@@ -346,6 +346,7 @@ pub fn dropped_on_save_count(
|
||||
/// Before overwriting `path`, copy the existing file to a sibling `<name>.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"));
|
||||
|
||||
@@ -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<String>,
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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<f32> = 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<f32> = 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
+136
-11
@@ -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<wgpu::RenderPipeline>,
|
||||
/// 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<wgpu::BindGroupLayout>,
|
||||
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<hatch_gpu::HatchGpu>,
|
||||
/// 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<hatch_web_gpu::HatchWebGpu>,
|
||||
/// Wipeout masks — solid fills rendered after wires in a separate pass via
|
||||
/// the legacy per-primitive `WipeoutGpu` renderer.
|
||||
gpu_wipeouts: Vec<WipeoutGpu>,
|
||||
@@ -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<HatchModel> =
|
||||
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<hatch_web_gpu::HatchWebGpu> = 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<HatchModel> =
|
||||
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 ─────────────────────────────────────────
|
||||
|
||||
@@ -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, &[]);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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<f32>,
|
||||
world_per_pixel: f32,
|
||||
lwdisplay_enable: f32,
|
||||
flat_shade: f32,
|
||||
transparency_enable: f32,
|
||||
_pad: vec2<f32>,
|
||||
view_rot: mat4x4<f32>,
|
||||
eye_high: vec3<f32>,
|
||||
_pad_eh: f32,
|
||||
eye_low: vec3<f32>,
|
||||
_pad_el: f32,
|
||||
}
|
||||
@group(0) @binding(0) var<uniform> u: Uniforms;
|
||||
|
||||
// ── Group 1: per-hatch data ────────────────────────────────────────────────
|
||||
|
||||
struct HatchUniforms {
|
||||
color: vec4<f32>, // 0
|
||||
color2: vec4<f32>, // 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<f32>, // 64
|
||||
origin_low: vec2<f32>, // 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<uniform> h: HatchUniforms;
|
||||
|
||||
@group(1) @binding(1) var data_tex: texture_2d<f32>;
|
||||
|
||||
// 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<f32> {
|
||||
let w = h.tex_width;
|
||||
return textureLoad(data_tex, vec2<u32>(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<f32> }
|
||||
struct VOut {
|
||||
@builtin(position) clip: vec4<f32>,
|
||||
@location(0) xz: vec2<f32>,
|
||||
}
|
||||
|
||||
@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<f32>(h.origin.x - u.eye_high.x,
|
||||
h.origin.y - u.eye_high.y,
|
||||
-u.eye_high.z);
|
||||
let lo = vec3<f32>(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<f32>(hi + lo, 1.0);
|
||||
o.xz = vec2<f32>(v.pos.x, v.pos.y);
|
||||
return o;
|
||||
}
|
||||
|
||||
// ── Point-in-polygon (ray casting) ────────────────────────────────────────
|
||||
|
||||
fn valid_vertex(p: vec2<f32>) -> bool {
|
||||
return p.x == p.x && p.y == p.y;
|
||||
}
|
||||
|
||||
fn edge_crosses(p: vec2<f32>, a: vec2<f32>, c: vec2<f32>) -> 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<f32>) -> bool {
|
||||
var inside = false;
|
||||
let n = h.vcount;
|
||||
var prev = vec2<f32>(0.0, 0.0);
|
||||
var first = vec2<f32>(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<f32>,
|
||||
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<f32>(dpdx(perp), dpdy(perp))) * 0.5;
|
||||
|
||||
let wpx = length(vec2<f32>(dpdx(xz.x), dpdy(xz.x)));
|
||||
let wpy = length(vec2<f32>(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<f32> {
|
||||
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<f32>(0.0);
|
||||
}
|
||||
Reference in new issue
Block a user