Skip to content

Commit 33ac141

Browse files
andystopiaKeavon
andauthored
Fix blurry overlay rendering when the pixel display ratio isn't 100% (#2204)
* support hi dpi overlay rendering * Code review and make scaling ratio dynamic --------- Co-authored-by: Keavon Chambers <[email protected]>
1 parent 9954e49 commit 33ac141

File tree

5 files changed

+122
-4
lines changed

5 files changed

+122
-4
lines changed

editor/src/messages/portfolio/document/overlays/overlays_message.rs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ use crate::messages::prelude::*;
55
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
66
pub enum OverlaysMessage {
77
Draw,
8-
8+
SetDevicePixelRatio { ratio: f64 },
99
// Serde functionality isn't used but is required by the message system macros
1010
AddProvider(#[serde(skip, default = "empty_provider")] OverlayProvider),
1111
RemoveProvider(#[serde(skip, default = "empty_provider")] OverlayProvider),

editor/src/messages/portfolio/document/overlays/overlays_message_handler.rs

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ pub struct OverlaysMessageHandler {
1111
pub overlay_providers: HashSet<OverlayProvider>,
1212
canvas: Option<web_sys::HtmlCanvasElement>,
1313
context: Option<web_sys::CanvasRenderingContext2d>,
14+
device_pixel_ratio: Option<f64>,
1415
}
1516

1617
impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessageHandler {
@@ -22,6 +23,7 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessag
2223
OverlaysMessage::Draw => {
2324
use super::utility_functions::overlay_canvas_element;
2425
use super::utility_types::OverlayContext;
26+
use glam::{DAffine2, DVec2};
2527
use wasm_bindgen::JsCast;
2628

2729
let canvas = match &self.canvas {
@@ -39,17 +41,24 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessag
3941

4042
let size = ipp.viewport_bounds.size().as_uvec2();
4143

44+
let device_pixel_ratio = self.device_pixel_ratio.unwrap_or(1.);
45+
46+
let [a, b, c, d, e, f] = DAffine2::from_scale(DVec2::splat(device_pixel_ratio)).to_cols_array();
47+
let _ = context.set_transform(a, b, c, d, e, f);
4248
context.clear_rect(0., 0., ipp.viewport_bounds.size().x, ipp.viewport_bounds.size().y);
49+
let _ = context.reset_transform();
4350

4451
if overlays_visible {
4552
responses.add(DocumentMessage::GridOverlays(OverlayContext {
4653
render_context: context.clone(),
4754
size: size.as_dvec2(),
55+
device_pixel_ratio,
4856
}));
4957
for provider in &self.overlay_providers {
5058
responses.add(provider(OverlayContext {
5159
render_context: context.clone(),
5260
size: size.as_dvec2(),
61+
device_pixel_ratio,
5362
}));
5463
}
5564
}
@@ -61,6 +70,10 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessag
6170
self.canvas, self.context
6271
);
6372
}
73+
OverlaysMessage::SetDevicePixelRatio { ratio } => {
74+
self.device_pixel_ratio = Some(ratio);
75+
responses.add(OverlaysMessage::Draw);
76+
}
6477
OverlaysMessage::AddProvider(message) => {
6578
self.overlay_providers.insert(message);
6679
}

editor/src/messages/portfolio/document/overlays/utility_types.rs

Lines changed: 68 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,9 @@ pub struct OverlayContext {
2626
#[specta(skip)]
2727
pub render_context: web_sys::CanvasRenderingContext2d,
2828
pub size: DVec2,
29+
// The device pixel ratio is a property provided by the browser window and is the CSS pixel size divided by the physical monitor's pixel size.
30+
// It allows better pixel density of visualizations on high-DPI displays where the OS display scaling is not 100%, or where the browser is zoomed.
31+
pub device_pixel_ratio: f64,
2932
}
3033
// Message hashing isn't used but is required by the message system macros
3134
impl core::hash::Hash for OverlayContext {
@@ -38,6 +41,8 @@ impl OverlayContext {
3841
}
3942

4043
pub fn dashed_quad(&mut self, quad: Quad, color_fill: Option<&str>, dash_width: Option<f64>, dash_gap_width: Option<f64>, dash_offset: Option<f64>) {
44+
self.start_dpi_aware_transform();
45+
4146
// Set the dash pattern
4247
if let Some(dash_width) = dash_width {
4348
let dash_gap_width = dash_gap_width.unwrap_or(1.);
@@ -82,13 +87,17 @@ impl OverlayContext {
8287
if dash_offset.is_some() && dash_offset != Some(0.) {
8388
self.render_context.set_line_dash_offset(0.);
8489
}
90+
91+
self.end_dpi_aware_transform();
8592
}
8693

8794
pub fn line(&mut self, start: DVec2, end: DVec2, color: Option<&str>) {
8895
self.dashed_line(start, end, color, None, None, None)
8996
}
9097

9198
pub fn dashed_line(&mut self, start: DVec2, end: DVec2, color: Option<&str>, dash_width: Option<f64>, dash_gap_width: Option<f64>, dash_offset: Option<f64>) {
99+
self.start_dpi_aware_transform();
100+
92101
// Set the dash pattern
93102
if let Some(dash_width) = dash_width {
94103
let dash_gap_width = dash_gap_width.unwrap_or(1.);
@@ -127,9 +136,13 @@ impl OverlayContext {
127136
if dash_offset.is_some() && dash_offset != Some(0.) {
128137
self.render_context.set_line_dash_offset(0.);
129138
}
139+
140+
self.end_dpi_aware_transform();
130141
}
131142

132143
pub fn manipulator_handle(&mut self, position: DVec2, selected: bool, color: Option<&str>) {
144+
self.start_dpi_aware_transform();
145+
133146
let position = position.round() - DVec2::splat(0.5);
134147

135148
self.render_context.begin_path();
@@ -142,6 +155,8 @@ impl OverlayContext {
142155
self.render_context.set_stroke_style_str(color.unwrap_or(COLOR_OVERLAY_BLUE));
143156
self.render_context.fill();
144157
self.render_context.stroke();
158+
159+
self.end_dpi_aware_transform();
145160
}
146161

147162
pub fn manipulator_anchor(&mut self, position: DVec2, selected: bool, color: Option<&str>) {
@@ -150,6 +165,23 @@ impl OverlayContext {
150165
self.square(position, None, Some(color_fill), Some(color_stroke));
151166
}
152167

168+
/// Transforms the canvas context to adjust for DPI scaling
169+
///
170+
/// Overwrites all existing tranforms. This operation can be reversed with [`Self::reset_transform`].
171+
fn start_dpi_aware_transform(&self) {
172+
let [a, b, c, d, e, f] = DAffine2::from_scale(DVec2::splat(self.device_pixel_ratio)).to_cols_array();
173+
self.render_context
174+
.set_transform(a, b, c, d, e, f)
175+
.expect("transform should be able to be set to be able to account for DPI");
176+
}
177+
178+
/// Un-transforms the Canvas context to adjust for DPI scaling
179+
///
180+
/// Warning: this function doesn't only reset the DPI scaling adjustment, it resets the entire transform.
181+
fn end_dpi_aware_transform(&self) {
182+
self.render_context.reset_transform().expect("transform should be able to be reset to be able to account for DPI");
183+
}
184+
153185
pub fn square(&mut self, position: DVec2, size: Option<f64>, color_fill: Option<&str>, color_stroke: Option<&str>) {
154186
let size = size.unwrap_or(MANIPULATOR_GROUP_MARKER_SIZE);
155187
let color_fill = color_fill.unwrap_or(COLOR_OVERLAY_WHITE);
@@ -158,12 +190,16 @@ impl OverlayContext {
158190
let position = position.round() - DVec2::splat(0.5);
159191
let corner = position - DVec2::splat(size) / 2.;
160192

193+
self.start_dpi_aware_transform();
194+
161195
self.render_context.begin_path();
162196
self.render_context.rect(corner.x, corner.y, size, size);
163197
self.render_context.set_fill_style_str(color_fill);
164198
self.render_context.set_stroke_style_str(color_stroke);
165199
self.render_context.fill();
166200
self.render_context.stroke();
201+
202+
self.end_dpi_aware_transform();
167203
}
168204

169205
pub fn pixel(&mut self, position: DVec2, color: Option<&str>) {
@@ -173,22 +209,31 @@ impl OverlayContext {
173209
let position = position.round() - DVec2::splat(0.5);
174210
let corner = position - DVec2::splat(size) / 2.;
175211

212+
self.start_dpi_aware_transform();
213+
176214
self.render_context.begin_path();
177215
self.render_context.rect(corner.x, corner.y, size, size);
178216
self.render_context.set_fill_style_str(color_fill);
179217
self.render_context.fill();
218+
219+
self.end_dpi_aware_transform();
180220
}
181221

182222
pub fn circle(&mut self, position: DVec2, radius: f64, color_fill: Option<&str>, color_stroke: Option<&str>) {
183223
let color_fill = color_fill.unwrap_or(COLOR_OVERLAY_WHITE);
184224
let color_stroke = color_stroke.unwrap_or(COLOR_OVERLAY_BLUE);
185225
let position = position.round();
226+
227+
self.start_dpi_aware_transform();
228+
186229
self.render_context.begin_path();
187230
self.render_context.arc(position.x, position.y, radius, 0., TAU).expect("Failed to draw the circle");
188231
self.render_context.set_fill_style_str(color_fill);
189232
self.render_context.set_stroke_style_str(color_stroke);
190233
self.render_context.fill();
191234
self.render_context.stroke();
235+
236+
self.end_dpi_aware_transform();
192237
}
193238

194239
pub fn draw_arc(&mut self, center: DVec2, radius: f64, start_from: f64, end_at: f64) {
@@ -252,6 +297,8 @@ impl OverlayContext {
252297
pub fn pivot(&mut self, position: DVec2) {
253298
let (x, y) = (position.round() - DVec2::splat(0.5)).into();
254299

300+
self.start_dpi_aware_transform();
301+
255302
// Circle
256303

257304
self.render_context.begin_path();
@@ -276,9 +323,15 @@ impl OverlayContext {
276323
self.render_context.move_to(x, y - crosshair_radius);
277324
self.render_context.line_to(x, y + crosshair_radius);
278325
self.render_context.stroke();
326+
327+
self.render_context.set_line_cap("butt");
328+
329+
self.end_dpi_aware_transform();
279330
}
280331

281332
pub fn outline_vector(&mut self, vector_data: &VectorData, transform: DAffine2) {
333+
self.start_dpi_aware_transform();
334+
282335
self.render_context.begin_path();
283336
let mut last_point = None;
284337
for (_, bezier, start_id, end_id) in vector_data.segment_bezier_iter() {
@@ -290,16 +343,24 @@ impl OverlayContext {
290343

291344
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
292345
self.render_context.stroke();
346+
347+
self.end_dpi_aware_transform();
293348
}
294349

295350
pub fn outline_bezier(&mut self, bezier: Bezier, transform: DAffine2) {
351+
self.start_dpi_aware_transform();
352+
296353
self.render_context.begin_path();
297354
self.bezier_command(bezier, transform, true);
298355
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
299356
self.render_context.stroke();
357+
358+
self.end_dpi_aware_transform();
300359
}
301360

302361
fn bezier_command(&self, bezier: Bezier, transform: DAffine2, move_to: bool) {
362+
self.start_dpi_aware_transform();
363+
303364
let Bezier { start, end, handles } = bezier.apply_transformation(|point| transform.transform_point2(point));
304365
if move_to {
305366
self.render_context.move_to(start.x, start.y);
@@ -310,9 +371,13 @@ impl OverlayContext {
310371
bezier_rs::BezierHandles::Quadratic { handle } => self.render_context.quadratic_curve_to(handle.x, handle.y, end.x, end.y),
311372
bezier_rs::BezierHandles::Cubic { handle_start, handle_end } => self.render_context.bezier_curve_to(handle_start.x, handle_start.y, handle_end.x, handle_end.y, end.x, end.y),
312373
}
374+
375+
self.end_dpi_aware_transform();
313376
}
314377

315378
pub fn outline(&mut self, subpaths: impl Iterator<Item = impl Borrow<Subpath<PointId>>>, transform: DAffine2) {
379+
self.start_dpi_aware_transform();
380+
316381
self.render_context.begin_path();
317382
for subpath in subpaths {
318383
let subpath = subpath.borrow();
@@ -359,6 +424,8 @@ impl OverlayContext {
359424

360425
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
361426
self.render_context.stroke();
427+
428+
self.end_dpi_aware_transform();
362429
}
363430

364431
pub fn get_width(&self, text: &str) -> f64 {
@@ -378,7 +445,7 @@ impl OverlayContext {
378445
Pivot::End => -padding,
379446
};
380447

381-
let [a, b, c, d, e, f] = (transform * DAffine2::from_translation(DVec2::new(x, y))).to_cols_array();
448+
let [a, b, c, d, e, f] = (DAffine2::from_scale(DVec2::splat(self.device_pixel_ratio)) * transform * DAffine2::from_translation(DVec2::new(x, y))).to_cols_array();
382449
self.render_context.set_transform(a, b, c, d, e, f).expect("Failed to rotate the render context to the specified angle");
383450

384451
if let Some(background) = background_color {

frontend/src/components/panels/Document.svelte

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,8 @@
7575
let canvasSvgWidth: number | undefined = undefined;
7676
let canvasSvgHeight: number | undefined = undefined;
7777
78-
// Used to set the canvas rendering dimensions.
78+
let devicePixelRatio: number | undefined;
79+
7980
// Dimension is rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing
8081
$: canvasWidthRoundedToEven = canvasSvgWidth && (canvasSvgWidth % 2 === 1 ? canvasSvgWidth + 1 : canvasSvgWidth);
8182
$: canvasHeightRoundedToEven = canvasSvgHeight && (canvasSvgHeight % 2 === 1 ? canvasSvgHeight + 1 : canvasSvgHeight);
@@ -84,6 +85,13 @@
8485
$: canvasWidthCSS = canvasWidthRoundedToEven ? `${canvasWidthRoundedToEven}px` : "100%";
8586
$: canvasHeightCSS = canvasHeightRoundedToEven ? `${canvasHeightRoundedToEven}px` : "100%";
8687
88+
$: canvasWidthScaled = canvasSvgWidth && devicePixelRatio && Math.floor(canvasSvgWidth * devicePixelRatio);
89+
$: canvasHeightScaled = canvasSvgHeight && devicePixelRatio && Math.floor(canvasSvgHeight * devicePixelRatio);
90+
91+
// Used to set the canvas rendering dimensions.
92+
$: canvasWidthScaledRoundedToEven = canvasWidthScaled && (canvasWidthScaled % 2 === 1 ? canvasWidthScaled + 1 : canvasWidthScaled);
93+
$: canvasHeightScaledRoundedToEven = canvasHeightScaled && (canvasHeightScaled % 2 === 1 ? canvasHeightScaled + 1 : canvasHeightScaled);
94+
8795
$: toolShelfTotalToolsAndSeparators = ((layoutGroup) => {
8896
if (!isWidgetSpanRow(layoutGroup)) return undefined;
8997
@@ -362,6 +370,22 @@
362370
}
363371
364372
onMount(() => {
373+
// Not compatible with Safari:
374+
// <https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio#browser_compatibility>
375+
// <https://bugs.webkit.org/show_bug.cgi?id=124862>
376+
let removeUpdatePixelRatio: (() => void) | undefined = undefined;
377+
const updatePixelRatio = () => {
378+
removeUpdatePixelRatio?.();
379+
const mediaQueryList = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
380+
// The event is one-time use, so we have to set up a new listener and remove the old one every time
381+
mediaQueryList.addEventListener("change", updatePixelRatio);
382+
removeUpdatePixelRatio = () => mediaQueryList.removeEventListener("change", updatePixelRatio);
383+
384+
devicePixelRatio = window.devicePixelRatio;
385+
editor.handle.setDevicePixelRatio(devicePixelRatio);
386+
};
387+
updatePixelRatio();
388+
365389
// Update rendered SVGs
366390
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, async (data) => {
367391
await tick();
@@ -508,7 +532,14 @@
508532
<div bind:this={textInput} style:transform="matrix({textInputMatrix})" on:scroll={preventTextEditingScroll} />
509533
{/if}
510534
</div>
511-
<canvas class="overlays" width={canvasWidthRoundedToEven} height={canvasHeightRoundedToEven} style:width={canvasWidthCSS} style:height={canvasHeightCSS} data-overlays-canvas>
535+
<canvas
536+
class="overlays"
537+
width={canvasWidthScaledRoundedToEven}
538+
height={canvasHeightScaledRoundedToEven}
539+
style:width={canvasWidthCSS}
540+
style:height={canvasHeightCSS}
541+
data-overlays-canvas
542+
>
512543
</canvas>
513544
</div>
514545
<div class="graph-view" class:open={$document.graphViewOverlayOpen} style:--fade-artwork={`${$document.fadeArtwork}%`} data-graph>

frontend/wasm/src/editor_api.rs

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,13 @@ impl EditorHandle {
352352
self.dispatch(message);
353353
}
354354

355+
/// Inform the overlays system of the current device pixel ratio
356+
#[wasm_bindgen(js_name = setDevicePixelRatio)]
357+
pub fn set_device_pixel_ratio(&self, ratio: f64) {
358+
let message = OverlaysMessage::SetDevicePixelRatio { ratio };
359+
self.dispatch(message);
360+
}
361+
355362
/// Mouse movement within the screenspace bounds of the viewport
356363
#[wasm_bindgen(js_name = onMouseMove)]
357364
pub fn on_mouse_move(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) {

0 commit comments

Comments
 (0)