Skip to main content

valo_renderer/
renderer.rs

1use valo_dl::DisplayList;
2use valo_geometry::Color;
3
4use crate::contours::ContourCache;
5use crate::frame::{FramePlan, PassColor, PlannedPass};
6use crate::glyphs::GlyphStore;
7use crate::gpu_timer::GpuTimer;
8use crate::host_buffer::HostBuffer;
9use crate::images::ImageStore;
10use crate::pipelines::PipelineCache;
11use crate::planner::Planner;
12use crate::pool::TargetPool;
13
14/// `linear_sampler` is the one linear sampler every filter/composite bind
15/// group shares — created once (a per-frame create is a JS hop on wasm).
16pub(crate) fn linear_sampler(device: &wgpu::Device) -> wgpu::Sampler {
17    device.create_sampler(&wgpu::SamplerDescriptor {
18        label: Some("valo.linear"),
19        mag_filter: wgpu::FilterMode::Linear,
20        min_filter: wgpu::FilterMode::Linear,
21        ..Default::default()
22    })
23}
24
25/// `RenderTarget` is the destination for one render operation.
26///
27/// `texture` must be the resource behind `view` and match `format` and `size`.
28/// Advanced blends and backdrop filters require `COPY_SRC` texture usage.
29pub struct RenderTarget<'a> {
30    /// `view` is the attachment into which Valo renders.
31    pub view: &'a wgpu::TextureView,
32    /// `texture` is the resource behind `view`.
33    pub texture: &'a wgpu::Texture,
34    /// `format` is the pixel format exposed by `view`.
35    pub format: wgpu::TextureFormat,
36    /// `size` is the renderable area in pixels.
37    pub size: [u32; 2],
38    /// `clear` replaces existing pixels when set and preserves them when `None`.
39    pub clear: Option<Color>,
40}
41
42/// `RenderStats` reports the work performed by one render operation.
43#[derive(Clone, Copy, Debug, Default)]
44pub struct RenderStats {
45    /// `ops` is the number of replayed operations, including nested lists.
46    pub ops: u32,
47    /// `draws` is the number of logical draws remaining after culling.
48    pub draws: u32,
49    /// `clips` is the number of encoded clip operations.
50    pub clips: u32,
51    /// `culled` is the number of draws skipped because their bounds miss the target.
52    pub culled: u32,
53    /// `layers_rendered` is the number of offscreen layers actually rendered.
54    pub layers_rendered: u32,
55    /// `layers_elided` is the number of save layers applied without an offscreen target.
56    pub layers_elided: u32,
57    /// `snapshots` is the number of target-region copies used by destination reads.
58    pub snapshots: u32,
59    /// `backdrops` is the number of backdrop regions that ran a blur chain.
60    pub backdrops: u32,
61    /// `shared_backdrops` is the number of backdrop regions that reused a shared blur.
62    pub shared_backdrops: u32,
63    /// `filter_passes` is the number of encoded image-filter passes.
64    pub filter_passes: u32,
65    /// `text_tiers` contains glyph-run counts for `[bitmap mask, SDF, outline]`.
66    pub text_tiers: [u32; 3],
67    /// `glyph_rasters` is the number of glyphs rasterized after cache misses.
68    pub glyph_rasters: u32,
69    /// `raster_quads` is the number of cached display lists drawn as image quads.
70    pub raster_quads: u32,
71    /// `raster_fills` is the number of display lists rendered into cache textures.
72    pub raster_fills: u32,
73    /// `atlas_gcs` is the number of full glyph-atlas collections.
74    pub atlas_gcs: u32,
75    /// `held_rasters` is the number of bitmap or SDF misses served by a resident size.
76    pub held_rasters: u32,
77    /// `opaque_reordered` is the number of opaque draws moved earlier for depth culling.
78    pub opaque_reordered: u32,
79    /// `gpu_ms` is the previous resolved frame's GPU time in milliseconds.
80    ///
81    /// It is zero until a timestamp resolves or when timestamps are unavailable.
82    pub gpu_ms: f32,
83    /// `blocks_created` is the number of transient upload blocks allocated this frame.
84    pub blocks_created: u32,
85    /// `cpu_ms` is total CPU time spent in rendering and submission.
86    pub cpu_ms: f32,
87    /// `draw_calls` is the number of encoded GPU draw commands.
88    ///
89    /// One logical draw may require several commands, while batched glyphs may
90    /// share one.
91    pub draw_calls: u32,
92    /// `render_passes` is the number of encoded render passes.
93    pub render_passes: u32,
94    /// `pipeline_switches` is the number of encoded pipeline changes.
95    pub pipeline_switches: u32,
96    /// `vertex_bytes` is the number of transient vertex bytes uploaded.
97    pub vertex_bytes: u64,
98    /// `uniform_bytes` is the number of transient uniform bytes uploaded.
99    pub uniform_bytes: u64,
100    /// `plan_ms` is the CPU time spent replaying and planning.
101    pub plan_ms: f32,
102    /// `encode_ms` is the CPU time spent compiling, uploading, encoding, and submitting.
103    pub encode_ms: f32,
104}
105
106/// `RendererCore` replays display lists on a host-owned wgpu device.
107///
108/// Hosts normally use the `valo` crate's `Context` instead. This type is the
109/// GPU core that context wraps: it owns caches and pipelines, and holds no
110/// application content of its own.
111pub struct RendererCore {
112    device: wgpu::Device,
113    queue: wgpu::Queue,
114    host: HostBuffer,
115    pipelines: PipelineCache,
116    images: ImageStore,
117    pool: TargetPool,
118    glyphs: GlyphStore,
119    contours: ContourCache,
120    ramps: crate::ramps::RampCache,
121    rasters: crate::raster::ListRasterCache,
122    /// The one linear sampler every filter/composite bind group shares —
123    /// created once (a per-frame create is a JS hop on wasm).
124    sampler: wgpu::Sampler,
125    timer: GpuTimer,
126}
127
128impl RendererCore {
129    /// `new` creates a renderer from a host-owned device and queue.
130    pub fn new(device: wgpu::Device, queue: wgpu::Queue) -> Self {
131        let host = HostBuffer::new(&device);
132        let pipelines = PipelineCache::new(&device, host.bind_group_layout());
133        let images = ImageStore::new(&device, &queue);
134        let pool = TargetPool::new(&device);
135        let glyphs = GlyphStore::new(&device, &queue);
136        let timer = GpuTimer::new(&device, &queue);
137        let sampler = linear_sampler(&device);
138        Self {
139            device,
140            queue,
141            host,
142            pipelines,
143            images,
144            pool,
145            glyphs,
146            contours: ContourCache::new(),
147            ramps: crate::ramps::RampCache::new(),
148            rasters: crate::raster::ListRasterCache::new(),
149            sampler,
150            timer,
151        }
152    }
153
154    /// `images` returns the image store used for uploads and sampling.
155    pub fn images(&mut self) -> &mut ImageStore {
156        &mut self.images
157    }
158
159    /// `device` returns the device used by this renderer.
160    pub fn device(&self) -> &wgpu::Device {
161        &self.device
162    }
163
164    /// `set_text_tiers` controls how text is rendered across font-size ranges.
165    ///
166    /// Valo uses bitmap masks below `sdf_min`, SDF below `path_min`, and
167    /// outlines above it. The defaults suit normal use; override them only for
168    /// specialized scaling or zoom behavior.
169    pub fn set_text_tiers(&mut self, tiers: crate::TextTiers) {
170        self.glyphs.tiers = tiers;
171    }
172
173    /// `set_hide_missing_glyphs` controls whether unresolved characters render blank.
174    ///
175    /// By default, unresolved characters render the font's `.notdef` glyph,
176    /// usually a "tofu" box. This is common when CJK fallback fonts are missing.
177    /// Use [`valo_text::FontDemand`] to detect characters hidden by this option.
178    pub fn set_hide_missing_glyphs(&mut self, hide: bool) {
179        self.glyphs.set_hide_missing_glyphs(hide);
180    }
181
182    /// `set_text_raster_hold` allows existing text rasters to stand in for missing sizes.
183    ///
184    /// This applies to bitmap-mask and SDF text, not vector outlines. It is
185    /// useful during rapid zooming: enable it while the gesture is active and
186    /// clear it afterward so the next frame renders sharply.
187    pub fn set_text_raster_hold(&mut self, held: bool) {
188        self.glyphs.set_text_raster_hold(held);
189    }
190
191    /// `set_raster_hold` allows cached display-list textures to be reused at any scale.
192    ///
193    /// This is useful during rapid zooming: enable it when the gesture starts
194    /// and clear it when the view settles so caches refill at the final scale.
195    pub fn set_raster_hold(&mut self, held: bool) {
196        self.rasters.set_hold(held);
197    }
198
199    /// `render` draws a display list into a target and returns frame statistics.
200    ///
201    /// Each call submits one command buffer.
202    pub fn render(&mut self, dl: &DisplayList, target: &RenderTarget) -> RenderStats {
203        #[cfg(feature = "trace")]
204        let _span = tracing::info_span!("valo.render", draws = dl.draw_count()).entered();
205        let t0 = web_time::Instant::now();
206        let blocks_before = self.host.blocks_created;
207
208        self.host.begin_frame();
209        let plan = self.plan(dl, target);
210        let t_planned = web_time::Instant::now();
211
212        let mut stats = plan.stats;
213        self.glyphs.flush_uploads();
214        (stats.uniform_bytes, stats.vertex_bytes) = self.upload_and_compile(&plan);
215        self.encode_and_submit(&plan, target, &mut stats);
216        (stats.glyph_rasters, stats.atlas_gcs, stats.held_rasters) = self.glyphs.frame_counters();
217        self.pool.end_frame();
218        self.images.end_frame();
219        self.contours.end_frame();
220        self.glyphs.end_frame();
221        self.ramps.end_frame();
222        self.rasters.end_frame();
223
224        stats.render_passes = plan.passes.len() as u32;
225        stats.blocks_created = (self.host.blocks_created - blocks_before) as u32;
226        stats.plan_ms = (t_planned - t0).as_secs_f32() * 1000.0;
227        stats.encode_ms = t_planned.elapsed().as_secs_f32() * 1000.0;
228        stats.cpu_ms = t0.elapsed().as_secs_f32() * 1000.0;
229        stats.gpu_ms = self.timer.latest_ms(&self.device);
230        stats
231    }
232
233    /// `memory_report` returns resource counts and estimated GPU memory usage.
234    ///
235    /// The `counters` feature adds the counters reported by wgpu.
236    pub fn memory_report(&self) -> crate::MemoryReport {
237        crate::MemoryReport {
238            images: self.images.report(),
239            atlas: self.glyphs.report_atlas(),
240            targets: self.pool.report(),
241            host_buffer: self.host.report(),
242            contours: self.contours.report(),
243            glyph_paths: self.glyphs.report_paths(),
244            ramps: self.ramps.report(),
245            raster_cache: self.rasters.report(),
246            wgpu: crate::report::wgpu_counters(&self.device),
247        }
248    }
249
250    fn plan(&mut self, dl: &DisplayList, target: &RenderTarget) -> FramePlan {
251        #[cfg(feature = "trace")]
252        let _span = tracing::info_span!("valo.plan").entered();
253        // Disjoint field borrows: the planner mutates arenas + pools while
254        // reading the pipeline cache's layouts.
255        let Self {
256            device,
257            queue,
258            host,
259            images,
260            pool,
261            pipelines,
262            glyphs,
263            contours,
264            ramps,
265            rasters,
266            sampler,
267            ..
268        } = self;
269        Planner::new(
270            device, queue, host, images, pool, pipelines, glyphs, contours, ramps, rasters,
271            sampler, target, dl,
272        )
273        .run(dl)
274    }
275
276    fn upload_and_compile(&mut self, plan: &FramePlan) -> (u64, u64) {
277        let bytes = self.host.flush(&self.queue);
278        for pass in &plan.passes {
279            for step in &pass.steps {
280                self.pipelines.ensure(&self.device, step.key);
281            }
282        }
283        bytes
284    }
285
286    fn encode_and_submit(
287        &mut self,
288        plan: &FramePlan,
289        target: &RenderTarget,
290        stats: &mut RenderStats,
291    ) {
292        #[cfg(feature = "trace")]
293        let _span = tracing::info_span!("valo.encode", passes = plan.passes.len()).entered();
294        let mut encoder = self
295            .device
296            .create_command_encoder(&wgpu::CommandEncoderDescriptor {
297                label: Some("valo.frame"),
298            });
299        for (index, pass) in plan.passes.iter().enumerate() {
300            self.encode_copies(&mut encoder, pass);
301            let timing = self.timer.pass_writes(index, plan.passes.len());
302            self.encode_pass(&mut encoder, pass, target, timing, stats);
303        }
304        self.timer.end_frame(&mut encoder);
305        self.queue.submit(std::iter::once(encoder.finish()));
306        self.timer.after_submit();
307    }
308
309    /// Dst snapshots for advanced blends: land BEFORE the segment that
310    /// samples them. Same origin in src and dst — the snapshot shares the
311    /// target's coordinates, so only the region under the draw is copied.
312    fn encode_copies(&self, encoder: &mut wgpu::CommandEncoder, pass: &PlannedPass) {
313        for copy in &pass.pre_copies {
314            let origin = wgpu::Origin3d {
315                x: copy.origin[0],
316                y: copy.origin[1],
317                z: 0,
318            };
319            encoder.copy_texture_to_texture(
320                copy_at(&copy.src, origin),
321                copy_at(&copy.dst, origin),
322                wgpu::Extent3d {
323                    width: copy.size[0],
324                    height: copy.size[1],
325                    depth_or_array_layers: 1,
326                },
327            );
328        }
329    }
330
331    fn encode_pass(
332        &self,
333        encoder: &mut wgpu::CommandEncoder,
334        pass: &PlannedPass,
335        target: &RenderTarget,
336        timing: Option<wgpu::RenderPassTimestampWrites>,
337        stats: &mut RenderStats,
338    ) {
339        let color = match &pass.color {
340            PassColor::Main { msaa } => color_attachment(msaa, Some(target.view), pass),
341            PassColor::Layer { msaa, resolve } => color_attachment(msaa, Some(resolve), pass),
342            PassColor::Filter { view } => color_attachment(view, None, pass),
343        };
344        let color_attachments = [Some(color)];
345        let mut rp = encoder.begin_render_pass(&wgpu::RenderPassDescriptor {
346            label: Some("valo.pass"),
347            color_attachments: &color_attachments,
348            depth_stencil_attachment: pass
349                .depth
350                .as_ref()
351                .map(|depth| depth_attachment(depth, pass.clear_depth, pass.store)),
352            timestamp_writes: timing,
353            occlusion_query_set: None,
354            multiview_mask: None,
355        });
356        rp.set_stencil_reference(0);
357        let mut bound = None;
358        for step in &pass.steps {
359            if bound != Some(step.key) {
360                rp.set_pipeline(self.pipelines.get(&step.key));
361                bound = Some(step.key);
362                stats.pipeline_switches += 1;
363            }
364            stats.draw_calls += 1;
365            rp.set_bind_group(
366                0,
367                self.host.bind_group(step.uniforms.block),
368                &[step.uniforms.offset],
369            );
370            if let Some(texture) = &step.texture {
371                rp.set_bind_group(1, texture, &[]);
372            }
373            match step.mesh {
374                None => rp.draw(0..6, 0..1),
375                Some((slot, vertex_count)) => {
376                    let buffer = self.host.vertex_buffer(slot.block);
377                    rp.set_vertex_buffer(0, buffer.slice(slot.offset..slot.offset + slot.bytes));
378                    rp.draw(0..vertex_count, 0..1);
379                }
380            }
381        }
382    }
383}
384
385fn copy_at(texture: &wgpu::Texture, origin: wgpu::Origin3d) -> wgpu::TexelCopyTextureInfo<'_> {
386    wgpu::TexelCopyTextureInfo {
387        texture,
388        mip_level: 0,
389        origin,
390        aspect: wgpu::TextureAspect::All,
391    }
392}
393
394/// Main/layer passes render ×4 and resolve every segment; MSAA contents are
395/// stored only when a later segment resumes the target (the final segment
396/// discards — skips the 4× write-out on tiled GPUs). Filter passes render
397/// 1-sample straight into `view` (no resolve) and always store.
398fn color_attachment<'a>(
399    view: &'a wgpu::TextureView,
400    resolve: Option<&'a wgpu::TextureView>,
401    pass: &PlannedPass,
402) -> wgpu::RenderPassColorAttachment<'a> {
403    wgpu::RenderPassColorAttachment {
404        view,
405        depth_slice: None,
406        resolve_target: resolve,
407        ops: wgpu::Operations {
408            load: match pass.clear {
409                Some(c) => wgpu::LoadOp::Clear(wgpu::Color {
410                    r: (c.r * c.a) as f64,
411                    g: (c.g * c.a) as f64,
412                    b: (c.b * c.a) as f64,
413                    a: c.a as f64,
414                }),
415                None => wgpu::LoadOp::Load,
416            },
417            store: store_op(pass.store),
418        },
419    }
420}
421
422/// Depth/stencil persist across a target's segments (clip ceilings survive
423/// pass breaks); only the first segment clears, only resumed segments store.
424fn depth_attachment(
425    view: &wgpu::TextureView,
426    clear: bool,
427    store: bool,
428) -> wgpu::RenderPassDepthStencilAttachment<'_> {
429    wgpu::RenderPassDepthStencilAttachment {
430        view,
431        depth_ops: Some(wgpu::Operations {
432            load: if clear {
433                wgpu::LoadOp::Clear(0.0)
434            } else {
435                wgpu::LoadOp::Load
436            },
437            store: store_op(store),
438        }),
439        stencil_ops: Some(wgpu::Operations {
440            load: if clear {
441                wgpu::LoadOp::Clear(0)
442            } else {
443                wgpu::LoadOp::Load
444            },
445            store: store_op(store),
446        }),
447    }
448}
449
450fn store_op(store: bool) -> wgpu::StoreOp {
451    if store {
452        wgpu::StoreOp::Store
453    } else {
454        wgpu::StoreOp::Discard
455    }
456}