Cache Vello render output as stitchable textures (#3722)

* WIP render caching

* Hook up render cache to render pipeline

* Fixed offsets

* Initial cleanup

* Integrate cache with context invalidation

* Cleanup

* Improve rounding and reduce tile size to fix vello not rendering

* Include pointer position in cache key

* Avoid unwraps and zero sized textures

* Destroy textures after blitting to surface

* Fix context dependencies

* Exclude footprint from render params

* Batch animation frame messages

* Add vello max render size to preference dialogue

* Remove unused import

* Reorder vello preference

* Clean up preferences dialog

* Apply review suggestions

* Cap max render region size

---------

Co-authored-by: Keavon Chambers <keavon@keavon.com>
This commit is contained in:
Dennis Kobert
2026-02-22 11:12:50 +01:00
committed by GitHub
parent 5aca75dcbd
commit 0531769c41
19 changed files with 839 additions and 104 deletions

View File

@@ -69,6 +69,10 @@ pub struct WasmApplicationIo {
static WGPU_AVAILABLE: std::sync::atomic::AtomicI8 = std::sync::atomic::AtomicI8::new(-1);
/// Returns:
/// - `None` if the availability of WGPU has not been determined yet
/// - `Some(true)` if WGPU is available
/// - `Some(false)` if WGPU is not available
pub fn wgpu_available() -> Option<bool> {
match WGPU_AVAILABLE.load(Ordering::SeqCst) {
-1 => None,
@@ -332,24 +336,38 @@ pub type WasmSurfaceHandle = SurfaceHandle<wgpu_executor::Window>;
#[cfg(feature = "wgpu")]
pub type WasmSurfaceHandleFrame = graphene_application_io::SurfaceHandleFrame<wgpu_executor::Window>;
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, specta::Type, serde::Serialize, serde::Deserialize)]
pub enum VelloPreference {
Auto,
Disabled,
}
#[derive(Clone, Debug, PartialEq, Hash, specta::Type, serde::Serialize, serde::Deserialize)]
pub struct EditorPreferences {
pub use_vello: bool,
pub vello_preference: VelloPreference,
/// Maximum render region size in pixels along one dimension of the square area.
pub max_render_region_size: u32,
}
impl graphene_application_io::GetEditorPreferences for EditorPreferences {
fn use_vello(&self) -> bool {
self.use_vello
match self.vello_preference {
VelloPreference::Auto => wgpu_available().unwrap_or(false),
VelloPreference::Disabled => false,
}
}
fn max_render_region_area(&self) -> u32 {
let size = self.max_render_region_size.min(u32::MAX.isqrt());
size.pow(2)
}
}
impl Default for EditorPreferences {
fn default() -> Self {
Self {
#[cfg(target_family = "wasm")]
use_vello: false,
#[cfg(not(target_family = "wasm"))]
use_vello: true,
vello_preference: VelloPreference::Auto,
max_render_region_size: 1280,
}
}
}

View File

@@ -125,7 +125,10 @@ async fn main() -> Result<(), Box<dyn Error>> {
let wgpu_executor_ref = application_io_arc.gpu_executor().unwrap();
let device = wgpu_executor_ref.context.device.clone();
let preferences = EditorPreferences { use_vello: true };
let preferences = EditorPreferences {
vello_preference: graph_craft::wasm_application_io::VelloPreference::Auto,
max_render_region_size: EditorPreferences::default().max_render_region_size,
};
let editor_api = Arc::new(WasmEditorApi {
font_cache: FontCache::default(),
application_io: Some(application_io_for_api),

View File

@@ -141,6 +141,7 @@ fn node_registry() -> HashMap<ProtoNodeIdentifier, HashMap<NodeIOTypes, NodeCons
#[cfg(feature = "gpu")]
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Arc<WasmSurfaceHandle>, Context => graphene_std::ContextFeatures]),
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => RenderIntermediate, Context => graphene_std::ContextFeatures]),
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => RenderOutput, Context => graphene_std::ContextFeatures]),
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => WgpuSurface, Context => graphene_std::ContextFeatures]),
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => Option<WgpuSurface>, Context => graphene_std::ContextFeatures]),
async_node!(graphene_core::context_modification::ContextModificationNode<_, _>, input: Context, fn_params: [Context => WindowHandle, Context => graphene_std::ContextFeatures]),

