Skip to content

Commit a932eae

Browse files
authored
Render artwork at correct resolution when using vello on wasm (#3416)
* Work on fixing rendering for wasm+vello * Render vello canvas in wasm at the correct resolution * Cleanup unused surface rendering code * Remove vector to raster conversion * Remove desktop changes * Revert window.rs changes * Don't round logical coordinates * Fix desktop compilation + don't round logical coordinates for svg rendering * Further cleanup * Compute logical size from acutal physical sizes
1 parent 6e66c79 commit a932eae

File tree

13 files changed

+197
-216
lines changed

13 files changed

+197
-216
lines changed

desktop/src/window.rs

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,6 @@ use winit::window::{Window as WinitWindow, WindowAttributes};
44

55
use crate::consts::APP_NAME;
66
use crate::event::AppEventScheduler;
7-
use crate::window::mac::NativeWindowImpl;
87
use crate::wrapper::messages::MenuItem;
98

109
pub(crate) trait NativeWindow {
@@ -37,7 +36,7 @@ pub(crate) struct Window {
3736

3837
impl Window {
3938
pub(crate) fn init() {
40-
NativeWindowImpl::init();
39+
native::NativeWindowImpl::init();
4140
}
4241

4342
pub(crate) fn new(event_loop: &dyn ActiveEventLoop, app_event_scheduler: AppEventScheduler) -> Self {

editor/src/messages/portfolio/document/node_graph/document_node_definitions.rs

Lines changed: 0 additions & 64 deletions
Original file line numberDiff line numberDiff line change
@@ -882,70 +882,6 @@ fn static_nodes() -> Vec<DocumentNodeDefinition> {
882882
properties: None,
883883
},
884884
#[cfg(feature = "gpu")]
885-
DocumentNodeDefinition {
886-
identifier: "Create GPU Surface",
887-
category: "Debug: GPU",
888-
node_template: NodeTemplate {
889-
document_node: DocumentNode {
890-
implementation: DocumentNodeImplementation::Network(NodeNetwork {
891-
exports: vec![NodeInput::node(NodeId(1), 0)],
892-
nodes: [
893-
DocumentNode {
894-
inputs: vec![NodeInput::scope("editor-api")],
895-
implementation: DocumentNodeImplementation::ProtoNode(wgpu_executor::create_gpu_surface::IDENTIFIER),
896-
..Default::default()
897-
},
898-
DocumentNode {
899-
inputs: vec![NodeInput::node(NodeId(0), 0)],
900-
implementation: DocumentNodeImplementation::ProtoNode(memo::memo::IDENTIFIER),
901-
..Default::default()
902-
},
903-
]
904-
.into_iter()
905-
.enumerate()
906-
.map(|(id, node)| (NodeId(id as u64), node))
907-
.collect(),
908-
..Default::default()
909-
}),
910-
..Default::default()
911-
},
912-
persistent_node_metadata: DocumentNodePersistentMetadata {
913-
output_names: vec!["GPU Surface".to_string()],
914-
network_metadata: Some(NodeNetworkMetadata {
915-
persistent_metadata: NodeNetworkPersistentMetadata {
916-
node_metadata: [
917-
DocumentNodeMetadata {
918-
persistent_metadata: DocumentNodePersistentMetadata {
919-
display_name: "Create GPU Surface".to_string(),
920-
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(0, 0)),
921-
..Default::default()
922-
},
923-
..Default::default()
924-
},
925-
DocumentNodeMetadata {
926-
persistent_metadata: DocumentNodePersistentMetadata {
927-
display_name: "Cache".to_string(),
928-
node_type_metadata: NodeTypePersistentMetadata::node(IVec2::new(7, 0)),
929-
..Default::default()
930-
},
931-
..Default::default()
932-
},
933-
]
934-
.into_iter()
935-
.enumerate()
936-
.map(|(id, node)| (NodeId(id as u64), node))
937-
.collect(),
938-
..Default::default()
939-
},
940-
..Default::default()
941-
}),
942-
..Default::default()
943-
},
944-
},
945-
description: Cow::Borrowed("TODO"),
946-
properties: None,
947-
},
948-
#[cfg(feature = "gpu")]
949885
DocumentNodeDefinition {
950886
identifier: "Upload Texture",
951887
category: "Debug: GPU",

editor/src/messages/portfolio/portfolio_message_handler.rs

Lines changed: 7 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ use crate::messages::prelude::*;
2121
use crate::messages::tool::common_functionality::graph_modification_utils;
2222
use crate::messages::tool::common_functionality::utility_functions::make_path_editable_is_allowed;
2323
use crate::messages::tool::utility_types::{HintData, HintGroup, ToolType};
24+
use crate::messages::viewport::ToPhysical;
2425
use crate::node_graph_executor::{ExportConfig, NodeGraphExecutor};
2526
use derivative::*;
2627
use glam::{DAffine2, DVec2};
@@ -364,12 +365,13 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
364365
let node_to_inspect = self.node_to_inspect();
365366

366367
let scale = viewport.scale();
367-
let resolution = viewport.size().into_dvec2().round().as_uvec2();
368+
// Use exact physical dimensions from browser (via ResizeObserver's devicePixelContentBoxSize)
369+
let physical_resolution = viewport.size().to_physical().into_dvec2().round().as_uvec2();
368370

369371
if let Ok(message) = self.executor.submit_node_graph_evaluation(
370372
self.documents.get_mut(document_id).expect("Tried to render non-existent document"),
371373
*document_id,
372-
resolution,
374+
physical_resolution,
373375
scale,
374376
timing_information,
375377
node_to_inspect,
@@ -970,11 +972,12 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
970972
};
971973

972974
let scale = viewport.scale();
973-
let resolution = viewport.size().into_dvec2().round().as_uvec2();
975+
// Use exact physical dimensions from browser (via ResizeObserver's devicePixelContentBoxSize)
976+
let physical_resolution = viewport.size().to_physical().into_dvec2().round().as_uvec2();
974977

975978
let result = self
976979
.executor
977-
.submit_node_graph_evaluation(document, document_id, resolution, scale, timing_information, node_to_inspect, ignore_hash);
980+
.submit_node_graph_evaluation(document, document_id, physical_resolution, scale, timing_information, node_to_inspect, ignore_hash);
978981

979982
match result {
980983
Err(description) => {

editor/src/node_graph_executor.rs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -421,8 +421,8 @@ impl NodeGraphExecutor {
421421
let matrix = format_transform_matrix(frame.transform);
422422
let transform = if matrix.is_empty() { String::new() } else { format!(" transform=\"{matrix}\"") };
423423
let svg = format!(
424-
r#"<svg><foreignObject width="{}" height="{}"{transform}><div data-canvas-placeholder="{}"></div></foreignObject></svg>"#,
425-
frame.resolution.x, frame.resolution.y, frame.surface_id.0
424+
r#"<svg><foreignObject width="{}" height="{}"{transform}><div data-canvas-placeholder="{}" data-is-viewport="true"></div></foreignObject></svg>"#,
425+
frame.resolution.x, frame.resolution.y, frame.surface_id.0,
426426
);
427427
self.last_svg_canvas = Some(frame);
428428
responses.add(FrontendMessage::UpdateDocumentArtwork { svg });

editor/src/node_graph_executor/runtime.rs

Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -55,6 +55,10 @@ pub struct NodeRuntime {
5555
/// The current renders of the thumbnails for layer nodes.
5656
thumbnail_renders: HashMap<NodeId, Vec<SvgSegment>>,
5757
vector_modify: HashMap<NodeId, Vector>,
58+
59+
/// Cached surface for WASM viewport rendering (reused across frames)
60+
#[cfg(all(target_family = "wasm", feature = "gpu"))]
61+
wasm_viewport_surface: Option<wgpu_executor::WgpuSurface>,
5862
}
5963

6064
/// Messages passed from the editor thread to the node runtime thread.
@@ -131,6 +135,8 @@ impl NodeRuntime {
131135
thumbnail_renders: Default::default(),
132136
vector_modify: Default::default(),
133137
inspect_state: None,
138+
#[cfg(all(target_family = "wasm", feature = "gpu"))]
139+
wasm_viewport_surface: None,
134140
}
135141
}
136142

@@ -259,6 +265,82 @@ impl NodeRuntime {
259265
None,
260266
)
261267
}
268+
#[cfg(all(target_family = "wasm", feature = "gpu"))]
269+
Ok(TaggedValue::RenderOutput(RenderOutput {
270+
data: RenderOutputType::Texture(image_texture),
271+
metadata,
272+
})) if !render_config.for_export => {
273+
// On WASM, for viewport rendering, blit the texture to a surface and return a CanvasFrame
274+
let app_io = self.editor_api.application_io.as_ref().unwrap();
275+
let executor = app_io.gpu_executor().expect("GPU executor should be available when we receive a texture");
276+
277+
// Get or create the cached surface
278+
if self.wasm_viewport_surface.is_none() {
279+
let surface_handle = app_io.create_window();
280+
let wasm_surface = executor
281+
.create_surface(graphene_std::wasm_application_io::WasmSurfaceHandle {
282+
surface: surface_handle.surface.clone(),
283+
window_id: surface_handle.window_id,
284+
})
285+
.expect("Failed to create surface");
286+
self.wasm_viewport_surface = Some(Arc::new(wasm_surface));
287+
}
288+
289+
let surface = self.wasm_viewport_surface.as_ref().unwrap();
290+
291+
// Use logical resolution for CSS sizing, physical resolution for the actual surface/texture
292+
let physical_resolution = render_config.viewport.resolution;
293+
let logical_resolution = physical_resolution.as_dvec2() / render_config.scale;
294+
295+
// Blit the texture to the surface
296+
let mut encoder = executor.context.device.create_command_encoder(&vello::wgpu::CommandEncoderDescriptor {
297+
label: Some("Texture to Surface Blit"),
298+
});
299+
300+
// Configure the surface at physical resolution (for HiDPI displays)
301+
let surface_inner = &surface.surface.inner;
302+
let surface_caps = surface_inner.get_capabilities(&executor.context.adapter);
303+
surface_inner.configure(
304+
&executor.context.device,
305+
&vello::wgpu::SurfaceConfiguration {
306+
usage: vello::wgpu::TextureUsages::RENDER_ATTACHMENT | vello::wgpu::TextureUsages::COPY_DST,
307+
format: vello::wgpu::TextureFormat::Rgba8Unorm,
308+
width: physical_resolution.x,
309+
height: physical_resolution.y,
310+
present_mode: surface_caps.present_modes[0],
311+
alpha_mode: vello::wgpu::CompositeAlphaMode::Opaque,
312+
view_formats: vec![],
313+
desired_maximum_frame_latency: 2,
314+
},
315+
);
316+
317+
let surface_texture = surface_inner.get_current_texture().expect("Failed to get surface texture");
318+
319+
// Blit the rendered texture to the surface
320+
surface.surface.blitter.copy(
321+
&executor.context.device,
322+
&mut encoder,
323+
&image_texture.texture.create_view(&vello::wgpu::TextureViewDescriptor::default()),
324+
&surface_texture.texture.create_view(&vello::wgpu::TextureViewDescriptor::default()),
325+
);
326+
327+
executor.context.queue.submit([encoder.finish()]);
328+
surface_texture.present();
329+
330+
let frame = graphene_std::application_io::SurfaceFrame {
331+
surface_id: surface.window_id,
332+
resolution: logical_resolution,
333+
transform: glam::DAffine2::IDENTITY,
334+
};
335+
336+
(
337+
Ok(TaggedValue::RenderOutput(RenderOutput {
338+
data: RenderOutputType::CanvasFrame(frame),
339+
metadata,
340+
})),
341+
None,
342+
)
343+
}
262344
Ok(TaggedValue::RenderOutput(RenderOutput {
263345
data: RenderOutputType::Texture(texture),
264346
metadata,

frontend/src/components/panels/Document.svelte

Lines changed: 29 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
<script lang="ts">
2-
import { getContext, onMount, tick } from "svelte";
2+
import { getContext, onMount, onDestroy, tick } from "svelte";
33
44
import type { Editor } from "@graphite/editor";
55
import {
@@ -20,7 +20,7 @@
2020
import type { DocumentState } from "@graphite/state-providers/document";
2121
import { textInputCleanup } from "@graphite/utility-functions/keyboard-entry";
2222
import { extractPixelData, rasterizeSVGCanvas } from "@graphite/utility-functions/rasterization";
23-
import { updateBoundsOfViewports as updateViewport } from "@graphite/utility-functions/viewports";
23+
import { setupViewportResizeObserver, cleanupViewportResizeObserver } from "@graphite/utility-functions/viewports";
2424
2525
import EyedropperPreview, { ZOOM_WINDOW_DIMENSIONS } from "@graphite/components/floating-menus/EyedropperPreview.svelte";
2626
import LayoutCol from "@graphite/components/layout/LayoutCol.svelte";
@@ -203,9 +203,18 @@
203203
// eslint-disable-next-line @typescript-eslint/no-explicit-any
204204
let canvas = (window as any).imageCanvases[canvasName];
205205
206-
if (canvasName !== "0" && canvas.parentElement) {
207-
var newCanvas = window.document.createElement("canvas");
208-
var context = newCanvas.getContext("2d");
206+
// Get logical dimensions from foreignObject parent (set by backend)
207+
const foreignObject = placeholder.parentElement;
208+
if (!foreignObject) return;
209+
const logicalWidth = parseFloat(foreignObject.getAttribute("width") || "0");
210+
const logicalHeight = parseFloat(foreignObject.getAttribute("height") || "0");
211+
212+
// Clone canvas for repeated instances (layers that appear multiple times)
213+
// Viewport canvas is marked with data-is-viewport and should never be cloned
214+
const isViewport = placeholder.hasAttribute("data-is-viewport");
215+
if (!isViewport && canvas.parentElement) {
216+
const newCanvas = window.document.createElement("canvas");
217+
const context = newCanvas.getContext("2d");
209218
210219
newCanvas.width = canvas.width;
211220
newCanvas.height = canvas.height;
@@ -215,6 +224,10 @@
215224
canvas = newCanvas;
216225
}
217226
227+
// Set CSS size to logical resolution (for correct display size)
228+
canvas.style.width = `${logicalWidth}px`;
229+
canvas.style.height = `${logicalHeight}px`;
230+
218231
placeholder.replaceWith(canvas);
219232
});
220233
}
@@ -393,8 +406,8 @@
393406
rulerHorizontal?.resize();
394407
rulerVertical?.resize();
395408
396-
// Send the new bounds of the viewports to the backend
397-
if (viewport.parentElement) updateViewport(editor);
409+
// Note: Viewport bounds are now sent to the backend by the ResizeObserver in viewports.ts
410+
// which provides pixel-perfect physical dimensions via devicePixelContentBoxSize
398411
}
399412
400413
onMount(() => {
@@ -473,14 +486,21 @@
473486
displayRemoveEditableTextbox();
474487
});
475488
476-
// Once this component is mounted, we want to resend the document bounds to the backend via the resize event handler which does that
477-
window.dispatchEvent(new Event("resize"));
489+
// Setup ResizeObserver for pixel-perfect viewport tracking with physical dimensions
490+
// This must happen in onMount to ensure the viewport container element exists
491+
setupViewportResizeObserver(editor);
478492
493+
// Also observe the inner viewport for canvas sizing and ruler updates
479494
const viewportResizeObserver = new ResizeObserver(() => {
480495
updateViewportInfo();
481496
});
482497
if (viewport) viewportResizeObserver.observe(viewport);
483498
});
499+
500+
onDestroy(() => {
501+
// Cleanup the viewport resize observer
502+
cleanupViewportResizeObserver();
503+
});
484504
</script>
485505

486506
<LayoutCol class="document" on:dragover={(e) => e.preventDefault()} on:drop={dropFile}>

frontend/src/io-managers/input.ts

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,6 @@ import { makeKeyboardModifiersBitfield, textInputCleanup, getLocalizedScanCode }
1010
import { operatingSystem } from "@graphite/utility-functions/platform";
1111
import { extractPixelData } from "@graphite/utility-functions/rasterization";
1212
import { stripIndents } from "@graphite/utility-functions/strip-indents";
13-
import { updateBoundsOfViewports } from "@graphite/utility-functions/viewports";
1413

1514
const BUTTON_LEFT = 0;
1615
const BUTTON_MIDDLE = 1;
@@ -43,7 +42,6 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
4342

4443
// eslint-disable-next-line @typescript-eslint/no-explicit-any
4544
const listeners: { target: EventListenerTarget; eventName: EventName; action: (event: any) => void; options?: AddEventListenerOptions }[] = [
46-
{ target: window, eventName: "resize", action: () => updateBoundsOfViewports(editor) },
4745
{ target: window, eventName: "beforeunload", action: (e: BeforeUnloadEvent) => onBeforeUnload(e) },
4846
{ target: window, eventName: "keyup", action: (e: KeyboardEvent) => onKeyUp(e) },
4947
{ target: window, eventName: "keydown", action: (e: KeyboardEvent) => onKeyDown(e) },
@@ -529,8 +527,6 @@ export function createInputManager(editor: Editor, dialog: DialogState, portfoli
529527

530528
// Bind the event listeners
531529
bindListeners();
532-
// Resize on creation
533-
updateBoundsOfViewports(editor);
534530

535531
// Return the destructor
536532
return unbindListeners;

0 commit comments

Comments
 (0)