Skip to content

Commit c40cee5

Browse files
committed
Render vello canvas in wasm at the correct resolution
1 parent cd6f37f commit c40cee5

File tree

10 files changed

+142
-36
lines changed

10 files changed

+142
-36
lines changed

editor/src/messages/portfolio/portfolio_message_handler.rs

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -364,12 +364,16 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
364364
let node_to_inspect = self.node_to_inspect();
365365

366366
let scale = viewport.scale();
367-
let resolution = viewport.size().into_dvec2().round().as_uvec2();
367+
// Use logical dimensions for viewport resolution (foreignObject sizing)
368+
let logical_resolution = viewport.size().into_dvec2().ceil().as_uvec2();
369+
// Use exact physical dimensions from browser (via ResizeObserver's devicePixelContentBoxSize)
370+
let physical_resolution = viewport.physical_size_uvec2();
368371

369372
if let Ok(message) = self.executor.submit_node_graph_evaluation(
370373
self.documents.get_mut(document_id).expect("Tried to render non-existent document"),
371374
*document_id,
372-
resolution,
375+
logical_resolution,
376+
physical_resolution,
373377
scale,
374378
timing_information,
375379
node_to_inspect,
@@ -970,11 +974,14 @@ impl MessageHandler<PortfolioMessage, PortfolioMessageContext<'_>> for Portfolio
970974
};
971975

972976
let scale = viewport.scale();
973-
let resolution = viewport.size().into_dvec2().round().as_uvec2();
977+
// Use logical dimensions for viewport resolution (foreignObject sizing)
978+
let logical_resolution = viewport.size().into_dvec2().ceil().as_uvec2();
979+
// Use exact physical dimensions from browser (via ResizeObserver's devicePixelContentBoxSize)
980+
let physical_resolution = viewport.physical_size_uvec2();
974981

975982
let result = self
976983
.executor
977-
.submit_node_graph_evaluation(document, document_id, resolution, scale, timing_information, node_to_inspect, ignore_hash);
984+
.submit_node_graph_evaluation(document, document_id, logical_resolution, physical_resolution, scale, timing_information, node_to_inspect, ignore_hash);
978985