View File

@@ -28,7 +28,7 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc<WasmEdito
let render_node = DocumentNode {
inputs: vec![NodeInput::node(NodeId(0), 0)],
implementation: DocumentNodeImplementation::Network(NodeNetwork {
exports: vec![NodeInput::node(NodeId(2), 0)],
exports: vec![NodeInput::node(NodeId(3), 0)],
nodes: [
DocumentNode {
call_argument: concrete!(Context),
@@ -36,7 +36,7 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc<WasmEdito
implementation: DocumentNodeImplementation::ProtoNode(graphene_std::render_node::render_intermediate::IDENTIFIER),
context_features: graphene_std::ContextDependencies {
extract: ContextFeatures::VARARGS,
inject: ContextFeatures::empty(),
inject: ContextFeatures::INDEX,
},
..Default::default()
},
@@ -51,12 +51,23 @@ pub fn wrap_network_in_scope(mut network: NodeNetwork, editor_api: Arc<WasmEdito
},
..Default::default()
},
DocumentNode {
call_argument: concrete!(Context),
inputs: vec![NodeInput::scope("editor-api"), NodeInput::node(NodeId(1), 0)],
implementation: DocumentNodeImplementation::ProtoNode(graphene_std::render_cache::render_output_cache::IDENTIFIER),
context_features: graphene_std::ContextDependencies {
extract: ContextFeatures::FOOTPRINT | ContextFeatures::VARARGS,
inject: ContextFeatures::VARARGS,
},
..Default::default()
},
DocumentNode {
call_argument: concrete!(graphene_std::application_io::RenderConfig),
inputs: vec![NodeInput::node(NodeId(1), 0)],
inputs: vec![NodeInput::node(NodeId(2), 0)],
implementation: DocumentNodeImplementation::ProtoNode(graphene_std::render_node::create_context::IDENTIFIER),
context_features: graphene_std::ContextDependencies {
extract: ContextFeatures::empty(),
// We add the extract index annotation here to force the compiler to add a context nullification node before this node so the render context is properly nullified so the render cache node can do its's work
extract: ContextFeatures::INDEX,
inject: ContextFeatures::REAL_TIME | ContextFeatures::ANIMATION_TIME | ContextFeatures::POINTER_POSITION | ContextFeatures::FOOTPRINT | ContextFeatures::VARARGS,
},
..Default::default()

View File

@@ -218,6 +218,7 @@ impl<T: NodeGraphUpdateSender> NodeGraphUpdateSender for std::sync::Mutex<T> {
pub trait GetEditorPreferences {
fn use_vello(&self) -> bool;
fn max_render_region_area(&self) -> u32;
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
@@ -261,6 +262,10 @@ impl GetEditorPreferences for DummyPreferences {
fn use_vello(&self) -> bool {
false
}
fn max_render_region_area(&self) -> u32 {
1024 * 1024
}
}
pub struct EditorApi<Io> {

View File

@@ -280,6 +280,16 @@ impl RenderMetadata {
value.transform = transform * value.transform;
}
}
/// Merge another RenderMetadata into this one.
/// Values from `other` take precedence for duplicate keys.
pub fn merge(&mut self, other: &RenderMetadata) {
self.upstream_footprints.extend(other.upstream_footprints.iter().map(|(k, v)| (*k, *v)));
self.local_transforms.extend(other.local_transforms.iter().map(|(k, v)| (*k, *v)));
self.first_element_source_id.extend(other.first_element_source_id.iter().map(|(k, v)| (*k, *v)));
self.click_targets.extend(other.click_targets.iter().map(|(k, v)| (*k, v.clone())));
self.clip_targets.extend(other.clip_targets.iter().copied());
}
}
// TODO: Rename to "Graphical"

View File

@@ -51,6 +51,7 @@ node-macro = { workspace = true }
reqwest = { workspace = true }
image = { workspace = true }
base64 = { workspace = true }
wgpu = { workspace = true }
# Optional workspace dependencies
wasm-bindgen = { workspace = true, optional = true }

View File

@@ -1,4 +1,5 @@
pub mod any;
pub mod render_cache;
pub mod render_node;
pub mod text;
#[cfg(feature = "wasm")]

View File

@@ -0,0 +1,581 @@
//! Tile-based render caching for efficient viewport panning.
use core_types::math::bbox::AxisAlignedBbox;
use core_types::transform::{Footprint, RenderQuality, Transform};
use core_types::{CloneVarArgs, Context, Ctx, ExtractAll, ExtractAnimationTime, ExtractPointerPosition, ExtractRealTime, OwnedContextImpl};
use glam::{DVec2, IVec2, UVec2};
use graph_craft::document::value::RenderOutput;
use graph_craft::wasm_application_io::WasmEditorApi;
use graphene_application_io::{ApplicationIo, ImageTexture};
use rendering::{RenderOutputType as RenderOutputTypeRequest, RenderParams};
use std::collections::HashSet;
use std::hash::Hash;
use std::sync::{Arc, Mutex};
use crate::render_node::RenderOutputType;
pub const TILE_SIZE: u32 = 256;
pub const MAX_CACHE_MEMORY_BYTES: usize = 512 * 1024 * 1024;
const BYTES_PER_PIXEL: usize = 4;
#[derive(Debug, Clone, Copy, Hash, Eq, PartialEq)]
pub struct TileCoord {
pub x: i32,
pub y: i32,
}
#[derive(Debug, Clone)]
pub struct CachedRegion {
pub texture: wgpu::Texture,
pub texture_size: UVec2,
pub scene_bounds: AxisAlignedBbox,
pub tiles: Vec<TileCoord>,
pub metadata: rendering::RenderMetadata,
last_access: u64,
memory_size: usize,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub struct CacheKey {
pub render_mode_hash: u64,
pub hide_artboards: bool,
pub for_export: bool,
pub for_mask: bool,
pub thumbnail: bool,
pub aligned_strokes: bool,
pub override_paint_order: bool,
pub animation_time_ms: i64,
pub real_time_ms: i64,
pub pointer: [u8; 16],
}
impl CacheKey {
pub fn new(
render_mode_hash: u64,
hide_artboards: bool,
for_export: bool,
for_mask: bool,
thumbnail: bool,
aligned_strokes: bool,
override_paint_order: bool,
animation_time: f64,
real_time: f64,
pointer: Option<DVec2>,
) -> Self {
let pointer_bytes = pointer
.map(|p| {
let mut bytes = [0u8; 16];
bytes[..8].copy_from_slice(&p.x.to_le_bytes());
bytes[8..].copy_from_slice(&p.y.to_le_bytes());
bytes
})
.unwrap_or([0u8; 16]);
Self {
render_mode_hash,
hide_artboards,
for_export,
for_mask,
thumbnail,
aligned_strokes,
override_paint_order,
animation_time_ms: (animation_time * 1000.0).round() as i64,
real_time_ms: (real_time * 1000.0).round() as i64,
pointer: pointer_bytes,
}
}
}
impl Default for CacheKey {
fn default() -> Self {
Self {
render_mode_hash: 0,
hide_artboards: false,
for_export: false,
for_mask: false,
thumbnail: false,
aligned_strokes: false,
override_paint_order: false,
animation_time_ms: 0,
real_time_ms: 0,
pointer: [0u8; 16],
}
}
}
#[derive(Debug)]
struct TileCacheImpl {
regions: Vec<CachedRegion>,
timestamp: u64,
total_memory: usize,
cache_key: CacheKey,
current_scale: f64,
}
impl Default for TileCacheImpl {
fn default() -> Self {
Self {
regions: Vec::new(),
timestamp: 0,
total_memory: 0,
cache_key: CacheKey::default(),
current_scale: 0.0,
}
}
}
#[derive(Clone, Default, dyn_any::DynAny, Debug)]
pub struct TileCache(Arc<Mutex<TileCacheImpl>>);
#[derive(Debug, Clone)]
pub struct RenderRegion {
pub scene_bounds: AxisAlignedBbox,
pub tiles: Vec<TileCoord>,
pub scale: f64,
}
#[derive(Debug)]
pub struct CacheQuery {
pub cached_regions: Vec<CachedRegion>,
pub missing_regions: Vec<RenderRegion>,
}
fn scene_bounds_to_tiles(bounds: &AxisAlignedBbox, scale: f64) -> Vec<TileCoord> {
let pixel_start = bounds.start * scale;
let pixel_end = bounds.end * scale;
let tile_start_x = (pixel_start.x / TILE_SIZE as f64).floor() as i32;
let tile_start_y = (pixel_start.y / TILE_SIZE as f64).floor() as i32;
let tile_end_x = (pixel_end.x / TILE_SIZE as f64).ceil() as i32;
let tile_end_y = (pixel_end.y / TILE_SIZE as f64).ceil() as i32;
let mut tiles = Vec::new();
for y in tile_start_y..tile_end_y {
for x in tile_start_x..tile_end_x {
tiles.push(TileCoord { x, y });
}
}
tiles
}
fn tile_scene_start(tile: &TileCoord, scale: f64) -> DVec2 {
DVec2::new(tile.x as f64, tile.y as f64) * (TILE_SIZE as f64 / scale)
}
fn tile_to_scene_bounds(coord: &TileCoord, scale: f64) -> AxisAlignedBbox {
let tile_scene_size = TILE_SIZE as f64 / scale;
let start = tile_scene_start(coord, scale);
AxisAlignedBbox {
start,
end: start + DVec2::splat(tile_scene_size),
}
}
fn tiles_to_scene_bounds(tiles: &[TileCoord], scale: f64) -> AxisAlignedBbox {
if tiles.is_empty() {
return AxisAlignedBbox::ZERO;
}
let mut result = tile_to_scene_bounds(&tiles[0], scale);
for tile in &tiles[1..] {
result = result.union(&tile_to_scene_bounds(tile, scale));
}
result
}
impl TileCacheImpl {
fn query(&mut self, viewport_bounds: &AxisAlignedBbox, scale: f64, cache_key: &CacheKey, max_region_area: u32) -> CacheQuery {
if &self.cache_key != cache_key || (self.current_scale - scale).abs() > 0.001 {
self.invalidate_all();
self.cache_key = cache_key.clone();
self.current_scale = scale;
}
let required_tiles = scene_bounds_to_tiles(viewport_bounds, scale);
let required_tile_set: HashSet<_> = required_tiles.iter().cloned().collect();
let mut cached_regions = Vec::new();
let mut covered_tiles = HashSet::new();
for region in &mut self.regions {
let region_tiles: HashSet<_> = region.tiles.iter().cloned().collect();
if region_tiles.iter().any(|t| required_tile_set.contains(t)) {
region.last_access = self.timestamp;
self.timestamp += 1;
cached_regions.push(region.clone());
covered_tiles.extend(region_tiles);
}
}
let missing_tiles: Vec<_> = required_tiles.into_iter().filter(|t| !covered_tiles.contains(t)).collect();
let missing_regions = group_into_regions(&missing_tiles, scale, max_region_area);
CacheQuery { cached_regions, missing_regions }
}
fn store_regions(&mut self, new_regions: Vec<CachedRegion>) {
for mut region in new_regions {
region.last_access = self.timestamp;
self.timestamp += 1;
self.total_memory += region.memory_size;
self.regions.push(region);
}
self.evict_until_under_budget();
}
fn evict_until_under_budget(&mut self) {
while self.total_memory > MAX_CACHE_MEMORY_BYTES && !self.regions.is_empty() {
if let Some((oldest_idx, _)) = self.regions.iter().enumerate().min_by_key(|(_, r)| r.last_access) {
let removed = self.regions.remove(oldest_idx);
removed.texture.destroy();
self.total_memory = self.total_memory.saturating_sub(removed.memory_size);
} else {
break;
}
}
}
fn invalidate_all(&mut self) {
for region in &self.regions {
region.texture.destroy();
}
self.regions.clear();
self.total_memory = 0;
}
}
impl TileCache {
pub fn query(&self, viewport_bounds: &AxisAlignedBbox, scale: f64, cache_key: &CacheKey, max_region_area: u32) -> CacheQuery {
self.0.lock().unwrap().query(viewport_bounds, scale, cache_key, max_region_area)
}
pub fn store_regions(&self, regions: Vec<CachedRegion>) {
self.0.lock().unwrap().store_regions(regions);
}
}
fn group_into_regions(tiles: &[TileCoord], scale: f64, max_region_area: u32) -> Vec<RenderRegion> {
if tiles.is_empty() {
return Vec::new();
}
let tile_set: HashSet<_> = tiles.iter().cloned().collect();
let mut visited = HashSet::new();
let mut regions = Vec::new();
for &tile in tiles {
if visited.contains(&tile) {
continue;
}
let region_tiles = flood_fill(&tile, &tile_set, &mut visited);
let scene_bounds = tiles_to_scene_bounds(&region_tiles, scale);
let region = RenderRegion {
scene_bounds,
tiles: region_tiles,
scale,
};
regions.extend(split_oversized_region(region, scale, max_region_area));
}
regions
}
/// Recursively subdivides a region until all sub-regions have area <= max_region_area.
/// Uses axis-aligned splits on the longest dimension.
fn split_oversized_region(region: RenderRegion, scale: f64, max_region_area: u32) -> Vec<RenderRegion> {
let pixel_size = region.scene_bounds.size() * scale;
let area = (pixel_size.x * pixel_size.y) as u32;
// Base case: region is small enough
if area <= max_region_area {
return vec![region];
}
// Determine split axis: choose the longer dimension
let split_horizontally = pixel_size.x > pixel_size.y;
// Split tiles into two groups based on midpoint
let mut group1 = Vec::new();
let mut group2 = Vec::new();
if split_horizontally {
// Find midpoint X in tile coordinates
let min_x = region.tiles.iter().map(|t| t.x).min().unwrap();
let max_x = region.tiles.iter().map(|t| t.x).max().unwrap();
let mid_x = (min_x + max_x) / 2;
for &tile in &region.tiles {
if tile.x <= mid_x {
group1.push(tile);
} else {
group2.push(tile);
}
}
} else {
// Split vertically - find midpoint Y
let min_y = region.tiles.iter().map(|t| t.y).min().unwrap();
let max_y = region.tiles.iter().map(|t| t.y).max().unwrap();
let mid_y = (min_y + max_y) / 2;
for &tile in &region.tiles {
if tile.y <= mid_y {
group1.push(tile);
} else {
group2.push(tile);
}
}
}
// Edge case: if split produces empty group, return as-is (can't split further)
if group1.is_empty() || group2.is_empty() {
return vec![region];
}
// Create sub-regions and recursively subdivide
let mut result = Vec::new();
for tiles in [group1, group2] {
if !tiles.is_empty() {
let sub_region = RenderRegion {
scene_bounds: tiles_to_scene_bounds(&tiles, scale),
tiles,
scale,
};
result.extend(split_oversized_region(sub_region, scale, max_region_area));
}
}
result
}
fn flood_fill(start: &TileCoord, tile_set: &HashSet<TileCoord>, visited: &mut HashSet<TileCoord>) -> Vec<TileCoord> {
let mut result = Vec::new();
let mut stack = vec![*start];
while let Some(current) = stack.pop() {
if visited.contains(&current) || !tile_set.contains(&current) {
continue;
}
visited.insert(current);
result.push(current);
for neighbor in [
TileCoord { x: current.x - 1, y: current.y },
TileCoord { x: current.x + 1, y: current.y },
TileCoord { x: current.x, y: current.y - 1 },
TileCoord { x: current.x, y: current.y + 1 },
] {
if tile_set.contains(&neighbor) && !visited.contains(&neighbor) {
stack.push(neighbor);
}
}
}
result
}
#[node_macro::node(category(""))]
pub async fn render_output_cache<'a: 'n>(
ctx: impl Ctx + ExtractAll + CloneVarArgs + ExtractRealTime + ExtractAnimationTime + ExtractPointerPosition + Sync,
editor_api: &'a WasmEditorApi,
data: impl Node<Context<'static>, Output = RenderOutput> + Send + Sync,
#[data] tile_cache: TileCache,
) -> RenderOutput {
let footprint = ctx.footprint();
let Some(render_params) = ctx.vararg(0).ok().and_then(|v| v.downcast_ref::<RenderParams>()) else {
log::warn!("render_output_cache: missing or invalid render params, falling back to direct render");
let context = OwnedContextImpl::empty().with_footprint(*footprint);
return data.eval(context.into_context()).await;
};
// Fall back to direct render for non-Vello or zero-size viewports
let physical_resolution = footprint.resolution;
if !matches!(render_params.render_output_type, RenderOutputTypeRequest::Vello) || physical_resolution.x == 0 || physical_resolution.y == 0 {
let context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone()));
return data.eval(context.into_context()).await;
}
let logical_scale = footprint.decompose_scale().x;
let device_scale = render_params.scale;
let physical_scale = logical_scale * device_scale;
let viewport_bounds = footprint.viewport_bounds_in_local_space();
let cache_key = CacheKey::new(
render_params.render_mode as u64,
render_params.hide_artboards,
render_params.for_export,
render_params.for_mask,
render_params.thumbnail,
render_params.aligned_strokes,
render_params.override_paint_order,
ctx.try_animation_time().unwrap_or(0.0),
ctx.try_real_time().unwrap_or(0.0),
ctx.try_pointer_position(),
);
let max_region_area = editor_api.editor_preferences.max_render_region_area();
let cache_query = tile_cache.query(&viewport_bounds, logical_scale, &cache_key, max_region_area);
let mut new_regions = Vec::new();
for missing_region in &cache_query.missing_regions {
if missing_region.tiles.is_empty() {
continue;
}
let region = render_missing_region(missing_region, |ctx| data.eval(ctx), ctx.clone(), render_params, logical_scale, device_scale).await;
new_regions.push(region);
}
tile_cache.store_regions(new_regions.clone());
let all_regions: Vec<_> = cache_query.cached_regions.into_iter().chain(new_regions.into_iter()).collect();
// If no regions, fall back to direct render
if all_regions.is_empty() {
let context = OwnedContextImpl::empty().with_footprint(*footprint).with_vararg(Box::new(render_params.clone()));
return data.eval(context.into_context()).await;
}
let exec = editor_api.application_io.as_ref().unwrap().gpu_executor().unwrap();
let (output_texture, combined_metadata) = composite_cached_regions(&all_regions, &viewport_bounds, physical_resolution, logical_scale, physical_scale, exec);
RenderOutput {
data: RenderOutputType::Texture(ImageTexture { texture: output_texture }),
metadata: combined_metadata,
}
}
async fn render_missing_region<F, Fut>(
region: &RenderRegion,
render_fn: F,
ctx: impl Ctx + ExtractAll + CloneVarArgs,
render_params: &RenderParams,
logical_scale: f64,
device_scale: f64,
) -> CachedRegion
where
F: Fn(Context<'static>) -> Fut,
Fut: std::future::Future<Output = RenderOutput>,
{
let min_tile = region.tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y)));
let max_tile = region.tiles.iter().fold(IVec2::new(i32::MIN, i32::MIN), |acc, t| acc.max(IVec2::new(t.x, t.y)));
let tile_scene_size = TILE_SIZE as f64 / logical_scale;
let region_scene_start = DVec2::new(min_tile.x as f64 * tile_scene_size, min_tile.y as f64 * tile_scene_size);
// Calculate pixel size from tile boundaries to avoid rounding gaps
// Use round() on boundaries to ensure adjacent tiles share the same edge
let pixel_start = (min_tile.as_dvec2() * TILE_SIZE as f64 * device_scale).round().as_ivec2();
let pixel_end = ((max_tile + IVec2::ONE).as_dvec2() * TILE_SIZE as f64 * device_scale).round().as_ivec2();
let region_pixel_size = (pixel_end - pixel_start).max(IVec2::ONE).as_uvec2();
let region_transform = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-region_scene_start);
let region_footprint = Footprint {
transform: region_transform,
resolution: region_pixel_size,
quality: RenderQuality::Full,
};
let region_params = render_params.clone();
let region_ctx = OwnedContextImpl::from(ctx).with_footprint(region_footprint).with_vararg(Box::new(region_params)).into_context();
let mut result = render_fn(region_ctx).await;
let RenderOutputType::Texture(rendered_texture) = result.data else {
unreachable!("render_missing_region: expected texture output from Vello render");
};
// Transform metadata from region pixel space to document space
let pixel_to_document = glam::DAffine2::from_translation(region_scene_start) * glam::DAffine2::from_scale(DVec2::splat(1.0 / logical_scale));
result.metadata.apply_transform(pixel_to_document);
let memory_size = (region_pixel_size.x * region_pixel_size.y) as usize * BYTES_PER_PIXEL;
CachedRegion {
texture: rendered_texture.texture,
texture_size: region_pixel_size,
scene_bounds: region.scene_bounds.clone(),
tiles: region.tiles.clone(),
metadata: result.metadata,
last_access: 0,
memory_size,
}
}
fn composite_cached_regions(
regions: &[CachedRegion],
viewport_bounds: &AxisAlignedBbox,
output_resolution: UVec2,
logical_scale: f64,
physical_scale: f64,
exec: &wgpu_executor::WgpuExecutor,
) -> (wgpu::Texture, rendering::RenderMetadata) {
let device = &exec.context.device;
let queue = &exec.context.queue;
// TODO: Use texture pool to reuse existing unused textures instead of allocating fresh ones every time
let output_texture = device.create_texture(&wgpu::TextureDescriptor {
label: Some("viewport_output"),
size: wgpu::Extent3d {
width: output_resolution.x,
height: output_resolution.y,
depth_or_array_layers: 1,
},
mip_level_count: 1,
sample_count: 1,
dimension: wgpu::TextureDimension::D2,
format: wgpu::TextureFormat::Rgba8Unorm,
usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_DST | wgpu::TextureUsages::COPY_SRC | wgpu::TextureUsages::TEXTURE_BINDING,
view_formats: &[],
});
let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { label: Some("composite") });
let mut combined_metadata = rendering::RenderMetadata::default();
// Calculate viewport pixel offset using round() to match region boundary calculations
let device_scale = physical_scale / logical_scale;
let viewport_pixel_start = (viewport_bounds.start * physical_scale).round().as_ivec2();
for region in regions {
let min_tile = region.tiles.iter().fold(IVec2::new(i32::MAX, i32::MAX), |acc, t| acc.min(IVec2::new(t.x, t.y)));
// Use round() on tile boundaries to match render_missing_region calculation
let region_pixel_start = (min_tile.as_dvec2() * TILE_SIZE as f64 * device_scale).round().as_ivec2();
let offset_pixels = region_pixel_start - viewport_pixel_start;
let (src_x, dst_x, width) = if offset_pixels.x >= 0 {
(0, offset_pixels.x as u32, region.texture_size.x.min(output_resolution.x.saturating_sub(offset_pixels.x as u32)))
} else {
let skip = (-offset_pixels.x) as u32;
(skip, 0, region.texture_size.x.saturating_sub(skip).min(output_resolution.x))
};
let (src_y, dst_y, height) = if offset_pixels.y >= 0 {
(0, offset_pixels.y as u32, region.texture_size.y.min(output_resolution.y.saturating_sub(offset_pixels.y as u32)))
} else {
let skip = (-offset_pixels.y) as u32;
(skip, 0, region.texture_size.y.saturating_sub(skip).min(output_resolution.y))
};
if width > 0 && height > 0 {
encoder.copy_texture_to_texture(
wgpu::TexelCopyTextureInfo {
texture: &region.texture,
mip_level: 0,
origin: wgpu::Origin3d { x: src_x, y: src_y, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::TexelCopyTextureInfo {
texture: &output_texture,
mip_level: 0,
origin: wgpu::Origin3d { x: dst_x, y: dst_y, z: 0 },
aspect: wgpu::TextureAspect::All,
},
wgpu::Extent3d {
width,
height,
depth_or_array_layers: 1,
},
);
}
// Transform metadata from document space to viewport logical pixels
let mut region_metadata = region.metadata.clone();
let document_to_viewport = glam::DAffine2::from_scale(DVec2::splat(logical_scale)) * glam::DAffine2::from_translation(-viewport_bounds.start);
region_metadata.apply_transform(document_to_viewport);
combined_metadata.merge(&region_metadata);
}
queue.submit([encoder.finish()]);
(output_texture, combined_metadata)
}

View File

@@ -18,6 +18,9 @@ use std::sync::Arc;
use vector_types::GradientStops;
use wgpu_executor::RenderContext;
// Re-export render_output_cache from render_cache module
pub use crate::render_cache::render_output_cache;
/// List of (canvas id, image data) pairs for embedding images as canvases in the final SVG string.
type ImageData = HashMap<Image<Color>, u64>;
@@ -28,9 +31,9 @@ pub enum RenderIntermediateType {
}
#[derive(Clone, dyn_any::DynAny)]
pub struct RenderIntermediate {
ty: RenderIntermediateType,
metadata: RenderMetadata,
contains_artboard: bool,
pub(crate) ty: RenderIntermediateType,
pub(crate) metadata: RenderMetadata,
pub(crate) contains_artboard: bool,
}
#[node_macro::node(category(""))]