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:
Hakan SevenandClaude Opus 4.8 committed 2026-07-14 00:53:51 +03:00
1 parent 9b1a495549
commit a4fc34c6a2
11 files changed
+745 -13

No files matched your search

+1
View File
@@ -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 {
+4
View File
@@ -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
+338
View File
@@ -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
View File
@@ -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 ─────────────────────────────────────────
+2 -2
View File
@@ -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, &[]);
}
}