979986
match result {
980987
Err(description) => {

editor/src/messages/viewport/viewport_message.rs

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,14 @@ use crate::messages::prelude::*;
33
#[impl_message(Message, Viewport)]
44
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
55
pub enum ViewportMessage {
6-
Update { x: f64, y: f64, width: f64, height: f64, scale: f64 },
6+
Update {
7+
x: f64,
8+
y: f64,
9+
width: f64,
10+
height: f64,
11+
scale: f64,
12+
physical_width: f64,
13+
physical_height: f64,
14+
},
715
RepropagateUpdate,
816
}

editor/src/messages/viewport/viewport_message_handler.rs

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,8 @@ use crate::messages::tool::tool_messages::tool_prelude::DVec2;
66
#[derive(Debug, PartialEq, Clone, Copy, serde::Serialize, serde::Deserialize, specta::Type, ExtractField)]
77
pub struct ViewportMessageHandler {
88
bounds: Bounds,
9+
// Physical bounds in device pixels (exact pixel dimensions from browser)
10+
physical_bounds: Bounds,
911
// Ratio of logical pixels to physical pixels
1012
scale: f64,
1113
}
@@ -16,6 +18,10 @@ impl Default for ViewportMessageHandler {
1618
offset: Point { x: 0.0, y: 0.0 },
1719
size: Point { x: 0.0, y: 0.0 },
1820
},
21+
physical_bounds: Bounds {
22+
offset: Point { x: 0.0, y: 0.0 },
23+
size: Point { x: 0.0, y: 0.0 },
24+
},
1925
scale: 1.0,
2026
}
2127
}
@@ -25,14 +31,29 @@ impl Default for ViewportMessageHandler {
2531
impl MessageHandler<ViewportMessage, ()> for ViewportMessageHandler {
2632
fn process_message(&mut self, message: ViewportMessage, responses: &mut VecDeque<Message>, _: ()) {
2733
match message {
28-
ViewportMessage::Update { x, y, width, height, scale } => {
34+
ViewportMessage::Update {
35+
x,
36+
y,
37+
width,
38+
height,
39+
scale,
40+
physical_width,
41+
physical_height,
42+
} => {
2943
assert_ne!(scale, 0.0, "Viewport scale cannot be zero");
3044
self.scale = scale;
3145

3246
self.bounds = Bounds {
3347
offset: Point { x, y },
3448
size: Point { x: width, y: height },
3549
};
50+
self.physical_bounds = Bounds {
51+
offset: Point { x, y },
52+
size: Point {
53+
x: physical_width,
54+
y: physical_height,
55+
},
56+
};
3657
responses.add(NodeGraphMessage::UpdateNodeGraphWidth);
3758
}
3859
ViewportMessage::RepropagateUpdate => {}
@@ -81,6 +102,11 @@ impl ViewportMessageHandler {
81102
self.bounds.size().into_scaled(self.scale)
82103
}
83104

105+
pub fn physical_size_uvec2(&self) -> glam::UVec2 {
106+
let size = self.physical_bounds.size();
107+
glam::UVec2::new(size.x.ceil() as u32, size.y.ceil() as u32)
108+
}
109+
84110
#[expect(private_bounds)]
85111
pub fn logical<T: Into<Point>>(&self, point: T) -> LogicalPoint {
86112
point.into().convert_to_logical(self.scale)

editor/src/node_graph_executor.rs

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,7 @@ impl NodeGraphExecutor {
138138
document: &mut DocumentMessageHandler,
139139
document_id: DocumentId,
140140
viewport_resolution: UVec2,
141+
physical_viewport_resolution: UVec2,
141142
viewport_scale: f64,
142143
time: TimingInformation,
143144
) -> Result<Message, String> {
@@ -148,6 +149,7 @@ impl NodeGraphExecutor {
148149
};
149150
let render_config = RenderConfig {
150151
viewport,
152+
physical_viewport_resolution,
151153
scale: viewport_scale,
152154
time,
153155
export_format: graphene_std::application_io::ExportFormat::Raster,
@@ -171,13 +173,14 @@ impl NodeGraphExecutor {
171173
document: &mut DocumentMessageHandler,
172174
document_id: DocumentId,
173175
viewport_resolution: UVec2,
176+
physical_viewport_resolution: UVec2,
174177
viewport_scale: f64,
175178
time: TimingInformation,
176179
node_to_inspect: Option<NodeId>,
177180
ignore_hash: bool,
178181
) -> Result<Message, String> {
179182
self.update_node_graph(document, node_to_inspect, ignore_hash)?;
180-
self.submit_current_node_graph_evaluation(document, document_id, viewport_resolution, viewport_scale, time)
183+
self.submit_current_node_graph_evaluation(document, document_id, viewport_resolution, physical_viewport_resolution, viewport_scale, time)
181184
}
182185

183186
/// Evaluates a node graph for export
@@ -206,6 +209,8 @@ impl NodeGraphExecutor {
206209
transform,
207210
..Default::default()
208211
},
212+
// For export, logical and physical are the same (no HiDPI scaling)
213+
physical_viewport_resolution: resolution,
209214
scale: export_config.scale_factor,
210215
time: Default::default(),
211216
export_format,
@@ -420,9 +425,10 @@ impl NodeGraphExecutor {
420425
}
421426
let matrix = format_transform_matrix(frame.transform);
422427
let transform = if matrix.is_empty() { String::new() } else { format!(" transform=\"{matrix}\"") };
428+
// Mark viewport canvas with data attribute so it won't be cloned for repeated instances
423429
let svg = format!(
424-
r#"<svg><foreignObject width="{}" height="{}"{transform}><div data-canvas-placeholder="{}" data-physical-width="{}" data-physical-height="{}"></div></foreignObject></svg>"#,
425-
frame.resolution.x, frame.resolution.y, frame.surface_id.0, frame.physical_resolution.x, frame.physical_resolution.y
430+
r#"<svg><foreignObject width="{}" height="{}"{transform}><div data-canvas-placeholder="{}" data-is-viewport="true"></div></foreignObject></svg>"#,
431+
frame.resolution.x, frame.resolution.y, frame.surface_id.0
426432
);
427433
self.last_svg_canvas = Some(frame);
428434
responses.add(FrontendMessage::UpdateDocumentArtwork { svg });

editor/src/node_graph_executor/runtime.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -290,7 +290,7 @@ impl NodeRuntime {
290290

291291
// Use logical resolution for CSS sizing, physical resolution for the actual surface/texture
292292
let logical_resolution = render_config.viewport.resolution;
293-
let physical_resolution = (logical_resolution.as_dvec2() * render_config.scale).as_uvec2();
293+
let physical_resolution = render_config.physical_viewport_resolution;
294294

295295
// Blit the texture to the surface
296296
let mut encoder = executor.context.device.create_command_encoder(&vello::wgpu::CommandEncoderDescriptor {

frontend/src/components/panels/Document.svelte

Lines changed: 24 additions & 15 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";
@@ -209,18 +209,20 @@
209209
const logicalWidth = parseInt(foreignObject.getAttribute("width") || "0");
210210
const logicalHeight = parseInt(foreignObject.getAttribute("height") || "0");
211211
212-
// if (canvasName !== "0" && canvas.parentElement) {
213-
// console.log("test");
214-
// var newCanvas = window.document.createElement("canvas");
215-
// var context = newCanvas.getContext("2d");
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");
216218
217-
// newCanvas.width = canvas.width;
218-
// newCanvas.height = canvas.height;
219+
newCanvas.width = canvas.width;
220+
newCanvas.height = canvas.height;
219221
220-
// context?.drawImage(canvas, 0, 0);
222+
context?.drawImage(canvas, 0, 0);
221223
222-
// canvas = newCanvas;
223-
// }
224+
canvas = newCanvas;
225+
}
224226
225227
// Set CSS size to logical resolution (for correct display size)
226228
canvas.style.width = `${logicalWidth}px`;
@@ -404,8 +406,8 @@
404406
rulerHorizontal?.resize();
405407
rulerVertical?.resize();
406408
407-
// Send the new bounds of the viewports to the backend
408-
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
409411
}
410412
411413
onMount(() => {
@@ -484,14 +486,21 @@
484486
displayRemoveEditableTextbox();
485487
});
486488
487-
// Once this component is mounted, we want to resend the document bounds to the backend via the resize event handler which does that
488-
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);
489492
493+
// Also observe the inner viewport for canvas sizing and ruler updates
490494
const viewportResizeObserver = new ResizeObserver(() => {
491495
updateViewportInfo();
492496
});
493497
if (viewport) viewportResizeObserver.observe(viewport);
494498
});
499+
500+
onDestroy(() => {
501+
// Cleanup the viewport resize observer
502+
cleanupViewportResizeObserver();
503+
});
495504
</script>
496505

497506
<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;
Lines changed: 49 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,56 @@
11
import { type Editor } from "@graphite/editor";
22

3-
export function updateBoundsOfViewports(editor: Editor) {
4-
const viewports = Array.from(window.document.querySelectorAll("[data-viewport-container]"));
3+
let resizeObserver: ResizeObserver | undefined;
4+
5+
export function setupViewportResizeObserver(editor: Editor) {
6+
// Clean up existing observer if any
7+
if (resizeObserver) {
8+
resizeObserver.disconnect();
9+
}
510

11+
const viewports = Array.from(window.document.querySelectorAll("[data-viewport-container]"));
612
if (viewports.length <= 0) return;
713

8-
const bounds = viewports[0].getBoundingClientRect();
9-
const scale = window.devicePixelRatio || 1;
14+
const viewport = viewports[0] as HTMLElement;
15+
16+
resizeObserver = new ResizeObserver((entries) => {
17+
for (const entry of entries) {
18+
const devicePixelRatio = window.devicePixelRatio || 1;
19+
20+
// Get exact device pixel dimensions from the browser
21+
// Use devicePixelContentBoxSize for pixel-perfect rendering with fallback for Safari
22+
let physicalWidth: number;
23+
let physicalHeight: number;
24+
25+
if (entry.devicePixelContentBoxSize && entry.devicePixelContentBoxSize.length > 0) {
26+
// Modern browsers (Chrome, Firefox): get exact device pixels from the browser
27+
physicalWidth = entry.devicePixelContentBoxSize[0].inlineSize;
28+
physicalHeight = entry.devicePixelContentBoxSize[0].blockSize;
29+
} else {
30+
// Fallback for Safari: calculate from contentBoxSize and devicePixelRatio
31+
physicalWidth = entry.contentBoxSize[0].inlineSize * devicePixelRatio;
32+
physicalHeight = entry.contentBoxSize[0].blockSize * devicePixelRatio;
33+
}
34+
35+
// Get logical dimensions from contentBoxSize (these may be fractional pixels)
36+
const logicalWidth = entry.contentBoxSize[0].inlineSize;
37+
const logicalHeight = entry.contentBoxSize[0].blockSize;
38+
39+
// Get viewport position
40+
const bounds = entry.target.getBoundingClientRect();
41+
42+
// Send both logical and physical dimensions to the backend
43+
// Logical dimensions are used for CSS/SVG sizing, physical for GPU textures
44+
editor.handle.updateViewport(bounds.x, bounds.y, logicalWidth, logicalHeight, devicePixelRatio, physicalWidth, physicalHeight);
45+
}
46+
});
47+
48+
resizeObserver.observe(viewport);
49+
}
1050

11-
editor.handle.updateViewport(bounds.x, bounds.y, bounds.width, bounds.height, scale);
51+
export function cleanupViewportResizeObserver() {
52+
if (resizeObserver) {
53+
resizeObserver.disconnect();
54+
resizeObserver = undefined;
55+
}
1256
}

frontend/wasm/src/editor_api.rs

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -518,8 +518,16 @@ impl EditorHandle {
518518

519519
/// Send new viewport info to the backend
520520
#[wasm_bindgen(js_name = updateViewport)]
521-
pub fn update_viewport(&self, x: f64, y: f64, width: f64, height: f64, scale: f64) {
522-
let message = ViewportMessage::Update { x, y, width, height, scale };
521+
pub fn update_viewport(&self, x: f64, y: f64, width: f64, height: f64, scale: f64, physical_width: f64, physical_height: f64) {
522+
let message = ViewportMessage::Update {
523+
x,
524+
y,
525+
width,
526+
height,
527+
scale,
528+
physical_width,
529+
physical_height,
530+
};
523531
self.dispatch(message);
524532
}
525533

node-graph/libraries/application-io/src/lib.rs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -239,6 +239,8 @@ pub struct TimingInformation {
239239
#[derive(Debug, Default, Clone, Copy, PartialEq, DynAny, serde::Serialize, serde::Deserialize)]
240240
pub struct RenderConfig {
241241
pub viewport: Footprint,
242+
/// Physical viewport resolution in device pixels (from ResizeObserver's devicePixelContentBoxSize)
243+
pub physical_viewport_resolution: UVec2,
242244
pub scale: f64,
243245
pub export_format: ExportFormat,
244246
pub time: TimingInformation,

0 commit comments

Comments
 (0)