Skip to main content

brep_app/viewport/
blit.rs

1use super::*;
2
3impl egui_wgpu::CallbackTrait for ViewportCallback {
4    fn paint(
5        &self,
6        _info: egui::PaintCallbackInfo,
7        render_pass: &mut wgpu::RenderPass<'static>,
8        resources: &egui_wgpu::CallbackResources,
9    ) {
10        let Some(vp) = resources.get::<ViewportPaint>() else {
11            return;
12        };
13        // egui has already set the render pass viewport to our panel rect, so a
14        // clip-space fullscreen triangle fills exactly the viewport.
15        render_pass.set_pipeline(&vp.pipeline);
16        render_pass.set_bind_group(0, &vp.bind_group, &[]);
17        render_pass.draw(0..3, 0..1);
18    }
19}
20
21impl Viewport {
22    /// Build the viewport's GPU resources from eframe's SHARED render state. We
23    /// render in the engine's native `COLOR_FORMAT` (Rgba8Unorm) — exactly what
24    /// the headless artifact path uses, so colors are known-good — and the blit
25    /// converts into eframe's actual target format.
26    pub fn new(render_state: &egui_wgpu::RenderState) -> Self {
27        let device = render_state.device.clone();
28        let queue = render_state.queue.clone();
29        let core = RenderCore::new(device.clone(), queue, COLOR_FORMAT);
30        let gpu_scene = GpuScene::default();
31
32        // --- blit pipeline: offscreen 3D texture -> egui's frame target -------
33        let target_format = render_state.target_format;
34        let blit_layout = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor {
35            label: Some("brep-app viewport blit"),
36            entries: &[
37                wgpu::BindGroupLayoutEntry {
38                    binding: 0,
39                    visibility: wgpu::ShaderStages::FRAGMENT,
40                    ty: wgpu::BindingType::Texture {
41                        sample_type: wgpu::TextureSampleType::Float { filterable: true },
42                        view_dimension: wgpu::TextureViewDimension::D2,
43                        multisampled: false,
44                    },
45                    count: None,
46                },
47                wgpu::BindGroupLayoutEntry {
48                    binding: 1,
49                    visibility: wgpu::ShaderStages::FRAGMENT,
50                    ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering),
51                    count: None,
52                },
53            ],
54        });
55        let blit_sampler = device.create_sampler(&wgpu::SamplerDescriptor {
56            label: Some("brep-app viewport sampler"),
57            mag_filter: wgpu::FilterMode::Nearest,
58            min_filter: wgpu::FilterMode::Nearest,
59            ..Default::default()
60        });
61        let shader = device.create_shader_module(wgpu::ShaderModuleDescriptor {
62            label: Some("brep-app blit"),
63            source: wgpu::ShaderSource::Wgsl(blit_wgsl(target_format.is_srgb()).into()),
64        });
65        let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor {
66            label: Some("brep-app blit layout"),
67            bind_group_layouts: &[Some(&blit_layout)],
68            immediate_size: 0,
69        });
70        let blit_pipeline = device.create_render_pipeline(&wgpu::RenderPipelineDescriptor {
71            label: Some("brep-app blit pipeline"),
72            layout: Some(&pipeline_layout),
73            vertex: wgpu::VertexState {
74                module: &shader,
75                entry_point: Some("vs"),
76                compilation_options: Default::default(),
77                buffers: &[],
78            },
79            fragment: Some(wgpu::FragmentState {
80                module: &shader,
81                entry_point: Some("fs"),
82                compilation_options: Default::default(),
83                targets: &[Some(wgpu::ColorTargetState {
84                    format: target_format,
85                    blend: None,
86                    write_mask: wgpu::ColorWrites::ALL,
87                })],
88            }),
89            primitive: wgpu::PrimitiveState {
90                topology: wgpu::PrimitiveTopology::TriangleList,
91                ..Default::default()
92            },
93            depth_stencil: None,
94            // egui's frame render pass is single-sample (web painter + native
95            // with multisampling=0), so the blit pipeline must match.
96            multisample: wgpu::MultisampleState::default(),
97            multiview_mask: None,
98            cache: None,
99        });
100
101        Self {
102            core,
103            gpu_scene,
104            egui_renderer: render_state.renderer.clone(),
105            blit_layout,
106            blit_sampler,
107            blit_pipeline,
108            offscreen: None,
109            dragging: false,
110            gizmo_dragging: false,
111            component_gizmo_dragging: false,
112            dim_dragging: None,
113            sketch_dragging: false,
114            sketch_handdrawing: false,
115            last_rect: None,
116            candidate_popup: None,
117            candidate_popup_fresh: false,
118            candidate_popup_rect: None,
119            candidate_hits: Vec::new(),
120            editing_dim: None,
121            dim_edit_fresh: false,
122            editing_feature_dim: None,
123            feature_dim_edit_fresh: false,
124            constraint_dragging: false,
125            constraint_label_hovered: None,
126            pmi_label_hovered: None,
127            pmi_label_dragging: None,
128            pmi_label_hits: Vec::new(),
129        }
130    }
131
132    /// (Re)create the offscreen texture at `w`x`h` physical px and refresh the
133    /// callback's blit bind group in egui's resource map.
134    pub(super) fn ensure_offscreen(&mut self, w: u32, h: u32) {
135        if let Some(off) = &self.offscreen {
136            if off.w == w && off.h == h {
137                return;
138            }
139        }
140        let texture = self.core.device.create_texture(&wgpu::TextureDescriptor {
141            label: Some("brep-app viewport offscreen"),
142            size: wgpu::Extent3d {
143                width: w,
144                height: h,
145                depth_or_array_layers: 1,
146            },
147            mip_level_count: 1,
148            sample_count: 1,
149            dimension: wgpu::TextureDimension::D2,
150            format: COLOR_FORMAT,
151            usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING,
152            view_formats: &[],
153        });
154        let view = texture.create_view(&Default::default());
155        let bind_group = self.core.device.create_bind_group(&wgpu::BindGroupDescriptor {
156            label: Some("brep-app viewport blit bind group"),
157            layout: &self.blit_layout,
158            entries: &[
159                wgpu::BindGroupEntry {
160                    binding: 0,
161                    resource: wgpu::BindingResource::TextureView(&view),
162                },
163                wgpu::BindGroupEntry {
164                    binding: 1,
165                    resource: wgpu::BindingResource::Sampler(&self.blit_sampler),
166                },
167            ],
168        });
169        // Publish the fresh pipeline+bind-group for the paint callback to read.
170        self.egui_renderer
171            .write()
172            .callback_resources
173            .insert(ViewportPaint {
174                pipeline: self.blit_pipeline.clone(),
175                bind_group,
176            });
177        self.offscreen = Some(Offscreen { view, w, h });
178    }
179
180    /// Render the 3D scene into the offscreen texture via the engine's render
181    /// core (its own MSAA + submit, on the shared queue). Runs only when the
182    /// engine is dirty (R22 on-demand).
183    pub(super) fn render_viewport(&mut self, phys_w: u32, phys_h: u32, ppp: f32, state: &mut EngineState) {
184        let Some(off) = &self.offscreen else { return };
185        self.core.sync_scene(
186            &mut self.gpu_scene,
187            &state.scene,
188            &state.settings,
189            state.settings_generation,
190        );
191        // Fit near/far to everything drawn (solids + the pushed overlay + the FULL
192        // widget overlay's world bounds + the origin) and resolve the camera in one
193        // shared step — see `EngineState::fit_camera_and_overlay`. Folding the full
194        // overlay in stops construction geometry (datum planes, world axes, gizmos)
195        // and an editing sketch from clipping against the solids-only bounds.
196        let (camera, overlay) = state.fit_camera_and_overlay();
197        let params = FrameParams {
198            camera: &camera,
199            width: phys_w,
200            height: phys_h,
201            dpr: ppp,
202            settings: &state.settings,
203            emphasis: &state.emphasis,
204            world_per_pixel: state.camera.world_per_pixel(),
205            overlay: overlay.as_ref(),
206        };
207        self.core
208            .render_to_view(&mut self.gpu_scene, &state.scene, &params, &off.view);
209        state.dirty = false;
210    }
211}
212
213/// The blit shader. Fullscreen triangle; the fragment linearizes the (already
214/// display-encoded) engine output when egui's target is sRGB, so the hardware
215/// re-encode reproduces the engine's exact colors; otherwise it passes through.
216fn blit_wgsl(target_is_srgb: bool) -> String {
217    let frag_body = if target_is_srgb {
218        // sampled texels are sRGB-encoded display values -> linearize so the
219        // sRGB target's write-encode round-trips them back.
220        r#"
221    let c = textureSample(tex, samp, in.uv);
222    let rgb = srgb_to_linear(c.rgb);
223    return vec4<f32>(rgb, c.a);
224"#
225    } else {
226        r#"
227    return textureSample(tex, samp, in.uv);
228"#
229    };
230    format!(
231        r#"
232@group(0) @binding(0) var tex: texture_2d<f32>;
233@group(0) @binding(1) var samp: sampler;
234
235struct VsOut {{
236    @builtin(position) pos: vec4<f32>,
237    @location(0) uv: vec2<f32>,
238}};
239
240@vertex
241fn vs(@builtin(vertex_index) vi: u32) -> VsOut {{
242    var p = array<vec2<f32>, 3>(
243        vec2<f32>(-1.0, -1.0),
244        vec2<f32>( 3.0, -1.0),
245        vec2<f32>(-1.0,  3.0),
246    );
247    var out: VsOut;
248    let xy = p[vi];
249    out.pos = vec4<f32>(xy, 0.0, 1.0);
250    // Framebuffer origin is top-left: map clip +y (top) -> uv.y 0.
251    out.uv = vec2<f32>((xy.x + 1.0) * 0.5, (1.0 - xy.y) * 0.5);
252    return out;
253}}
254
255fn srgb_to_linear(c: vec3<f32>) -> vec3<f32> {{
256    let lo = c / 12.92;
257    let hi = pow((c + 0.055) / 1.055, vec3<f32>(2.4));
258    return select(hi, lo, c <= vec3<f32>(0.04045));
259}}
260
261@fragment
262fn fs(in: VsOut) -> @location(0) vec4<f32> {{{frag_body}}}
263"#
264    )
265}