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 <keavon@keavon.com>
This commit is contained in:
Andy Day 2025-01-25 04:37:49 -05:00 committed by GitHub
parent 9954e49530
commit 33ac141fb8
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
5 changed files with 122 additions and 4 deletions

View file

@ -5,7 +5,7 @@ use crate::messages::prelude::*;
#[derive(PartialEq, Clone, Debug, serde::Serialize, serde::Deserialize)]
pub enum OverlaysMessage {
Draw,
SetDevicePixelRatio { ratio: f64 },
// Serde functionality isn't used but is required by the message system macros
AddProvider(#[serde(skip, default = "empty_provider")] OverlayProvider),
RemoveProvider(#[serde(skip, default = "empty_provider")] OverlayProvider),

View file

@ -11,6 +11,7 @@ pub struct OverlaysMessageHandler {
pub overlay_providers: HashSet<OverlayProvider>,
canvas: Option<web_sys::HtmlCanvasElement>,
context: Option<web_sys::CanvasRenderingContext2d>,
device_pixel_ratio: Option<f64>,
}
impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessageHandler {
@ -22,6 +23,7 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessag
OverlaysMessage::Draw => {
use super::utility_functions::overlay_canvas_element;
use super::utility_types::OverlayContext;
use glam::{DAffine2, DVec2};
use wasm_bindgen::JsCast;
let canvas = match &self.canvas {
@ -39,17 +41,24 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessag
let size = ipp.viewport_bounds.size().as_uvec2();
let device_pixel_ratio = self.device_pixel_ratio.unwrap_or(1.);
let [a, b, c, d, e, f] = DAffine2::from_scale(DVec2::splat(device_pixel_ratio)).to_cols_array();
let _ = context.set_transform(a, b, c, d, e, f);
context.clear_rect(0., 0., ipp.viewport_bounds.size().x, ipp.viewport_bounds.size().y);
let _ = context.reset_transform();
if overlays_visible {
responses.add(DocumentMessage::GridOverlays(OverlayContext {
render_context: context.clone(),
size: size.as_dvec2(),
device_pixel_ratio,
}));
for provider in &self.overlay_providers {
responses.add(provider(OverlayContext {
render_context: context.clone(),
size: size.as_dvec2(),
device_pixel_ratio,
}));
}
}
@ -61,6 +70,10 @@ impl MessageHandler<OverlaysMessage, OverlaysMessageData<'_>> for OverlaysMessag
self.canvas, self.context
);
}
OverlaysMessage::SetDevicePixelRatio { ratio } => {
self.device_pixel_ratio = Some(ratio);
responses.add(OverlaysMessage::Draw);
}
OverlaysMessage::AddProvider(message) => {
self.overlay_providers.insert(message);
}

View file

@ -26,6 +26,9 @@ pub struct OverlayContext {
#[specta(skip)]
pub render_context: web_sys::CanvasRenderingContext2d,
pub size: DVec2,
// 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.
// 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.
pub device_pixel_ratio: f64,
}
// Message hashing isn't used but is required by the message system macros
impl core::hash::Hash for OverlayContext {
@ -38,6 +41,8 @@ impl OverlayContext {
}
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>) {
self.start_dpi_aware_transform();
// Set the dash pattern
if let Some(dash_width) = dash_width {
let dash_gap_width = dash_gap_width.unwrap_or(1.);
@ -82,6 +87,8 @@ impl OverlayContext {
if dash_offset.is_some() && dash_offset != Some(0.) {
self.render_context.set_line_dash_offset(0.);
}
self.end_dpi_aware_transform();
}
pub fn line(&mut self, start: DVec2, end: DVec2, color: Option<&str>) {
@ -89,6 +96,8 @@ impl OverlayContext {
}
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>) {
self.start_dpi_aware_transform();
// Set the dash pattern
if let Some(dash_width) = dash_width {
let dash_gap_width = dash_gap_width.unwrap_or(1.);
@ -127,9 +136,13 @@ impl OverlayContext {
if dash_offset.is_some() && dash_offset != Some(0.) {
self.render_context.set_line_dash_offset(0.);
}
self.end_dpi_aware_transform();
}
pub fn manipulator_handle(&mut self, position: DVec2, selected: bool, color: Option<&str>) {
self.start_dpi_aware_transform();
let position = position.round() - DVec2::splat(0.5);
self.render_context.begin_path();
@ -142,6 +155,8 @@ impl OverlayContext {
self.render_context.set_stroke_style_str(color.unwrap_or(COLOR_OVERLAY_BLUE));
self.render_context.fill();
self.render_context.stroke();
self.end_dpi_aware_transform();
}
pub fn manipulator_anchor(&mut self, position: DVec2, selected: bool, color: Option<&str>) {
@ -150,6 +165,23 @@ impl OverlayContext {
self.square(position, None, Some(color_fill), Some(color_stroke));
}
/// Transforms the canvas context to adjust for DPI scaling
///
/// Overwrites all existing tranforms. This operation can be reversed with [`Self::reset_transform`].
fn start_dpi_aware_transform(&self) {
let [a, b, c, d, e, f] = DAffine2::from_scale(DVec2::splat(self.device_pixel_ratio)).to_cols_array();
self.render_context
.set_transform(a, b, c, d, e, f)
.expect("transform should be able to be set to be able to account for DPI");
}
/// Un-transforms the Canvas context to adjust for DPI scaling
///
/// Warning: this function doesn't only reset the DPI scaling adjustment, it resets the entire transform.
fn end_dpi_aware_transform(&self) {
self.render_context.reset_transform().expect("transform should be able to be reset to be able to account for DPI");
}
pub fn square(&mut self, position: DVec2, size: Option<f64>, color_fill: Option<&str>, color_stroke: Option<&str>) {
let size = size.unwrap_or(MANIPULATOR_GROUP_MARKER_SIZE);
let color_fill = color_fill.unwrap_or(COLOR_OVERLAY_WHITE);
@ -158,12 +190,16 @@ impl OverlayContext {
let position = position.round() - DVec2::splat(0.5);
let corner = position - DVec2::splat(size) / 2.;
self.start_dpi_aware_transform();
self.render_context.begin_path();
self.render_context.rect(corner.x, corner.y, size, size);
self.render_context.set_fill_style_str(color_fill);
self.render_context.set_stroke_style_str(color_stroke);
self.render_context.fill();
self.render_context.stroke();
self.end_dpi_aware_transform();
}
pub fn pixel(&mut self, position: DVec2, color: Option<&str>) {
@ -173,22 +209,31 @@ impl OverlayContext {
let position = position.round() - DVec2::splat(0.5);
let corner = position - DVec2::splat(size) / 2.;
self.start_dpi_aware_transform();
self.render_context.begin_path();
self.render_context.rect(corner.x, corner.y, size, size);
self.render_context.set_fill_style_str(color_fill);
self.render_context.fill();
self.end_dpi_aware_transform();
}
pub fn circle(&mut self, position: DVec2, radius: f64, color_fill: Option<&str>, color_stroke: Option<&str>) {
let color_fill = color_fill.unwrap_or(COLOR_OVERLAY_WHITE);
let color_stroke = color_stroke.unwrap_or(COLOR_OVERLAY_BLUE);
let position = position.round();
self.start_dpi_aware_transform();
self.render_context.begin_path();
self.render_context.arc(position.x, position.y, radius, 0., TAU).expect("Failed to draw the circle");
self.render_context.set_fill_style_str(color_fill);
self.render_context.set_stroke_style_str(color_stroke);
self.render_context.fill();
self.render_context.stroke();
self.end_dpi_aware_transform();
}
pub fn draw_arc(&mut self, center: DVec2, radius: f64, start_from: f64, end_at: f64) {
@ -252,6 +297,8 @@ impl OverlayContext {
pub fn pivot(&mut self, position: DVec2) {
let (x, y) = (position.round() - DVec2::splat(0.5)).into();
self.start_dpi_aware_transform();
// Circle
self.render_context.begin_path();
@ -276,9 +323,15 @@ impl OverlayContext {
self.render_context.move_to(x, y - crosshair_radius);
self.render_context.line_to(x, y + crosshair_radius);
self.render_context.stroke();
self.render_context.set_line_cap("butt");
self.end_dpi_aware_transform();
}
pub fn outline_vector(&mut self, vector_data: &VectorData, transform: DAffine2) {
self.start_dpi_aware_transform();
self.render_context.begin_path();
let mut last_point = None;
for (_, bezier, start_id, end_id) in vector_data.segment_bezier_iter() {
@ -290,16 +343,24 @@ impl OverlayContext {
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
self.render_context.stroke();
self.end_dpi_aware_transform();
}
pub fn outline_bezier(&mut self, bezier: Bezier, transform: DAffine2) {
self.start_dpi_aware_transform();
self.render_context.begin_path();
self.bezier_command(bezier, transform, true);
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
self.render_context.stroke();
self.end_dpi_aware_transform();
}
fn bezier_command(&self, bezier: Bezier, transform: DAffine2, move_to: bool) {
self.start_dpi_aware_transform();
let Bezier { start, end, handles } = bezier.apply_transformation(|point| transform.transform_point2(point));
if move_to {
self.render_context.move_to(start.x, start.y);
@ -310,9 +371,13 @@ impl OverlayContext {
bezier_rs::BezierHandles::Quadratic { handle } => self.render_context.quadratic_curve_to(handle.x, handle.y, end.x, end.y),
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),
}
self.end_dpi_aware_transform();
}
pub fn outline(&mut self, subpaths: impl Iterator<Item = impl Borrow<Subpath<PointId>>>, transform: DAffine2) {
self.start_dpi_aware_transform();
self.render_context.begin_path();
for subpath in subpaths {
let subpath = subpath.borrow();
@ -359,6 +424,8 @@ impl OverlayContext {
self.render_context.set_stroke_style_str(COLOR_OVERLAY_BLUE);
self.render_context.stroke();
self.end_dpi_aware_transform();
}
pub fn get_width(&self, text: &str) -> f64 {
@ -378,7 +445,7 @@ impl OverlayContext {
Pivot::End => -padding,
};
let [a, b, c, d, e, f] = (transform * DAffine2::from_translation(DVec2::new(x, y))).to_cols_array();
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();
self.render_context.set_transform(a, b, c, d, e, f).expect("Failed to rotate the render context to the specified angle");
if let Some(background) = background_color {

View file

@ -75,7 +75,8 @@
let canvasSvgWidth: number | undefined = undefined;
let canvasSvgHeight: number | undefined = undefined;
// Used to set the canvas rendering dimensions.
let devicePixelRatio: number | undefined;
// Dimension is rounded up to the nearest even number because resizing is centered, and dividing an odd number by 2 for centering causes antialiasing
$: canvasWidthRoundedToEven = canvasSvgWidth && (canvasSvgWidth % 2 === 1 ? canvasSvgWidth + 1 : canvasSvgWidth);
$: canvasHeightRoundedToEven = canvasSvgHeight && (canvasSvgHeight % 2 === 1 ? canvasSvgHeight + 1 : canvasSvgHeight);
@ -84,6 +85,13 @@
$: canvasWidthCSS = canvasWidthRoundedToEven ? `${canvasWidthRoundedToEven}px` : "100%";
$: canvasHeightCSS = canvasHeightRoundedToEven ? `${canvasHeightRoundedToEven}px` : "100%";
$: canvasWidthScaled = canvasSvgWidth && devicePixelRatio && Math.floor(canvasSvgWidth * devicePixelRatio);
$: canvasHeightScaled = canvasSvgHeight && devicePixelRatio && Math.floor(canvasSvgHeight * devicePixelRatio);
// Used to set the canvas rendering dimensions.
$: canvasWidthScaledRoundedToEven = canvasWidthScaled && (canvasWidthScaled % 2 === 1 ? canvasWidthScaled + 1 : canvasWidthScaled);
$: canvasHeightScaledRoundedToEven = canvasHeightScaled && (canvasHeightScaled % 2 === 1 ? canvasHeightScaled + 1 : canvasHeightScaled);
$: toolShelfTotalToolsAndSeparators = ((layoutGroup) => {
if (!isWidgetSpanRow(layoutGroup)) return undefined;
@ -362,6 +370,22 @@
}
onMount(() => {
// Not compatible with Safari:
// <https://developer.mozilla.org/en-US/docs/Web/API/Window/devicePixelRatio#browser_compatibility>
// <https://bugs.webkit.org/show_bug.cgi?id=124862>
let removeUpdatePixelRatio: (() => void) | undefined = undefined;
const updatePixelRatio = () => {
removeUpdatePixelRatio?.();
const mediaQueryList = matchMedia(`(resolution: ${window.devicePixelRatio}dppx)`);
// The event is one-time use, so we have to set up a new listener and remove the old one every time
mediaQueryList.addEventListener("change", updatePixelRatio);
removeUpdatePixelRatio = () => mediaQueryList.removeEventListener("change", updatePixelRatio);
devicePixelRatio = window.devicePixelRatio;
editor.handle.setDevicePixelRatio(devicePixelRatio);
};
updatePixelRatio();
// Update rendered SVGs
editor.subscriptions.subscribeJsMessage(UpdateDocumentArtwork, async (data) => {
await tick();
@ -508,7 +532,14 @@
<div bind:this={textInput} style:transform="matrix({textInputMatrix})" on:scroll={preventTextEditingScroll} />
{/if}
</div>
<canvas class="overlays" width={canvasWidthRoundedToEven} height={canvasHeightRoundedToEven} style:width={canvasWidthCSS} style:height={canvasHeightCSS} data-overlays-canvas>
<canvas
class="overlays"
width={canvasWidthScaledRoundedToEven}
height={canvasHeightScaledRoundedToEven}
style:width={canvasWidthCSS}
style:height={canvasHeightCSS}
data-overlays-canvas
>
</canvas>
</div>
<div class="graph-view" class:open={$document.graphViewOverlayOpen} style:--fade-artwork={`${$document.fadeArtwork}%`} data-graph>

View file

@ -352,6 +352,13 @@ impl EditorHandle {
self.dispatch(message);
}
/// Inform the overlays system of the current device pixel ratio
#[wasm_bindgen(js_name = setDevicePixelRatio)]
pub fn set_device_pixel_ratio(&self, ratio: f64) {
let message = OverlaysMessage::SetDevicePixelRatio { ratio };
self.dispatch(message);
}
/// Mouse movement within the screenspace bounds of the viewport
#[wasm_bindgen(js_name = onMouseMove)]
pub fn on_mouse_move(&self, x: f64, y: f64, mouse_keys: u8, modifiers: u8) {