diff --git a/Cargo.toml b/Cargo.toml index 10f46d8a..fc93b0a6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -48,6 +48,9 @@ calendar = [] deeplink = [] microphone = [] media_session = [] +# Render GPUI windows into images (`gpui_mobile::offscreen`). Needs the vendored +# `gpui-pre-wgpu` (see `[patch.crates-io]`), which exposes its headless renderer. +offscreen = ["gpui_wgpu/offscreen", "dep:image"] [lib] name = "gpui_mobile" @@ -61,6 +64,8 @@ log = { version = "0.4", features = ["kv_unstable_serde", "serde"] } parking_lot = "0.12" futures = "0.3" raw-window-handle = "0.6" +# Same major version as `gpui-pre-wgpu`, whose `RgbaImage` `offscreen` returns. +image = { version = "0.25", default-features = false, optional = true } # ── GPUI core crate (provides Platform trait, event types, geometry, etc.) ─── gpui = { package = "gpui-pre", version = "=0.3.8", default-features = false } @@ -139,3 +144,10 @@ strip = true [profile.dev] opt-level = 0 debug = true + +# `gpui-pre-wgpu` 0.3.8 with its headless renderer behind an `offscreen` feature +# instead of `test-support` / `bench-support`, and a constructor that takes the +# wgpu instance (iOS needs Metal). Applications that enable `offscreen` must +# patch it the same way. +[patch.crates-io] +gpui-pre-wgpu = { path = "vendor/gpui-pre-wgpu" } diff --git a/README.md b/README.md index beff6308..0a9e83be 100644 --- a/README.md +++ b/README.md @@ -89,6 +89,35 @@ cd android/gradle && ./gradlew assembleDebug | Android (arm64) | ✅ | API 26+ | Vulkan (preferred), GL ES 3.0 | | Android (armv7/x86_64) | ⚠️ Untested | API 26+ | Vulkan / GL ES | +## Rendering views to images + +The `offscreen` feature adds `gpui_mobile::offscreen::OffscreenWindow`, a GPUI +window drawn into a texture instead of onto the screen, for turning a view into +an image (a share card, a long screenshot): + +```rust +// Open with the view to capture, then render from an async task +// (`render` drives the window's own frame callbacks, which borrow the `App`). +let window = OffscreenWindow::open(size(px(390.), px(844.)), 3.0, WindowAppearance::Light, cx, |window, cx| { + cx.new(|cx| MyView::new(window, cx)) +})?; +window.layout(size(px(390.), px(844.)))?; // lay out without reading pixels back, e.g. to measure content +let image = window.render(size(px(390.), content_height))?; // image::RgbaImage +window.close(cx); +``` + +It needs `gpui-pre-wgpu`'s headless renderer, which upstream only builds for +tests and benchmarks and which picks a Vulkan/GL device that iOS lacks. This +repository vendors `gpui-pre-wgpu` 0.3.8 with that renderer behind an +`offscreen` feature and a constructor that takes the wgpu instance +(`vendor/gpui-pre-wgpu`). An application enabling `offscreen` must patch it the +same way: + +```toml +[patch.crates-io] +gpui-pre-wgpu = { git = "https://github.com/longbridge/gpui-mobile" } +``` + ## Screenshots Running on a Motorola Edge 50 Pro (Android, Vulkan): diff --git a/src/android/platform.rs b/src/android/platform.rs index eaafa6e5..c30a247d 100644 --- a/src/android/platform.rs +++ b/src/android/platform.rs @@ -1148,6 +1148,10 @@ impl Platform for AndroidPlatform { _handle: AnyWindowHandle, _options: WindowParams, ) -> anyhow::Result> { + #[cfg(feature = "offscreen")] + if let Some(window) = crate::offscreen::take_pending(&_options) { + return Ok(window); + } // On Android the native window is created by the system via // MainEvent::InitWindow, not by the application. If a window // already exists we wrap it in an AndroidPlatformWindow and hand diff --git a/src/ios/platform.rs b/src/ios/platform.rs index 0582c738..ac7b2233 100644 --- a/src/ios/platform.rs +++ b/src/ios/platform.rs @@ -161,6 +161,10 @@ impl Platform for IosPlatform { handle: AnyWindowHandle, options: WindowParams, ) -> anyhow::Result> { + #[cfg(feature = "offscreen")] + if let Some(window) = crate::offscreen::take_pending(&options) { + return Ok(window); + } let window = Box::new(IosWindow::new(handle, options)?); // Register the window with FFI layer so Objective-C can access it for rendering window.register_with_ffi(); diff --git a/src/lib.rs b/src/lib.rs index 635285ff..43d8c488 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -79,6 +79,8 @@ pub(crate) mod frame_demand; #[cfg_attr(not(target_os = "android"), allow(dead_code))] pub(crate) mod frame_pacer; pub mod momentum; +#[cfg(all(feature = "offscreen", any(target_os = "ios", target_os = "android")))] +pub mod offscreen; pub mod packages; pub mod platform_view; pub mod target_platform; diff --git a/src/offscreen.rs b/src/offscreen.rs new file mode 100644 index 00000000..1f4f59e1 --- /dev/null +++ b/src/offscreen.rs @@ -0,0 +1,382 @@ +//! Offscreen windows: GPUI windows drawn into a texture instead of onto the +//! screen, so an application can turn a view into an image — a share card, a +//! long screenshot of a conversation. +//! +//! [`OffscreenWindow::open`] opens an ordinary GPUI window whose platform side +//! has no UIKit / NDK surface: layout, text shaping and painting run exactly as +//! they do on screen, and each [`OffscreenWindow::render`] forces one frame and +//! reads its pixels back through a headless wgpu renderer. +//! +//! The renderer (one GPU device plus its pipelines) is created on first use and +//! kept for the rest of the process on the main thread, so later captures skip +//! device creation and shader compilation. + +use std::{cell::RefCell, rc::Rc, sync::Arc}; + +use anyhow::{Context as _, Result}; +use gpui::{ + point, px, App, Bounds, Capslock, DevicePixels, DispatchEventResult, Entity, GpuSpecs, + Modifiers, Pixels, PlatformAtlas, PlatformDisplay, PlatformInput, PlatformInputHandler, + PlatformWindow, Point, PromptButton, PromptLevel, Render, RequestFrameOptions, Scene, Size, + Window, WindowAppearance, WindowBackgroundAppearance, WindowBounds, WindowControlArea, + WindowHandle, WindowOptions, WindowParams, WindowVisibility, +}; +use gpui_wgpu::WgpuHeadlessRenderer; +pub use image::RgbaImage; +use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; + +thread_local! { + /// Set by [`OffscreenWindow::open`] for the platform's `open_window` call it + /// makes, which then answers with an [`OffscreenPlatformWindow`]. + static PENDING: RefCell>>> = const { RefCell::new(None) }; + static RENDERER: RefCell>>> = + const { RefCell::new(None) }; +} + +/// A GPUI window rendered into images instead of onto the screen. +/// +/// Dropping the handle leaves the window open; call [`Self::close`]. +pub struct OffscreenWindow { + handle: WindowHandle, + state: Rc>, +} + +impl OffscreenWindow { + /// Opens a window of `size` logical pixels drawn at `scale_factor` device + /// pixels per logical pixel, with `build` creating its root view. + pub fn open( + size: Size, + scale_factor: f32, + appearance: WindowAppearance, + cx: &mut App, + build: impl FnOnce(&mut Window, &mut App) -> Entity, + ) -> Result { + let renderer = renderer()?; + let sprite_atlas = renderer.borrow().sprite_atlas(); + let state = Rc::new(RefCell::new(State { + bounds: Bounds::new(Point::default(), size), + scale_factor, + appearance, + renderer, + sprite_atlas, + request_frame: None, + resize: None, + input_handler: None, + read_back: false, + image: None, + })); + PENDING.with(|pending| *pending.borrow_mut() = Some(state.clone())); + let options = WindowOptions { + window_bounds: Some(WindowBounds::Windowed(Bounds::new( + point(px(0.), px(0.)), + size, + ))), + focus: false, + show: false, + ..Default::default() + }; + let handle = cx.open_window(options, build); + // `open_window` takes it on success; clear it if the platform never got that far. + PENDING.with(|pending| pending.borrow_mut().take()); + Ok(Self { + handle: handle?, + state, + }) + } + + /// The GPUI handle of the window, to update its root view between renders. + pub fn handle(&self) -> WindowHandle { + self.handle + } + + /// The largest size one [`Self::render`] can produce: the GPU's texture + /// limit divided by the window's scale factor. + pub fn max_size(&self) -> Size { + let state = self.state.borrow(); + let side = px(state.renderer.borrow().max_texture_size() as f32 / state.scale_factor); + Size { + width: side, + height: side, + } + } + + /// Lays the window out at `size`, draws one frame and returns its pixels. + /// + /// GPUI delivers the frame through the window's own callbacks, which borrow + /// the `App` themselves, so call this from an async task between `update`s + /// (for example after `cx.spawn(async move |cx| …)` resumes), never from + /// inside `App::update` or a view's `render`. + pub fn render(&self, size: Size) -> Result { + self.frame(size, true)? + .context("GPUI did not draw the offscreen window")? + } + + /// Lays the window out at `size` and paints one frame without reading it + /// back, so views can measure their content (for example to size the next + /// [`Self::render`]). The same calling rule as [`Self::render`] applies. + pub fn layout(&self, size: Size) -> Result<()> { + self.frame(size, false).map(|_| ()) + } + + fn frame(&self, size: Size, read_back: bool) -> Result>> { + let resize = { + let mut state = self.state.borrow_mut(); + let changed = state.bounds.size != size; + state.bounds.size = size; + state.read_back = read_back; + state.image = None; + changed.then(|| state.resize.take()).flatten() + }; + if let Some(mut resize) = resize { + let scale_factor = self.state.borrow().scale_factor; + resize(size, scale_factor); + self.state.borrow_mut().resize = Some(resize); + } + + let mut request_frame = self + .state + .borrow_mut() + .request_frame + .take() + .context("the offscreen window has no frame callback; was it closed?")?; + request_frame(RequestFrameOptions { + require_presentation: true, + force_render: true, + ..Default::default() + }); + let mut state = self.state.borrow_mut(); + state.request_frame = Some(request_frame); + Ok(state.image.take()) + } + + /// Closes the window, dropping its view tree. + pub fn close(self, cx: &mut App) { + let _ = self + .handle + .update(cx, |_, window, _| window.remove_window()); + } +} + +/// Answers the platform's `open_window` with an offscreen window when +/// [`OffscreenWindow::open`] asked for one. +pub(crate) fn take_pending(_params: &WindowParams) -> Option> { + let state = PENDING.with(|pending| pending.borrow_mut().take())?; + Some(Box::new(OffscreenPlatformWindow(state))) +} + +fn renderer() -> Result>> { + RENDERER.with(|slot| { + if let Some(renderer) = slot.borrow().as_ref() { + return Ok(renderer.clone()); + } + let renderer = Rc::new(RefCell::new(create_renderer()?)); + *slot.borrow_mut() = Some(renderer.clone()); + Ok(renderer) + }) +} + +/// iOS only has Metal, which the default wgpu instance leaves out. +#[cfg(target_os = "ios")] +fn create_renderer() -> Result { + let instance = gpui_wgpu::wgpu::Instance::new(gpui_wgpu::wgpu::InstanceDescriptor { + backends: gpui_wgpu::wgpu::Backends::METAL, + flags: gpui_wgpu::wgpu::InstanceFlags::default(), + backend_options: gpui_wgpu::wgpu::BackendOptions::default(), + memory_budget_thresholds: gpui_wgpu::wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + WgpuHeadlessRenderer::with_instance(instance) +} + +#[cfg(not(target_os = "ios"))] +fn create_renderer() -> Result { + WgpuHeadlessRenderer::new() +} + +/// GPUI's per-window frame callback, driven by [`OffscreenWindow::frame`]. +type FrameCallback = Box; +/// GPUI's per-window resize callback. +type ResizeCallback = Box, f32)>; + +struct State { + bounds: Bounds, + scale_factor: f32, + appearance: WindowAppearance, + renderer: Rc>, + sprite_atlas: Arc, + request_frame: Option, + resize: Option, + input_handler: Option, + /// Whether the next `draw` reads its pixels back ([`OffscreenWindow::render`]) + /// or only paints ([`OffscreenWindow::layout`]). + read_back: bool, + /// What the last `draw` produced, taken by [`OffscreenWindow::render`]. + image: Option>, +} + +struct OffscreenPlatformWindow(Rc>); + +// No native window backs it, so there is no handle to report; `NotSupported` +// is `raw_window_handle`'s variant for exactly this. +impl HasWindowHandle for OffscreenPlatformWindow { + fn window_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl HasDisplayHandle for OffscreenPlatformWindow { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Err(raw_window_handle::HandleError::NotSupported) + } +} + +impl PlatformWindow for OffscreenPlatformWindow { + fn bounds(&self) -> Bounds { + self.0.borrow().bounds + } + + fn is_maximized(&self) -> bool { + false + } + + fn window_bounds(&self) -> WindowBounds { + WindowBounds::Windowed(self.bounds()) + } + + fn content_size(&self) -> Size { + self.bounds().size + } + + fn resize(&mut self, size: Size) { + self.0.borrow_mut().bounds.size = size; + } + + fn scale_factor(&self) -> f32 { + self.0.borrow().scale_factor + } + + fn appearance(&self) -> WindowAppearance { + self.0.borrow().appearance + } + + fn display(&self) -> Option> { + None + } + + fn mouse_position(&self) -> Point { + Point::default() + } + + fn modifiers(&self) -> Modifiers { + Modifiers::default() + } + + fn capslock(&self) -> Capslock { + Capslock::default() + } + + fn set_input_handler(&mut self, input_handler: PlatformInputHandler) { + self.0.borrow_mut().input_handler = Some(input_handler); + } + + fn take_input_handler(&mut self) -> Option { + self.0.borrow_mut().input_handler.take() + } + + fn prompt( + &self, + _level: PromptLevel, + _msg: &str, + _detail: Option<&str>, + _answers: &[PromptButton], + ) -> Option> { + None + } + + fn activate(&self) {} + + fn is_active(&self) -> bool { + false + } + + fn visibility(&self) -> WindowVisibility { + WindowVisibility::Visible + } + + fn is_hovered(&self) -> bool { + false + } + + fn background_appearance(&self) -> WindowBackgroundAppearance { + WindowBackgroundAppearance::Opaque + } + + fn set_title(&mut self, _title: &str) {} + + fn set_background_appearance(&self, _background_appearance: WindowBackgroundAppearance) {} + + fn minimize(&self) {} + + fn zoom(&self) {} + + fn toggle_fullscreen(&self) {} + + fn is_fullscreen(&self) -> bool { + false + } + + fn on_request_frame(&self, callback: Box) { + self.0.borrow_mut().request_frame = Some(callback); + } + + fn on_input(&self, _callback: Box DispatchEventResult>) {} + + fn on_active_status_change(&self, _callback: Box) {} + + fn on_visibility_change(&self, _callback: Box) {} + + fn on_hover_status_change(&self, _callback: Box) {} + + fn on_resize(&self, callback: Box, f32)>) { + self.0.borrow_mut().resize = Some(callback); + } + + fn on_moved(&self, _callback: Box) {} + + fn on_should_close(&self, _callback: Box bool>) {} + + fn on_hit_test_window_control(&self, _callback: Box Option>) { + } + + fn on_close(&self, _callback: Box) {} + + fn on_appearance_changed(&self, _callback: Box) {} + + fn draw(&self, scene: &Scene) { + let mut state = self.0.borrow_mut(); + if !state.read_back { + return; + } + let size: Size = state.bounds.size.to_device_pixels(state.scale_factor); + let image = state.renderer.borrow_mut().render_to_image(scene, size); + state.image = Some(image); + } + + fn sprite_atlas(&self) -> Arc { + self.0.borrow().sprite_atlas.clone() + } + + fn is_subpixel_rendering_supported(&self) -> bool { + false + } + + fn gpu_specs(&self) -> Option { + None + } + + fn update_ime_position(&self, _bounds: Bounds) {} +} diff --git a/vendor/gpui-pre-wgpu/Cargo.toml b/vendor/gpui-pre-wgpu/Cargo.toml new file mode 100644 index 00000000..f2900468 --- /dev/null +++ b/vendor/gpui-pre-wgpu/Cargo.toml @@ -0,0 +1,176 @@ +# THIS FILE IS AUTOMATICALLY GENERATED BY CARGO +# +# When uploading crates to the registry Cargo will automatically +# "normalize" Cargo.toml files for maximal compatibility +# with all versions of Cargo and also rewrite `path` dependencies +# to registry (e.g., crates.io) dependencies. +# +# If you are reading this file be aware that the original Cargo.toml +# will likely look very different (and much more reasonable). +# See Cargo.toml.orig for the original contents. + +[package] +edition = "2024" +name = "gpui-pre-wgpu" +version = "0.3.8" +build = false +publish = true +autolib = false +autobins = false +autoexamples = false +autotests = false +autobenches = false +description = "Zed's `gpui_wgpu` crate (gpui-pre snapshot of zed@279fe07)" +readme = false +license = "Apache-2.0" +repository = "https://github.com/zed-industries/zed" +resolver = "2" + +[package.metadata.gpui-pre] +zed-crate = "gpui_wgpu" +zed-version = "0.1.0" +zed-rev = "279fe070bb389b79652e52065b2f001edcc0b11b" + +[features] +bench-support = [ + "gpui/bench-support", + "dep:image", +] +default = [] +font-kit = ["dep:font-kit"] +offscreen = ["dep:image"] +test-support = [ + "gpui/test-support", + "dep:image", +] + +[lib] +name = "gpui_wgpu" +path = "src/gpui_wgpu.rs" +doctest = false + +[[bench]] +name = "layout_line" +path = "benches/layout_line.rs" +harness = false + +[dependencies.anyhow] +version = "1.0.86" + +[dependencies.bytemuck] +version = "1" + +[dependencies.collections] +version = "=0.3.8" +package = "gpui-pre-collections" + +[dependencies.cosmic-text] +version = "0.19.0" + +[dependencies.etagere] +version = "0.2" + +[dependencies.font-kit] +version = "0.14.1-zed" +optional = true +package = "zed-font-kit" + +[dependencies.gpui] +version = "=0.3.8" +default-features = false +package = "gpui-pre" + +[dependencies.gpui_util] +version = "=0.3.8" +package = "gpui-pre-util" + +[dependencies.image] +version = "0.25.1" +features = [ + "bmp", + "dds", + "exr", + "ff", + "gif", + "hdr", + "ico", + "jpeg", + "png", + "pnm", + "qoi", + "rayon", + "tga", + "tiff", + "webp", +] +optional = true +default-features = false + +[dependencies.itertools] +version = "0.14.0" + +[dependencies.log] +version = "0.4.16" +features = [ + "kv_unstable_serde", + "serde", +] + +[dependencies.parking_lot] +version = "0.12.1" + +[dependencies.profiling] +version = "1" + +[dependencies.raw-window-handle] +version = "0.6" + +[dependencies.smallvec] +version = "1.6" +features = [ + "union", + "const_new", +] + +[dependencies.swash] +version = "0.2.6" + +[dependencies.unicode-bidi] +version = "0.3.18" +features = ["hardcoded-data"] +default-features = false + +[dependencies.unicode-segmentation] +version = "1.10" + +[dependencies.wgpu] +version = "29.0.4" + +[target.'cfg(target_family = "wasm")'.dependencies.web-sys] +version = "0.3" +features = ["HtmlCanvasElement"] + +[target.'cfg(target_family = "wasm")'.dependencies.wgpu] +version = "29.0.4" +features = ["webgl"] + +[lints.clippy] +dbg_macro = "deny" +declare_interior_mutable_const = "deny" +disallowed_methods = "deny" +large_enum_variant = "allow" +let_underscore_future = "allow" +nonminimal_bool = "allow" +redundant_clone = "deny" +single_range_in_vec_init = "allow" +todo = "deny" +too_many_arguments = "allow" +type_complexity = "allow" + +[lints.clippy.style] +level = "allow" +priority = -1 + +[lints.rust.unexpected_cfgs] +level = "allow" +priority = 0 diff --git a/vendor/gpui-pre-wgpu/Cargo.toml.orig b/vendor/gpui-pre-wgpu/Cargo.toml.orig new file mode 100644 index 00000000..e2af01c8 --- /dev/null +++ b/vendor/gpui-pre-wgpu/Cargo.toml.orig @@ -0,0 +1,56 @@ +[package] +name = "gpui-pre-wgpu" +version = "0.3.8" +edition = { workspace = true } +publish = true +license = "Apache-2.0" +description = "Zed's `gpui_wgpu` crate (gpui-pre snapshot of zed@279fe07)" +repository = "https://github.com/zed-industries/zed" + +[package.metadata.gpui-pre] +zed-crate = "gpui_wgpu" +zed-version = "0.1.0" +zed-rev = "279fe070bb389b79652e52065b2f001edcc0b11b" + +[lib] +path = "src/gpui_wgpu.rs" +doctest = false +name = "gpui_wgpu" + +[features] +default = [] +bench-support = ["gpui/bench-support", "dep:image"] +font-kit = ["dep:font-kit"] +test-support = ["gpui/test-support", "dep:image"] + +[dependencies] +gpui = { workspace = true } +anyhow = { workspace = true } +bytemuck = "1" +collections = { workspace = true } +cosmic-text = "0.19.0" +etagere = { workspace = true } +itertools = { workspace = true } +image = { workspace = true, optional = true } +log = { workspace = true } +parking_lot = { workspace = true } +profiling = { workspace = true } +raw-window-handle = { workspace = true } +smallvec = { workspace = true } +swash = { workspace = true } +unicode-bidi = { workspace = true } +unicode-segmentation = { workspace = true } +gpui_util = { workspace = true } +wgpu = { workspace = true } +font-kit = { git = "https://github.com/zed-industries/font-kit", rev = "94b0f28166665e8fd2f53ff6d268a14955c82269", package = "zed-font-kit", version = "0.14.1-zed", optional = true } + +[target.'cfg(target_family = "wasm")'.dependencies] +web-sys = { version = "0.3", features = ["HtmlCanvasElement"] } +wgpu = { workspace = true, features = ["webgl"] } + +[lints] +workspace = true + +[[bench]] +name = "layout_line" +harness = false diff --git a/vendor/gpui-pre-wgpu/LICENSE-APACHE b/vendor/gpui-pre-wgpu/LICENSE-APACHE new file mode 100644 index 00000000..461a0fe5 --- /dev/null +++ b/vendor/gpui-pre-wgpu/LICENSE-APACHE @@ -0,0 +1,222 @@ +Copyright 2022 - 2025 Zed Industries, Inc. + + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + + http://www.apache.org/licenses/LICENSE-2.0 + + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. + + + + +Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + + 1. Definitions. + + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + + END OF TERMS AND CONDITIONS diff --git a/vendor/gpui-pre-wgpu/benches/layout_line.rs b/vendor/gpui-pre-wgpu/benches/layout_line.rs new file mode 100644 index 00000000..6d66a7a2 --- /dev/null +++ b/vendor/gpui-pre-wgpu/benches/layout_line.rs @@ -0,0 +1,104 @@ +use criterion::{Criterion, criterion_group, criterion_main}; +use gpui::{FontFallbacks, FontRun, PlatformTextSystem, font, px}; +use gpui_wgpu::CosmicTextSystem; +use std::borrow::Cow; + +const LILEX: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"); +const IBM_PLEX: &[u8] = + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); + +// ~4 000 chars of typical ASCII code text, as a single display line. +// +// `layout_line` is handed one line at a time, already split on `\n` by its +// callers, so the newlines are replaced rather than kept: leaving them in would +// make this measure the multi-paragraph path instead of the common one, since +// `\n` is itself a bidi paragraph separator. +fn code_text() -> String { + concat!( + " fn compute_run_spans(\n", + " text: &str,\n", + " run_offset: usize,\n", + " run_len: usize,\n", + " primary: FontId,\n", + " fallback_chain: &[(FontId, SharedString)],\n", + " covers: &impl Fn(FontId, char) -> bool,\n", + " ) -> SmallVec<[RunSpan; 4]> {\n", + " let mut spans = SmallVec::new();\n", + " let run_end = run_offset + run_len;\n", + " if run_end <= run_offset { return spans; }\n", + " let run_text = &text[run_offset..run_end];\n", + " let mut span_start = run_offset;\n", + " let mut span_slot: Option = None;\n", + " for (ch_idx, ch) in run_text.char_indices() {\n", + " let abs = run_offset + ch_idx;\n", + " let next = pick_covering_slot(ch, span_slot, primary, fallback_chain, covers);\n", + " if next == span_slot { continue; }\n", + " if abs > span_start {\n", + " spans.push(RunSpan { start: span_start, end: abs, slot: span_slot });\n", + " }\n", + " span_start = abs;\n", + " span_slot = next;\n", + " }\n", + " spans\n", + " }\n", + ) + .repeat(8) // ~3 800 chars + .replace('\n', " ") +} + +fn bench_layout_line(c: &mut Criterion) { + let system = CosmicTextSystem::new_without_system_fonts("Lilex"); + system + .add_fonts(vec![Cow::Borrowed(LILEX), Cow::Borrowed(IBM_PLEX)]) + .unwrap(); + + let font_id_no_fallback = system.font_id(&font("Lilex")).unwrap(); + + let font_id_with_fallback = { + let mut f = font("Lilex"); + f.fallbacks = Some(FontFallbacks::from_fonts(vec!["IBM Plex Sans".to_string()])); + system.font_id(&f).unwrap() + }; + + let text = code_text(); + + // Same text, but with a bidi paragraph separator (U+001C) and RTL content + // forcing the per-paragraph shaping path. + let text_mixed_direction = text.clone() + "\u{001c}\u{05d0}\u{05d1}"; + assert!( + !text.contains('\n'), + "fast-path corpus must contain no separator" + ); + + let runs_no_fallback = vec![FontRun { + len: text.len(), + font_id: font_id_no_fallback, + }]; + let runs_with_fallback = vec![FontRun { + len: text.len(), + font_id: font_id_with_fallback, + }]; + let runs_mixed_direction = vec![FontRun { + len: text_mixed_direction.len(), + font_id: font_id_no_fallback, + }]; + + let mut group = c.benchmark_group("layout_line"); + + group.bench_function("no_fallback", |b| { + b.iter(|| system.layout_line(&text, px(14.0), &runs_no_fallback)) + }); + + group.bench_function("with_fallback_ascii", |b| { + b.iter(|| system.layout_line(&text, px(14.0), &runs_with_fallback)) + }); + + group.bench_function("mixed_direction_paragraphs", |b| { + b.iter(|| system.layout_line(&text_mixed_direction, px(14.0), &runs_mixed_direction)) + }); + + group.finish(); +} + +criterion_group!(benches, bench_layout_line); +criterion_main!(benches); diff --git a/vendor/gpui-pre-wgpu/src/cosmic_text_system.rs b/vendor/gpui-pre-wgpu/src/cosmic_text_system.rs new file mode 100644 index 00000000..0e4e3b11 --- /dev/null +++ b/vendor/gpui-pre-wgpu/src/cosmic_text_system.rs @@ -0,0 +1,1798 @@ +use anyhow::{Context as _, Ok, Result}; +use collections::HashMap; +use cosmic_text::{ + Attrs, AttrsList, Ellipsize, Family, Font as CosmicTextFont, + FontFeatures as CosmicFontFeatures, FontSystem, ShapeBuffer, ShapeLine, Stretch, Style, Weight, +}; +use gpui::{ + Bounds, DevicePixels, FallbackFontClass, Font, FontFallbacks, FontFeatures, FontId, + FontMetrics, FontRun, GlyphId, IsZero as _, LineLayout, MissingGlyph, MissingGlyphSink, Pixels, + PlatformTextSystem, RenderGlyphParams, SUBPIXEL_VARIANTS_X, SUBPIXEL_VARIANTS_Y, ShapedGlyph, + ShapedRun, SharedString, Size, TextRenderingMode, point, size, +}; + +use itertools::Itertools; +use parking_lot::RwLock; +use smallvec::SmallVec; +use std::{borrow::Cow, ops::Range, sync::Arc}; +use swash::{ + scale::{Render, ScaleContext, Source, StrikeWith}, + zeno::{Format, Vector}, +}; +use unicode_segmentation::UnicodeSegmentation; + +pub struct CosmicTextSystem(RwLock); + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct FontKey { + family: SharedString, + features: FontFeatures, + fallbacks: Option, +} + +impl FontKey { + fn new(family: SharedString, features: FontFeatures, fallbacks: Option) -> Self { + Self { + family, + features, + fallbacks, + } + } +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +struct LoadedFontKey { + database_id: cosmic_text::fontdb::ID, + font: FontKey, +} + +struct CosmicTextSystemState { + font_system: FontSystem, + scratch: ShapeBuffer, + swash_scale_context: ScaleContext, + pending_glyph_images: HashMap, + /// Contains all already loaded fonts, including all faces. Indexed by `FontId`. + loaded_fonts: Vec, + loaded_font_ids_by_key: HashMap, + /// Caches the `FontId`s associated with a specific family to avoid iterating the font database + /// for every font face in a family. + font_ids_by_family_cache: HashMap>, + system_font_fallback: String, + missing_glyph_sink: Option>, +} + +struct LoadedFont { + font: Arc, + features: CosmicFontFeatures, + is_known_emoji_font: bool, + /// resolved at load time so `layout_line` shares one chain across faces. + /// `Arc` keeps clone cheap on the per-run hot path. + user_fallback_chain: Arc<[(FontId, SharedString)]>, +} + +struct FontMatchProperties { + primary_family_name: SharedString, + stretch: Stretch, + style: Style, + weight: Weight, + features: CosmicFontFeatures, + fallback_chain: Arc<[(FontId, SharedString)]>, +} + +impl FontMatchProperties { + fn attributes<'a>(&'a self, font_id: FontId, family_name: &'a str) -> Attrs<'a> { + Attrs::new() + .metadata(font_id.0) + .family(Family::Name(family_name)) + .stretch(self.stretch) + .style(self.style) + .weight(self.weight) + .font_features(self.features.clone()) + } +} + +impl CosmicTextSystem { + /// Returns the selected face's weight and style, which may differ from the request. + pub fn font_weight_and_style( + &self, + font_id: FontId, + ) -> Result<(gpui::FontWeight, gpui::FontStyle)> { + let state = self.0.read(); + let font = state + .loaded_fonts + .get(font_id.0) + .context("invalid font ID")?; + let face = state + .font_system + .db() + .face(font.font.id()) + .context("font face not found")?; + let style = match face.style { + cosmic_text::Style::Normal => gpui::FontStyle::Normal, + cosmic_text::Style::Italic => gpui::FontStyle::Italic, + cosmic_text::Style::Oblique => gpui::FontStyle::Oblique, + }; + Ok((gpui::FontWeight(face.weight.0 as f32), style)) + } + + /// Builds reports for unresolved source indices after an outer text system + /// has applied its own fallback. + pub fn missing_glyphs( + &self, + text: &str, + font_runs: &[FontRun], + missing_text_indices: impl IntoIterator, + ) -> Vec { + self.0 + .read() + .missing_glyphs(text, font_runs, missing_text_indices) + } + + pub fn new(system_font_fallback: &str) -> Self { + let font_system = FontSystem::new(); + + Self(RwLock::new(CosmicTextSystemState { + font_system, + scratch: ShapeBuffer::default(), + swash_scale_context: ScaleContext::new(), + pending_glyph_images: HashMap::default(), + loaded_fonts: Vec::new(), + loaded_font_ids_by_key: HashMap::default(), + font_ids_by_family_cache: HashMap::default(), + system_font_fallback: system_font_fallback.to_string(), + missing_glyph_sink: None, + })) + } + + pub fn new_without_system_fonts(system_font_fallback: &str) -> Self { + let font_system = FontSystem::new_with_locale_and_db( + "en-US".to_string(), + cosmic_text::fontdb::Database::new(), + ); + + Self(RwLock::new(CosmicTextSystemState { + font_system, + scratch: ShapeBuffer::default(), + swash_scale_context: ScaleContext::new(), + pending_glyph_images: HashMap::default(), + loaded_fonts: Vec::new(), + loaded_font_ids_by_key: HashMap::default(), + font_ids_by_family_cache: HashMap::default(), + system_font_fallback: system_font_fallback.to_string(), + missing_glyph_sink: None, + })) + } +} + +impl PlatformTextSystem for CosmicTextSystem { + fn add_fonts(&self, fonts: Vec>) -> Result<()> { + self.0.write().add_fonts(fonts) + } + + fn set_missing_glyph_sink(&self, sink: Option>) { + self.0.write().missing_glyph_sink = sink; + } + + fn all_font_names(&self) -> Vec { + let mut result = self + .0 + .read() + .font_system + .db() + .faces() + .filter_map(|face| face.families.first().map(|family| family.0.clone())) + .collect_vec(); + result.sort_unstable(); + result.dedup(); + result + } + + fn font_id(&self, font: &Font) -> Result { + let mut state = self.0.write(); + let key = FontKey::new( + font.family.clone(), + font.features.clone(), + font.fallbacks.clone(), + ); + let candidates = if let Some(font_ids) = state.font_ids_by_family_cache.get(&key) { + font_ids.as_slice() + } else { + let font_ids = + state.load_family(&font.family, &font.features, font.fallbacks.as_ref())?; + state.font_ids_by_family_cache.insert(key.clone(), font_ids); + state.font_ids_by_family_cache[&key].as_ref() + }; + + let ix = find_best_match(font, candidates, &state)?; + + Ok(candidates[ix]) + } + + fn prewarm_fonts(&self, font_ids: &[FontId]) { + self.0.write().prewarm_fonts(font_ids); + } + + fn font_metrics(&self, font_id: FontId) -> FontMetrics { + let metrics = self + .0 + .read() + .loaded_font(font_id) + .font + .as_swash() + .metrics(&[]); + + FontMetrics { + units_per_em: metrics.units_per_em as u32, + ascent: metrics.ascent, + descent: -metrics.descent, + line_gap: metrics.leading, + underline_position: metrics.underline_offset, + underline_thickness: metrics.stroke_size, + cap_height: metrics.cap_height, + x_height: metrics.x_height, + bounding_box: Bounds { + origin: point(0.0, 0.0), + size: size(metrics.max_width, metrics.ascent + metrics.descent), + }, + } + } + + fn typographic_bounds(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + let lock = self.0.read(); + let glyph_metrics = lock.loaded_font(font_id).font.as_swash().glyph_metrics(&[]); + let glyph_id = glyph_id.0 as u16; + Ok(Bounds { + origin: point(0.0, 0.0), + size: size( + glyph_metrics.advance_width(glyph_id), + glyph_metrics.advance_height(glyph_id), + ), + }) + } + + fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + self.0.read().advance(font_id, glyph_id) + } + + fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option { + self.0.read().glyph_for_char(font_id, ch) + } + + fn glyph_raster_bounds(&self, params: &RenderGlyphParams) -> Result> { + self.0.write().raster_bounds(params) + } + + fn rasterize_glyph( + &self, + params: &RenderGlyphParams, + raster_bounds: Bounds, + ) -> Result<(Size, Vec)> { + self.0.write().rasterize_glyph(params, raster_bounds) + } + + fn layout_line(&self, text: &str, font_size: Pixels, runs: &[FontRun]) -> LineLayout { + self.0.write().layout_line(text, font_size, runs) + } + + fn recommended_rendering_mode( + &self, + _font_id: FontId, + _font_size: Pixels, + ) -> TextRenderingMode { + TextRenderingMode::Subpixel + } +} + +impl CosmicTextSystemState { + fn loaded_font(&self, font_id: FontId) -> &LoadedFont { + &self.loaded_fonts[font_id.0] + } + + fn font_match_properties(&self, font_id: FontId) -> Option { + let loaded_font = self.loaded_font(font_id); + let Some(face) = self.font_system.db().face(loaded_font.font.id()) else { + log::warn!("font face not found in database for font_id {:?}", font_id); + return None; + }; + let Some(first_family) = face.families.first() else { + log::warn!("font face has no family names for font_id {:?}", font_id); + return None; + }; + + Some(FontMatchProperties { + primary_family_name: first_family.0.clone().into(), + stretch: face.stretch, + style: face.style, + weight: face.weight, + features: loaded_font.features.clone(), + fallback_chain: Arc::clone(&loaded_font.user_fallback_chain), + }) + } + + fn prewarm_fonts(&mut self, font_ids: &[FontId]) { + for &font_id in font_ids { + let Some(properties) = self.font_match_properties(font_id) else { + continue; + }; + let primary_attributes = + properties.attributes(font_id, &properties.primary_family_name); + self.font_system.get_font_matches(&primary_attributes); + + for (fallback_id, fallback_name) in &*properties.fallback_chain { + let fallback_attributes = properties.attributes(*fallback_id, fallback_name); + self.font_system.get_font_matches(&fallback_attributes); + } + } + } + + #[profiling::function] + fn add_fonts(&mut self, fonts: Vec>) -> Result<()> { + self.font_ids_by_family_cache.clear(); + let db = self.font_system.db_mut(); + for bytes in fonts { + db.load_font_source(cosmic_text::fontdb::Source::Binary(Arc::new(bytes))); + } + Ok(()) + } + + #[profiling::function] + fn load_family( + &mut self, + name: &str, + features: &FontFeatures, + fallbacks: Option<&FontFallbacks>, + ) -> Result> { + let loaded_font_key = FontKey::new( + SharedString::from(name.to_owned()), + features.clone(), + fallbacks.cloned(), + ); + + // recurse with `fallbacks = None` so a fallback family cannot pull in + // another chain. missing fallback families are dropped so a typo in + // settings still lets the primary family load. + let user_fallback_chain: Arc<[(FontId, SharedString)]> = match fallbacks { + Some(fallbacks) if !fallbacks.fallback_list().is_empty() => { + let mut chain: Vec<(FontId, SharedString)> = Vec::new(); + for fallback_name in fallbacks.fallback_list() { + let fb_key = FontKey::new( + SharedString::from(fallback_name.clone()), + features.clone(), + None, + ); + let fb_ids = if let Some(cached) = self.font_ids_by_family_cache.get(&fb_key) { + cached.clone() + } else { + let loaded = self.load_family(fallback_name, features, None)?; + self.font_ids_by_family_cache + .insert(fb_key.clone(), loaded.clone()); + loaded + }; + let Some(&fb_id) = fb_ids.first() else { + continue; + }; + let db_id = self.loaded_fonts[fb_id.0].font.id(); + if let Some(face) = self.font_system.db().face(db_id) + && let Some(family) = face.families.first() + { + chain.push((fb_id, SharedString::from(family.0.clone()))); + } + } + Arc::from(chain) + } + _ => Arc::from(Vec::new()), + }; + + let name = gpui::font_name_with_fallbacks(name, &self.system_font_fallback); + + let families = self + .font_system + .db() + .faces() + .filter(|face| face.families.iter().any(|family| *name == family.0)) + .map(|face| (face.id, face.post_script_name.clone())) + .collect::>(); + + let cosmic_features = cosmic_font_features(features)?; + + let mut loaded_font_ids = SmallVec::new(); + for (database_id, postscript_name) in families { + let key = LoadedFontKey { + database_id, + font: loaded_font_key.clone(), + }; + if let Some(&font_id) = self.loaded_font_ids_by_key.get(&key) { + self.loaded_fonts[font_id.0].user_fallback_chain = Arc::clone(&user_fallback_chain); + loaded_font_ids.push(font_id); + continue; + } + + let font = self + .font_system + .get_font(database_id, cosmic_text::Weight::NORMAL) + .context("Could not load font")?; + + // HACK: To let the storybook run and render Windows caption icons. We should actually do better font fallback. + let allowed_bad_font_names = [ + "SegoeFluentIcons", // NOTE: Segoe fluent icons postscript name is inconsistent + "Segoe Fluent Icons", + ]; + + if font.as_swash().charmap().map('m') == 0 + && !allowed_bad_font_names.contains(&postscript_name.as_str()) + { + self.font_system.db_mut().remove_face(font.id()); + continue; + }; + + let font_id = FontId(self.loaded_fonts.len()); + loaded_font_ids.push(font_id); + self.loaded_fonts.push(LoadedFont { + font, + features: cosmic_features.clone(), + is_known_emoji_font: check_is_known_emoji_font(&postscript_name), + user_fallback_chain: Arc::clone(&user_fallback_chain), + }); + self.loaded_font_ids_by_key.insert(key, font_id); + } + + Ok(loaded_font_ids) + } + + fn advance(&self, font_id: FontId, glyph_id: GlyphId) -> Result> { + let glyph_metrics = self.loaded_font(font_id).font.as_swash().glyph_metrics(&[]); + Ok(Size { + width: glyph_metrics.advance_width(glyph_id.0 as u16), + height: glyph_metrics.advance_height(glyph_id.0 as u16), + }) + } + + fn glyph_for_char(&self, font_id: FontId, ch: char) -> Option { + let glyph_id = self.loaded_font(font_id).font.as_swash().charmap().map(ch); + if glyph_id == 0 { + None + } else { + Some(GlyphId(glyph_id.into())) + } + } + + fn raster_bounds(&mut self, params: &RenderGlyphParams) -> Result> { + let image = self.render_glyph_image(params)?; + let bounds = Bounds { + origin: point(image.placement.left.into(), (-image.placement.top).into()), + size: size(image.placement.width.into(), image.placement.height.into()), + }; + if !bounds.is_zero() { + self.pending_glyph_images.insert(params.clone(), image); + } + Ok(bounds) + } + + #[profiling::function] + fn rasterize_glyph( + &mut self, + params: &RenderGlyphParams, + glyph_bounds: Bounds, + ) -> Result<(Size, Vec)> { + if glyph_bounds.size.width.0 == 0 || glyph_bounds.size.height.0 == 0 { + anyhow::bail!("glyph bounds are empty"); + } + + let mut image = match self.pending_glyph_images.remove(params) { + Some(image) => image, + None => self.render_glyph_image(params)?, + }; + let bitmap_size = glyph_bounds.size; + match image.content { + swash::scale::image::Content::Color | swash::scale::image::Content::SubpixelMask => { + // Convert from RGBA to BGRA. + for pixel in image.data.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Ok((bitmap_size, image.data)) + } + swash::scale::image::Content::Mask => { + if params.subpixel_rendering { + // We must always return RGBA data when subpixel rendering is requested. + let expanded = image.data.iter().flat_map(|&a| [a, a, a, a]).collect(); + Ok((bitmap_size, expanded)) + } else { + Ok((bitmap_size, image.data)) + } + } + } + } + + fn render_glyph_image( + &mut self, + params: &RenderGlyphParams, + ) -> Result { + let loaded_font = &self.loaded_fonts[params.font_id.0]; + let font_ref = loaded_font.font.as_swash(); + let pixel_size = f32::from(params.font_size); + + let subpixel_offset = Vector::new( + params.subpixel_variant.x as f32 / SUBPIXEL_VARIANTS_X as f32 / params.scale_factor, + params.subpixel_variant.y as f32 / SUBPIXEL_VARIANTS_Y as f32 / params.scale_factor, + ); + + let mut scaler = self + .swash_scale_context + .builder(font_ref) + .size(pixel_size * params.scale_factor) + .hint(true) + .build(); + + let sources: &[Source] = if params.is_emoji { + &[ + Source::ColorOutline(0), + Source::ColorBitmap(StrikeWith::BestFit), + Source::Outline, + ] + } else { + &[Source::Bitmap(StrikeWith::ExactSize), Source::Outline] + }; + + let mut renderer = Render::new(sources); + if params.subpixel_rendering { + // There seems to be a bug in Swash where the B and R values are swapped. + renderer + .format(Format::subpixel_bgra()) + .offset(subpixel_offset); + } else { + renderer.format(Format::Alpha).offset(subpixel_offset); + } + + let glyph_id: u16 = params.glyph_id.0.try_into()?; + renderer + .render(&mut scaler, glyph_id) + .with_context(|| format!("unable to render glyph via swash for {params:?}")) + } + + /// This is used when cosmic_text has chosen a fallback font instead of using the requested + /// font, typically to handle some unicode characters. When this happens, `loaded_fonts` may not + /// yet have an entry for this fallback font, and so one is added. + /// + /// Note that callers shouldn't use this `FontId` somewhere that will retrieve the corresponding + /// `LoadedFont.features`, as it will have an arbitrarily chosen or empty value. The only + /// current use of this field is for the *input* of `layout_line`, and so it's fine to use + /// `font_id_for_cosmic_id` when computing the *output* of `layout_line`. + fn font_id_for_cosmic_id(&mut self, id: cosmic_text::fontdb::ID) -> Result { + if let Some(ix) = self + .loaded_fonts + .iter() + .position(|loaded_font| loaded_font.font.id() == id) + { + Ok(FontId(ix)) + } else { + let font = self + .font_system + .get_font(id, cosmic_text::Weight::NORMAL) + .context("failed to get fallback font from cosmic-text font system")?; + let face = self + .font_system + .db() + .face(id) + .context("fallback font face not found in cosmic-text database")?; + + let font_id = FontId(self.loaded_fonts.len()); + self.loaded_fonts.push(LoadedFont { + font, + features: CosmicFontFeatures::new(), + is_known_emoji_font: check_is_known_emoji_font(&face.post_script_name), + user_fallback_chain: Arc::from(Vec::new()), + }); + + Ok(font_id) + } + } + + #[profiling::function] + fn layout_line(&mut self, text: &str, font_size: Pixels, font_runs: &[FontRun]) -> LineLayout { + if contains_paragraph_separator(text) { + self.layout_line_with_separators(text, font_size, font_runs) + } else { + self.layout_line_no_separators(text, font_size, font_runs) + } + } + + fn layout_line_with_separators( + &mut self, + text: &str, + font_size: Pixels, + font_runs: &[FontRun], + ) -> LineLayout { + let mut layout = LineLayout { + font_size, + len: text.len(), + ..Default::default() + }; + let mut paragraph_start = 0; + + for (separator_start, separator) in text + .char_indices() + .filter(|(_, character)| is_paragraph_separator(*character)) + { + let separator_end = separator_start + separator.len_utf8(); + self.shape_segment( + text, + paragraph_start..separator_start, + font_size, + font_runs, + &mut layout, + ); + self.shape_segment( + text, + separator_start..separator_end, + font_size, + font_runs, + &mut layout, + ); + paragraph_start = separator_end; + } + + self.shape_segment( + text, + paragraph_start..text.len(), + font_size, + font_runs, + &mut layout, + ); + + layout + } + + fn shape_segment( + &mut self, + text: &str, + range: Range, + font_size: Pixels, + font_runs: &[FontRun], + layout: &mut LineLayout, + ) { + if range.is_empty() { + return; + } + + let segment_font_runs = clip_font_runs(font_runs, range.clone()); + let segment = + self.layout_line_no_separators(&text[range.clone()], font_size, &segment_font_runs); + + let mut segment_runs = segment.runs; + for run in &mut segment_runs { + for glyph in &mut run.glyphs { + glyph.index += range.start; + glyph.position.x += layout.width; + } + } + + for mut run in segment_runs { + if let Some(same_run) = layout + .runs + .last_mut() + .filter(|last| last.font_id == run.font_id) + { + same_run.glyphs.append(&mut run.glyphs); + } else { + layout.runs.push(run); + } + } + + layout.width += segment.width; + layout.ascent = layout.ascent.max(segment.ascent); + layout.descent = layout.descent.max(segment.descent); + } + + fn layout_line_no_separators( + &mut self, + text: &str, + font_size: Pixels, + font_runs: &[FontRun], + ) -> LineLayout { + let mut attrs_list = AttrsList::new(&Attrs::new()); + let mut offs = 0; + for run in font_runs { + let run_end = offs + run.len; + + let Some(properties) = self.font_match_properties(run.font_id) else { + offs = run_end; + continue; + }; + + let primary_attrs = properties.attributes(run.font_id, &properties.primary_family_name); + let fallback_attrs: SmallVec<[Attrs<'_>; 4]> = properties + .fallback_chain + .iter() + .map(|(font_id, family_name)| properties.attributes(*font_id, family_name)) + .collect(); + + let spans = if properties.fallback_chain.is_empty() { + let mut spans = SmallVec::<[RunSpan; 4]>::new(); + spans.push(RunSpan { + start: offs, + end: run_end, + slot: None, + font_id: run.font_id, + }); + spans + } else { + let loaded_fonts = &self.loaded_fonts; + let covers = |id: FontId, ch: char| charmap_covers(loaded_fonts, id, ch); + compute_run_spans( + text, + offs, + run.len, + run.font_id, + &properties.fallback_chain, + &covers, + ) + }; + + for span in spans { + let attrs = match span.slot { + None => &primary_attrs, + Some(ix) => &fallback_attrs[ix], + }; + attrs_list.add_span(span.start..span.end, attrs); + } + offs = run_end; + } + + let line = ShapeLine::new( + &mut self.font_system, + text, + &attrs_list, + cosmic_text::Shaping::Advanced, + 4, + ); + let mut layout_lines = Vec::with_capacity(1); + line.layout_to_buffer( + &mut self.scratch, + f32::from(font_size), + None, // We do our own wrapping + cosmic_text::Wrap::None, + Ellipsize::None, + None, + &mut layout_lines, + None, + cosmic_text::Hinting::Disabled, + ); + + let Some(layout) = layout_lines.first() else { + return LineLayout { + font_size, + width: Pixels::ZERO, + ascent: Pixels::ZERO, + descent: Pixels::ZERO, + runs: Vec::new(), + len: text.len(), + }; + }; + + let missing_glyphs = self.missing_glyph_sink.as_ref().map(|_| { + self.missing_glyphs( + text, + font_runs, + layout + .glyphs + .iter() + .filter(|glyph| glyph.glyph_id == 0) + .map(|glyph| glyph.start), + ) + }); + + let mut runs: Vec = Vec::new(); + for glyph in &layout.glyphs { + let mut font_id = FontId(glyph.metadata); + let mut loaded_font = self.loaded_font(font_id); + if loaded_font.font.id() != glyph.font_id { + match self.font_id_for_cosmic_id(glyph.font_id) { + std::result::Result::Ok(resolved_id) => { + font_id = resolved_id; + loaded_font = self.loaded_font(font_id); + } + Err(error) => { + log::warn!( + "failed to resolve cosmic font id {:?}: {error:#}", + glyph.font_id + ); + continue; + } + } + } + let is_emoji = loaded_font.is_known_emoji_font; + + // HACK: Prevent crash caused by variation selectors. + if glyph.glyph_id == 3 && is_emoji { + continue; + } + + let shaped_glyph = ShapedGlyph { + id: GlyphId(glyph.glyph_id as u32), + position: point(glyph.x.into(), glyph.y.into()), + index: glyph.start, + is_emoji, + }; + + if let Some(last_run) = runs + .last_mut() + .filter(|last_run| last_run.font_id == font_id) + { + last_run.glyphs.push(shaped_glyph); + } else { + runs.push(ShapedRun { + font_id, + glyphs: vec![shaped_glyph], + }); + } + } + + if let Some((sink, missing_glyphs)) = self.missing_glyph_sink.as_ref().zip(missing_glyphs) { + sink.report(missing_glyphs); + } + + LineLayout { + font_size, + width: layout.w.into(), + ascent: layout.max_ascent.into(), + descent: layout.max_descent.into(), + runs, + len: text.len(), + } + } + + fn missing_glyphs( + &self, + text: &str, + font_runs: &[FontRun], + missing_text_indices: impl IntoIterator, + ) -> Vec { + let mut missing_text_indices = missing_text_indices.into_iter().peekable(); + if missing_text_indices.peek().is_none() { + return Vec::new(); + } + let mut missing_text_indices = missing_text_indices.collect::>(); + missing_text_indices.sort_unstable(); + missing_text_indices.dedup(); + + let mut font_run_index = 0; + let mut font_run_end = font_runs.first().map_or(0, |font_run| font_run.len); + let mut missing_glyphs = Vec::new(); + let mut missing_index = 0; + for (grapheme_start, grapheme) in text.grapheme_indices(true) { + let grapheme_end = grapheme_start + grapheme.len(); + while missing_text_indices + .get(missing_index) + .is_some_and(|text_index| *text_index < grapheme_start) + { + missing_index += 1; + } + let Some(&text_index) = missing_text_indices.get(missing_index) else { + break; + }; + if text_index >= grapheme_end { + continue; + } + + while font_run_end <= text_index && font_run_index + 1 < font_runs.len() { + font_run_index += 1; + let Some(font_run) = font_runs.get(font_run_index) else { + break; + }; + font_run_end += font_run.len; + } + let font_class = self.fallback_font_class( + font_runs + .get(font_run_index) + .or_else(|| font_runs.last()) + .map(|font_run| font_run.font_id), + ); + missing_glyphs.push(MissingGlyph::new(grapheme.into(), font_class)); + while missing_text_indices + .get(missing_index) + .is_some_and(|text_index| *text_index < grapheme_end) + { + missing_index += 1; + } + } + missing_glyphs + } + + fn fallback_font_class(&self, font_id: Option) -> FallbackFontClass { + let Some(font_id) = font_id else { + return FallbackFontClass::Proportional; + }; + let loaded_font = self.loaded_font(font_id); + let is_monospace = self + .font_system + .db() + .face(loaded_font.font.id()) + .is_some_and(|face| face.monospaced); + if is_monospace { + FallbackFontClass::Monospace + } else { + FallbackFontClass::Proportional + } + } +} + +#[inline(always)] +fn is_paragraph_separator(character: char) -> bool { + unicode_bidi::bidi_class(character) == unicode_bidi::BidiClass::B +} + +fn contains_paragraph_separator(text: &str) -> bool { + if text + .bytes() + .any(|byte| matches!(byte, b'\n' | b'\r' | 0x1c | 0x1d | 0x1e)) + { + return true; + } + + !text.is_ascii() && text.chars().any(is_paragraph_separator) +} + +fn clip_font_runs(font_runs: &[FontRun], range: Range) -> SmallVec<[FontRun; 4]> { + let mut clipped = SmallVec::new(); + let mut offs = 0; + for run in font_runs { + let run_start = offs; + offs += run.len; + if offs <= range.start { + continue; + } + if run_start >= range.end { + break; + } + let start = run_start.max(range.start); + let end = offs.min(range.end); + if start < end { + clipped.push(FontRun { + len: end - start, + font_id: run.font_id, + }); + } + } + clipped +} + +#[cfg(feature = "font-kit")] +fn find_best_match( + font: &Font, + candidates: &[FontId], + state: &CosmicTextSystemState, +) -> Result { + let candidate_properties = candidates + .iter() + .map(|font_id| { + let database_id = state.loaded_font(*font_id).font.id(); + let face_info = state + .font_system + .db() + .face(database_id) + .context("font face not found in database")?; + Ok(face_info_into_properties(face_info)) + }) + .collect::>>()?; + + let ix = + font_kit::matching::find_best_match(&candidate_properties, &font_into_properties(font)) + .context("requested font family contains no font matching the other parameters")?; + + Ok(ix) +} + +#[cfg(not(feature = "font-kit"))] +fn find_best_match( + font: &Font, + candidates: &[FontId], + state: &CosmicTextSystemState, +) -> Result { + if candidates.is_empty() { + anyhow::bail!("requested font family contains no font matching the other parameters"); + } + if candidates.len() == 1 { + return Ok(0); + } + + let target_weight = font.weight.0; + let target_italic = matches!( + font.style, + gpui::FontStyle::Italic | gpui::FontStyle::Oblique + ); + + let mut best_index = 0; + let mut best_score = u32::MAX; + + for (index, font_id) in candidates.iter().enumerate() { + let database_id = state.loaded_font(*font_id).font.id(); + let face_info = state + .font_system + .db() + .face(database_id) + .context("font face not found in database")?; + + let is_italic = matches!( + face_info.style, + cosmic_text::Style::Italic | cosmic_text::Style::Oblique + ); + let style_penalty: u32 = if is_italic == target_italic { 0 } else { 1000 }; + let weight_diff = (face_info.weight.0 as i32 - target_weight as i32).unsigned_abs(); + let score = style_penalty + weight_diff; + + if score < best_score { + best_score = score; + best_index = index; + } + } + + Ok(best_index) +} + +/// one contiguous slice of a `FontRun` that maps to a single slot. `slot` is +/// `None` for the primary font and `Some(ix)` for `fallback_chain[ix]`. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct RunSpan { + start: usize, + end: usize, + slot: Option, + font_id: FontId, +} + +/// walks `text[run_offset..run_offset + run_len]` and groups codepoints into +/// spans. inheriting codepoints stay in the current span so shaping clusters +/// like emoji zwj sequences and combining marks are not torn apart. +fn compute_run_spans( + text: &str, + run_offset: usize, + run_len: usize, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], + covers: &impl Fn(FontId, char) -> bool, +) -> SmallVec<[RunSpan; 4]> { + let mut spans = SmallVec::new(); + let run_end = run_offset + run_len; + if run_end <= run_offset { + return spans; + } + if fallback_chain.is_empty() { + spans.push(RunSpan { + start: run_offset, + end: run_end, + slot: None, + font_id: primary, + }); + return spans; + } + let run_text = &text[run_offset..run_end]; + let mut span_start = run_offset; + let mut span_slot: Option = None; + let mut span_font_id = primary; + for (grapheme_idx, grapheme) in run_text.grapheme_indices(true) { + let abs = run_offset + grapheme_idx; + let ch = grapheme.chars().next().unwrap_or('\0'); + let next_slot = pick_covering_slot(ch, span_slot, primary, fallback_chain, covers); + if next_slot == span_slot { + continue; + } + if abs > span_start { + spans.push(RunSpan { + start: span_start, + end: abs, + slot: span_slot, + font_id: span_font_id, + }); + } + span_start = abs; + span_slot = next_slot; + span_font_id = slot_font_id(next_slot, primary, fallback_chain); + } + if span_start < run_end { + spans.push(RunSpan { + start: span_start, + end: run_end, + slot: span_slot, + font_id: span_font_id, + }); + } + spans +} + +fn slot_font_id( + slot: Option, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], +) -> FontId { + match slot { + None => primary, + Some(ix) => fallback_chain[ix].0, + } +} + +fn pick_covering_slot( + ch: char, + current: Option, + primary: FontId, + fallback_chain: &[(FontId, SharedString)], + covers: &impl Fn(FontId, char) -> bool, +) -> Option { + if (ch as u32) <= 0x7F { + return None; + } + if covers(primary, ch) { + return None; + } + let current_id = slot_font_id(current, primary, fallback_chain); + if covers(current_id, ch) { + return current; + } + + fallback_chain + .iter() + .position(|(fb_id, _)| covers(*fb_id, ch)) +} + +fn charmap_covers(loaded_fonts: &[LoadedFont], id: FontId, ch: char) -> bool { + loaded_fonts + .get(id.0) + .is_some_and(|loaded| loaded.font.as_swash().charmap().map(ch) != 0) +} + +fn cosmic_font_features(features: &FontFeatures) -> Result { + let mut result = CosmicFontFeatures::new(); + for feature in features.0.iter() { + let name_bytes: [u8; 4] = feature + .0 + .as_bytes() + .try_into() + .context("Incorrect feature flag format")?; + + let tag = cosmic_text::FeatureTag::new(&name_bytes); + + result.set(tag, feature.1); + } + Ok(result) +} + +#[cfg(feature = "font-kit")] +fn font_into_properties(font: &gpui::Font) -> font_kit::properties::Properties { + font_kit::properties::Properties { + style: match font.style { + gpui::FontStyle::Normal => font_kit::properties::Style::Normal, + gpui::FontStyle::Italic => font_kit::properties::Style::Italic, + gpui::FontStyle::Oblique => font_kit::properties::Style::Oblique, + }, + weight: font_kit::properties::Weight(font.weight.0), + stretch: Default::default(), + } +} + +#[cfg(feature = "font-kit")] +fn face_info_into_properties( + face_info: &cosmic_text::fontdb::FaceInfo, +) -> font_kit::properties::Properties { + font_kit::properties::Properties { + style: match face_info.style { + cosmic_text::Style::Normal => font_kit::properties::Style::Normal, + cosmic_text::Style::Italic => font_kit::properties::Style::Italic, + cosmic_text::Style::Oblique => font_kit::properties::Style::Oblique, + }, + weight: font_kit::properties::Weight(face_info.weight.0.into()), + stretch: match face_info.stretch { + cosmic_text::Stretch::Condensed => font_kit::properties::Stretch::CONDENSED, + cosmic_text::Stretch::Expanded => font_kit::properties::Stretch::EXPANDED, + cosmic_text::Stretch::ExtraCondensed => font_kit::properties::Stretch::EXTRA_CONDENSED, + cosmic_text::Stretch::ExtraExpanded => font_kit::properties::Stretch::EXTRA_EXPANDED, + cosmic_text::Stretch::Normal => font_kit::properties::Stretch::NORMAL, + cosmic_text::Stretch::SemiCondensed => font_kit::properties::Stretch::SEMI_CONDENSED, + cosmic_text::Stretch::SemiExpanded => font_kit::properties::Stretch::SEMI_EXPANDED, + cosmic_text::Stretch::UltraCondensed => font_kit::properties::Stretch::ULTRA_CONDENSED, + cosmic_text::Stretch::UltraExpanded => font_kit::properties::Stretch::ULTRA_EXPANDED, + }, + } +} + +fn check_is_known_emoji_font(postscript_name: &str) -> bool { + // TODO: Include other common emoji fonts + postscript_name == "NotoColorEmoji" +} + +#[cfg(test)] +mod tests { + use super::*; + #[cfg(feature = "test-support")] + use std::{cell::RefCell, rc::Rc}; + + #[test] + fn all_font_names_tracks_available_families() -> Result<()> { + let text_system = gpui::TextSystem::new(Arc::new( + CosmicTextSystem::new_without_system_fonts("IBM Plex Sans"), + )); + assert!(text_system.all_font_names().is_empty()); + + text_system.add_fonts(vec![Cow::Borrowed(include_bytes!( + "../../../assets/fonts/lilex/Lilex-Regular.ttf" + ))])?; + assert_eq!(text_system.all_font_names(), ["Lilex"]); + + text_system.add_fonts(vec![ + Cow::Borrowed(IBM_PLEX), + Cow::Borrowed(include_bytes!("../../../assets/fonts/lilex/Lilex-Bold.ttf")), + ])?; + assert_eq!(text_system.all_font_names(), ["IBM Plex Sans", "Lilex"]); + Ok(()) + } + + fn fid(i: usize) -> FontId { + FontId(i) + } + + fn chain(ids: &[usize]) -> SmallVec<[(FontId, SharedString); 4]> { + ids.iter() + .map(|&i| (fid(i), SharedString::from(format!("fb{i}")))) + .collect() + } + + fn span(start: usize, end: usize, slot: Option, font_id: FontId) -> RunSpan { + RunSpan { + start, + end, + slot, + font_id, + } + } + + const IBM_PLEX: &[u8] = + include_bytes!("../../../assets/fonts/ibm-plex-sans/IBMPlexSans-Regular.ttf"); + const LILEX: &[u8] = include_bytes!("../../../assets/fonts/lilex/Lilex-Regular.ttf"); + + /// Every code point of `Bidi_Class=B`, each of which starts a new bidi + /// paragraph and so can split one line into mixed-direction paragraphs. + const SEPARATORS: &[char] = &[ + '\u{000a}', '\u{000d}', '\u{001c}', '\u{001d}', '\u{001e}', '\u{0085}', '\u{2029}', + ]; + + fn text_system() -> Result { + let text_system = CosmicTextSystem::new_without_system_fonts("IBM Plex Sans"); + text_system.add_fonts(vec![Cow::Borrowed(IBM_PLEX)])?; + Ok(text_system) + } + + #[test] + fn font_properties_describe_the_selected_face() -> Result<()> { + let text_system = text_system()?; + let regular = gpui::font("IBM Plex Sans"); + let regular_id = text_system.font_id(®ular)?; + for (weight, style) in [ + (gpui::FontWeight::MEDIUM, gpui::FontStyle::Normal), + (gpui::FontWeight::BOLD, gpui::FontStyle::Italic), + (gpui::FontWeight::NORMAL, gpui::FontStyle::Oblique), + ] { + let requested = Font { + weight, + style, + ..regular.clone() + }; + let font_id = text_system.font_id(&requested)?; + assert_eq!(font_id, regular_id); + assert_eq!( + text_system.font_weight_and_style(font_id)?, + (gpui::FontWeight::NORMAL, gpui::FontStyle::Normal) + ); + } + Ok(()) + } + + fn layout_text(text_system: &CosmicTextSystem, text: &str) -> Result { + let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?; + let runs = [FontRun { + len: text.len(), + font_id, + }]; + Ok(text_system.layout_line(text, gpui::px(14.0), &runs)) + } + + /// Mirrors the original crash: mixed-direction text reaching the shaper + /// through `shape_text`, which only splits lines on `\n`. + #[test] + fn shape_text_with_mixed_direction_paragraphs() -> Result<()> { + let platform_text_system = Arc::new(text_system()?); + let text_system = Arc::new(gpui::TextSystem::new(platform_text_system)); + let window_text_system = gpui::WindowTextSystem::new(text_system); + + let text: SharedString = "first line\n\u{05d0}\u{001c}A".into(); + let runs = [gpui::TextRun { + len: text.len(), + font: gpui::font("IBM Plex Sans"), + ..Default::default() + }]; + + let lines = window_text_system.shape_text(text, gpui::px(14.0), &runs, None, None)?; + + assert_eq!(lines.len(), 2); + assert_eq!(lines[1].len(), "\u{05d0}\u{001c}A".len()); + assert!(lines[1].width() > Pixels::ZERO); + Ok(()) + } + + #[cfg(feature = "test-support")] + #[test] + fn reports_graphemes_that_exhaust_font_fallback() -> Result<()> { + let platform_text_system = Arc::new(text_system()?); + let dispatcher = gpui::TestDispatcher::new(0); + let cx = + gpui::TestAppContext::build_with_text_system(dispatcher, None, platform_text_system); + let observed = Rc::new(RefCell::new(Vec::new())); + let _subscription = cx.update(|cx| { + let observed = observed.clone(); + cx.on_missing_glyphs(move |missing_glyphs, _| { + observed.borrow_mut().extend_from_slice(missing_glyphs); + }) + }); + let text: SharedString = "界".into(); + + cx.update(|cx| { + let text_system = gpui::WindowTextSystem::new(cx.text_system().clone()); + let runs = [gpui::TextRun { + len: text.len(), + font: gpui::font("IBM Plex Sans"), + ..Default::default() + }]; + text_system.shape_line(text, gpui::px(14.0), &runs, None); + }); + cx.run_until_parked(); + + let observed = observed.borrow(); + assert_eq!(observed.len(), 1); + assert_eq!(observed[0].grapheme(), "界"); + assert_eq!( + observed[0].font_class(), + gpui::FallbackFontClass::Proportional + ); + Ok(()) + } + + #[test] + fn combines_missing_glyphs_from_one_grapheme() -> Result<()> { + let text_system = text_system()?; + let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?; + let text = "x\u{0301}"; + let runs = [FontRun { + len: text.len(), + font_id, + }]; + + let missing_glyphs = text_system + .0 + .read() + .missing_glyphs(text, &runs, [0, "x".len()]); + + assert_eq!(missing_glyphs.len(), 1); + assert_eq!(missing_glyphs[0].grapheme(), text); + Ok(()) + } + + #[test] + fn adding_fonts_invalidates_cached_line_layouts() -> Result<()> { + let platform_text_system = Arc::new(text_system()?); + let text_system = Arc::new(gpui::TextSystem::new(platform_text_system.clone())); + let window_text_system = gpui::WindowTextSystem::new(text_system.clone()); + let text: SharedString = "cached text".into(); + let runs = [gpui::TextRun { + len: text.len(), + font: gpui::font("IBM Plex Sans"), + ..Default::default() + }]; + + let first_layout = window_text_system.shape_line(text.clone(), gpui::px(14.0), &runs, None); + let cached_layout = + window_text_system.shape_line(text.clone(), gpui::px(14.0), &runs, None); + assert!(std::ptr::eq::( + &**first_layout, + &**cached_layout + )); + let loaded_font_count = platform_text_system.0.read().loaded_fonts.len(); + + text_system.add_fonts(vec![Cow::Borrowed(LILEX)])?; + + let refreshed_layout = window_text_system.shape_line(text, gpui::px(14.0), &runs, None); + assert!(!std::ptr::eq::( + &**first_layout, + &**refreshed_layout + )); + assert_eq!( + platform_text_system.0.read().loaded_fonts.len(), + loaded_font_count + ); + Ok(()) + } + + #[test] + fn layout_line_with_mixed_direction_paragraphs() -> Result<()> { + let text_system = text_system()?; + + for separator in SEPARATORS { + for text in [ + format!("\u{05d0}{separator}A"), + format!("A{separator}\u{05d0}"), + ] { + let layout = layout_text(&text_system, &text)?; + + assert_eq!(layout.len, text.len(), "{text:?}"); + assert!(layout.width > Pixels::ZERO, "{text:?}"); + assert!( + layout.runs.iter().any(|run| !run.glyphs.is_empty()), + "{text:?}" + ); + } + } + + Ok(()) + } + + #[test] + fn layout_line_with_separators_at_line_edges() -> Result<()> { + let text_system = text_system()?; + + for text in [ + "\u{001c}", + "\u{001c}\u{001c}", + "\u{001c}\u{05d0}", + "\u{05d0}\u{001c}", + "\u{05d0}\u{001c}\u{001c}A", + "\u{001c}\u{05d0}\u{001c}A\u{001c}", + ] { + let layout = layout_text(&text_system, text)?; + assert_eq!(layout.len, text.len(), "{text:?}"); + } + + Ok(()) + } + + /// Glyph indices must stay absolute and positions ordered across segment + /// boundaries, otherwise cursor placement and hit testing desync. Uses + /// single-direction text so visual order matches logical order. + #[test] + fn layout_line_keeps_indices_and_positions_ordered_across_paragraphs() -> Result<()> { + let text_system = text_system()?; + let text = "ab\u{001c}cd\u{2029}ef"; + let layout = layout_text(&text_system, text)?; + + let glyphs: Vec<_> = layout.runs.iter().flat_map(|run| &run.glyphs).collect(); + assert!(!glyphs.is_empty()); + + for glyph in &glyphs { + assert!(glyph.index < text.len(), "{:?}", glyph.index); + assert!(text.is_char_boundary(glyph.index), "{:?}", glyph.index); + } + for pair in glyphs.windows(2) { + assert!(pair[0].index < pair[1].index); + assert!(pair[0].position.x <= pair[1].position.x); + } + + // Every segment contributes width, so the whole line is wider than its + // leading paragraph alone. + assert!(layout.width > layout_text(&text_system, "ab")?.width); + Ok(()) + } + + /// A font run boundary that does not line up with a paragraph boundary must + /// still be clipped to the right segments. + #[test] + fn layout_line_with_font_run_straddling_a_separator() -> Result<()> { + let text_system = text_system()?; + let font_id = text_system.font_id(&gpui::font("IBM Plex Sans"))?; + let text = "ab\u{001c}\u{05d0}\u{05d1}"; + + // The run boundary falls inside the trailing RTL paragraph. + let runs = [ + FontRun { + len: "ab\u{001c}\u{05d0}".len(), + font_id, + }, + FontRun { + len: "\u{05d1}".len(), + font_id, + }, + ]; + let layout = text_system.layout_line(text, gpui::px(14.0), &runs); + + assert_eq!(layout.len, text.len()); + assert!(layout.width > Pixels::ZERO); + Ok(()) + } + + /// Lines with no separator take the fast path and must be shaped exactly as + /// they were before paragraph splitting existed. + #[test] + fn layout_line_without_separators_takes_fast_path() -> Result<()> { + let text_system = text_system()?; + + for text in [ + "hello world", + "\u{05d0}\u{05d1}\u{05d2}", + "mixed \u{05d0}\u{05d1}", + ] { + assert!(!contains_paragraph_separator(text), "{text:?}"); + let layout = layout_text(&text_system, text)?; + assert_eq!(layout.len, text.len(), "{text:?}"); + assert!(layout.width > Pixels::ZERO, "{text:?}"); + } + + Ok(()) + } + + /// cosmic-text sums word widths to get a line's width but accumulates glyph + /// advances to position glyphs, so a trailing zero-advance glyph (here a + /// zero-width space) can land a few ulps past the width. When that glyph is a + /// wrap boundary, the row before it extends past the line's width, and hit + /// testing in that sliver used to panic (ZED-BW8, ZED-75K, ZED-81Z). + #[test] + fn index_for_position_past_line_width() -> Result<()> { + let text_system = Arc::new(gpui::TextSystem::new(Arc::new(text_system()?))); + let window_text_system = gpui::WindowTextSystem::new(text_system); + let text: SharedString = "Warning: this will delete files\u{200b}".into(); + let runs = [gpui::TextRun { + len: text.len(), + font: gpui::font("IBM Plex Sans"), + ..Default::default() + }]; + let lines = + window_text_system.shape_text(text, gpui::px(14.), &runs, Some(gpui::px(4.)), None)?; + let line = &lines[0]; + let width = line.unwrapped_layout.width; + let boundary_glyph = |row: usize| { + let boundary = line.wrap_boundaries()[row]; + &line.runs()[boundary.run_ix].glyphs[boundary.glyph_ix] + }; + let row = (1..line.wrap_boundaries().len()) + .find(|row| boundary_glyph(*row).position.x > width) + .expect("trailing zero-width space should be a wrap boundary past the line width"); + let row_start_x = f32::from(boundary_glyph(row - 1).position.x); + let row_end = boundary_glyph(row); + + let mut x = f32::from(width) - row_start_x; + while x + row_start_x < f32::from(width) { + x = x.next_up(); + } + assert!(gpui::px(x + row_start_x) < row_end.position.x); + + let line_height = gpui::px(20.); + let position = gpui::point(gpui::px(x), line_height * row as f32 + gpui::px(1.)); + assert_eq!( + line.index_for_position(position, line_height), + Err(row_end.index) + ); + Ok(()) + } + + #[test] + fn paragraph_separator_detection() { + for separator in SEPARATORS { + assert!(is_paragraph_separator(*separator), "{separator:?}"); + assert!(contains_paragraph_separator(&format!("a{separator}b"))); + } + + for text in [ + "", + "plain ascii", + "\u{05d0}", + "tab\there", + "emoji \u{1f600}", + ] { + assert!(!contains_paragraph_separator(text), "{text:?}"); + } + } + + #[test] + fn font_runs_are_clipped_to_segment() { + let runs = [ + FontRun { + len: 3, + font_id: fid(1), + }, + FontRun { + len: 4, + font_id: fid(2), + }, + ]; + + assert_eq!(clip_font_runs(&runs, 0..7).as_slice(), &runs); + assert_eq!( + clip_font_runs(&runs, 2..5).as_slice(), + &[ + FontRun { + len: 1, + font_id: fid(1) + }, + FontRun { + len: 2, + font_id: fid(2) + }, + ] + ); + assert_eq!( + clip_font_runs(&runs, 3..7).as_slice(), + &[FontRun { + len: 4, + font_id: fid(2) + }] + ); + assert!(clip_font_runs(&runs, 5..5).is_empty()); + } + + #[test] + fn primary_wins_over_current_fallback_when_primary_covers() { + let primary = fid(0); + let fb = chain(&[1, 2]); + let covers = |id: FontId, _: char| id == fid(0) || id == fid(1); + assert_eq!( + pick_covering_slot('a', Some(0), primary, &fb, &covers), + None + ); + } + + #[test] + fn primary_preferred_over_fallback_when_both_cover() { + let primary = fid(0); + let fb = chain(&[1]); + let covers = |_: FontId, _: char| true; + assert_eq!(pick_covering_slot('a', None, primary, &fb, &covers), None); + } + + #[test] + fn falls_through_chain_in_order() { + let primary = fid(0); + let fb = chain(&[1, 2, 3]); + // only fallback 2 at index 1 covers. + let covers = |id: FontId, _: char| id == fid(2); + assert_eq!( + pick_covering_slot('字', None, primary, &fb, &covers), + Some(1) + ); + } + + #[test] + fn no_coverage_returns_primary() { + let primary = fid(0); + let fb = chain(&[1, 2]); + let covers = |_: FontId, _: char| false; + // nothing covers. return `None` so the `cosmic-text` built in script + // fallback can take over during shaping. + assert_eq!( + pick_covering_slot('\u{1F600}', Some(1), primary, &fb, &covers), + None + ); + } + + #[test] + fn empty_chain_always_returns_primary() { + let primary = fid(0); + let fb: SmallVec<[(FontId, SharedString); 4]> = SmallVec::new(); + let covers = |_: FontId, _: char| false; + assert_eq!(pick_covering_slot('a', None, primary, &fb, &covers), None); + } + + #[test] + fn slot_font_id_resolution() { + let primary = fid(7); + let fb = chain(&[10, 20]); + assert_eq!(slot_font_id(None, primary, &fb), fid(7)); + assert_eq!(slot_font_id(Some(0), primary, &fb), fid(10)); + assert_eq!(slot_font_id(Some(1), primary, &fb), fid(20)); + } + + #[test] + fn run_spans_with_no_chain_emit_one_primary_span() { + let primary = fid(0); + let fb: SmallVec<[(FontId, SharedString); 4]> = SmallVec::new(); + let covers = |_: FontId, _: char| false; + let text = "hello"; + let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers); + assert_eq!(spans.as_slice(), &[span(0, text.len(), None, primary)]); + } + + #[test] + fn run_spans_use_byte_offsets_for_multibyte_chars() { + let primary = fid(0); + let fb = chain(&[1]); + // primary covers ascii. fallback covers cjk. + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + let text = "a字b"; + let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers); + // '字' is 3 bytes so split is at 1 then 4. + assert_eq!( + spans.as_slice(), + &[ + span(0, 1, None, primary), + span(1, 4, Some(0), fid(1)), + span(4, 5, None, primary), + ] + ); + } + + #[test] + fn run_spans_respect_run_offset() { + let primary = fid(0); + let fb = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + // outer text has a prefix that is not part of this run. + let text = "xx字y"; + let run_offset = 2; + let run_len = text.len() - run_offset; + let spans = compute_run_spans(text, run_offset, run_len, primary, &fb, &covers); + assert_eq!( + spans.as_slice(), + &[span(2, 5, Some(0), fid(1)), span(5, 6, None, primary)] + ); + } + + #[test] + fn run_spans_keep_combining_marks_with_base_in_fallback() { + let primary = fid(0); + let fb = chain(&[1]); + // primary covers ascii only. fallback covers the base char. + // combining mark must stay in the fallback span even when fallback + // does not advertise coverage of it. + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + ch == '\u{0905}' + } + }; + // \u{0905} devanagari short a + \u{0902} candrabindu mark. + let text = "\u{0905}\u{0902}"; + let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers); + assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]); + } + + #[test] + fn run_spans_keep_zwj_inside_emoji_cluster() { + let primary = fid(0); + let fb = chain(&[1]); + // only fallback covers the emoji codepoints. zwj must not split. + let covers = |id: FontId, ch: char| id == fid(1) && ch != '\u{200D}'; + // family zwj sequence woman zwj girl. + let text = "\u{1F469}\u{200D}\u{1F467}"; + let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers); + assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]); + } + + #[test] + fn run_spans_collapse_adjacent_same_slot() { + let primary = fid(0); + let fb = chain(&[1]); + let covers = |id: FontId, ch: char| { + if id == primary { + ch.is_ascii() + } else { + !ch.is_ascii() + } + }; + let text = "字字字"; + let spans = compute_run_spans(text, 0, text.len(), primary, &fb, &covers); + assert_eq!(spans.as_slice(), &[span(0, text.len(), Some(0), fid(1))]); + } + + #[test] + fn run_spans_empty_run_returns_no_spans() { + let primary = fid(0); + let fb = chain(&[1]); + let covers = |_: FontId, _: char| true; + let spans = compute_run_spans("anything", 3, 0, primary, &fb, &covers); + assert!(spans.is_empty()); + } +} diff --git a/vendor/gpui-pre-wgpu/src/gpui_wgpu.rs b/vendor/gpui-pre-wgpu/src/gpui_wgpu.rs new file mode 100644 index 00000000..53902372 --- /dev/null +++ b/vendor/gpui-pre-wgpu/src/gpui_wgpu.rs @@ -0,0 +1,20 @@ +mod cosmic_text_system; +mod wgpu_atlas; +mod wgpu_context; +mod wgpu_renderer; + +pub use cosmic_text_system::*; +pub use wgpu; +pub use wgpu_atlas::*; +pub use wgpu_context::*; +#[cfg(all( + not(target_family = "wasm"), + any( + test, + feature = "bench-support", + feature = "test-support", + feature = "offscreen" + ) +))] +pub use wgpu_renderer::WgpuHeadlessRenderer; +pub use wgpu_renderer::{GpuContext, WgpuRenderer, WgpuSurfaceConfig}; diff --git a/vendor/gpui-pre-wgpu/src/shaders.wgsl b/vendor/gpui-pre-wgpu/src/shaders.wgsl new file mode 100644 index 00000000..718cb759 --- /dev/null +++ b/vendor/gpui-pre-wgpu/src/shaders.wgsl @@ -0,0 +1,1362 @@ +/* Functions useful for debugging: + +// A heat map color for debugging (blue -> cyan -> green -> yellow -> red). +fn heat_map_color(value: f32, minValue: f32, maxValue: f32, position: vec2) -> vec4 { + // Normalize value to 0-1 range + let t = clamp((value - minValue) / (maxValue - minValue), 0.0, 1.0); + + // Heat map color calculation + let r = t * t; + let g = 4.0 * t * (1.0 - t); + let b = (1.0 - t) * (1.0 - t); + let heat_color = vec3(r, g, b); + + // Create a checkerboard pattern (black and white) + let sum = floor(position.x / 3) + floor(position.y / 3); + let is_odd = fract(sum * 0.5); // 0.0 for even, 0.5 for odd + let checker_value = is_odd * 2.0; // 0.0 for even, 1.0 for odd + let checker_color = vec3(checker_value); + + // Determine if value is in range (1.0 if in range, 0.0 if out of range) + let in_range = step(minValue, value) * step(value, maxValue); + + // Mix checkerboard and heat map based on whether value is in range + let final_color = mix(checker_color, heat_color, in_range); + + return vec4(final_color, 1.0); +} + +*/ + +// Contrast and gamma correction adapted from https://github.com/microsoft/terminal/blob/1283c0f5b99a2961673249fa77c6b986efb5086c/src/renderer/atlas/dwrite.hlsl +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT license. +fn color_brightness(color: vec3) -> f32 { + // REC. 601 luminance coefficients for perceived brightness + return dot(color, vec3(0.30, 0.59, 0.11)); +} + +fn light_on_dark_contrast(enhancedContrast: f32, color: vec3) -> f32 { + let brightness = color_brightness(color); + let multiplier = saturate(4.0 * (0.75 - brightness)); + return enhancedContrast * multiplier; +} + +fn enhance_contrast(alpha: f32, k: f32) -> f32 { + return alpha * (k + 1.0) / (alpha * k + 1.0); +} + +fn enhance_contrast3(alpha: vec3, k: f32) -> vec3 { + return alpha * (k + 1.0) / (alpha * k + 1.0); +} + +fn apply_alpha_correction(a: f32, b: f32, g: vec4) -> f32 { + let brightness_adjustment = g.x * b + g.y; + let correction = brightness_adjustment * a + (g.z * b + g.w); + return a + a * (1.0 - a) * correction; +} + +fn apply_alpha_correction3(a: vec3, b: vec3, g: vec4) -> vec3 { + let brightness_adjustment = g.x * b + g.y; + let correction = brightness_adjustment * a + (g.z * b + g.w); + return a + a * (1.0 - a) * correction; +} + +fn apply_contrast_and_gamma_correction(sample: f32, color: vec3, enhanced_contrast_factor: f32, gamma_ratios: vec4) -> f32 { + let enhanced_contrast = light_on_dark_contrast(enhanced_contrast_factor, color); + let brightness = color_brightness(color); + + let contrasted = enhance_contrast(sample, enhanced_contrast); + return apply_alpha_correction(contrasted, brightness, gamma_ratios); +} + +fn apply_contrast_and_gamma_correction3(sample: vec3, color: vec3, enhanced_contrast_factor: f32, gamma_ratios: vec4) -> vec3 { + let enhanced_contrast = light_on_dark_contrast(enhanced_contrast_factor, color); + + let contrasted = enhance_contrast3(sample, enhanced_contrast); + return apply_alpha_correction3(contrasted, color, gamma_ratios); +} + +struct GlobalParams { + viewport_size: vec2, + premultiplied_alpha: u32, + pad: u32, +} + +struct GammaParams { + gamma_ratios: vec4, + grayscale_enhanced_contrast: f32, + subpixel_enhanced_contrast: f32, + is_bgr: u32, + pad: u32, +} + +@group(0) @binding(0) var globals: GlobalParams; +@group(0) @binding(1) var gamma_params: GammaParams; +@group(2) @binding(0) var t_sprite: texture_2d; +@group(2) @binding(1) var s_sprite: sampler; + +const M_PI_F: f32 = 3.1415926; +const GRAYSCALE_FACTORS: vec3 = vec3(0.2126, 0.7152, 0.0722); + +struct Bounds { + origin: vec2, + size: vec2, +} + +struct Corners { + top_left: f32, + top_right: f32, + bottom_right: f32, + bottom_left: f32, +} + +struct Edges { + top: f32, + right: f32, + bottom: f32, + left: f32, +} + +struct Hsla { + h: f32, + s: f32, + l: f32, + a: f32, +} + +struct LinearColorStop { + color: Hsla, + percentage: f32, +} + +struct Background { + // 0u is Solid + // 1u is LinearGradient + // 2u is PatternSlash + // 3u is Checkerboard + tag: u32, + // 0u is sRGB linear color + // 1u is Oklab color + color_space: u32, + solid: Hsla, + gradient_angle_or_pattern_height: f32, + colors: array, + pad: u32, +} + +struct AtlasTextureId { + index: u32, + kind: u32, +} + +struct AtlasBounds { + origin: vec2, + size: vec2, +} + +struct AtlasTile { + texture_id: AtlasTextureId, + tile_id: u32, + padding: u32, + bounds: AtlasBounds, +} + +struct TransformationMatrix { + rotation_scale: mat2x2, + translation: vec2, +} + +fn to_device_position_impl(position: vec2) -> vec4 { + let device_position = position / globals.viewport_size * vec2(2.0, -2.0) + vec2(-1.0, 1.0); + return vec4(device_position, 0.0, 1.0); +} + +fn to_device_position(unit_vertex: vec2, bounds: Bounds) -> vec4 { + let position = unit_vertex * vec2(bounds.size) + bounds.origin; + return to_device_position_impl(position); +} + +fn to_device_position_transformed(unit_vertex: vec2, bounds: Bounds, transform: TransformationMatrix) -> vec4 { + let position = unit_vertex * vec2(bounds.size) + bounds.origin; + //Note: Rust side stores it as row-major, so transposing here + let transformed = transpose(transform.rotation_scale) * position + transform.translation; + return to_device_position_impl(transformed); +} + +fn to_tile_position(unit_vertex: vec2, tile: AtlasTile) -> vec2 { + let atlas_size = vec2(textureDimensions(t_sprite, 0)); + return (vec2(tile.bounds.origin) + unit_vertex * vec2(tile.bounds.size)) / atlas_size; +} + +fn distance_from_clip_rect_impl(position: vec2, clip_bounds: Bounds) -> vec4 { + let tl = position - clip_bounds.origin; + let br = clip_bounds.origin + clip_bounds.size - position; + return vec4(tl.x, br.x, tl.y, br.y); +} + +fn distance_from_clip_rect(unit_vertex: vec2, bounds: Bounds, clip_bounds: Bounds) -> vec4 { + let position = unit_vertex * vec2(bounds.size) + bounds.origin; + return distance_from_clip_rect_impl(position, clip_bounds); +} + +fn distance_from_clip_rect_transformed(unit_vertex: vec2, bounds: Bounds, clip_bounds: Bounds, transform: TransformationMatrix) -> vec4 { + let position = unit_vertex * vec2(bounds.size) + bounds.origin; + let transformed = transpose(transform.rotation_scale) * position + transform.translation; + return distance_from_clip_rect_impl(transformed, clip_bounds); +} + +// https://gamedev.stackexchange.com/questions/92015/optimized-linear-to-srgb-glsl +fn srgb_to_linear(srgb: vec3) -> vec3 { + let cutoff = srgb < vec3(0.04045); + let higher = pow((srgb + vec3(0.055)) / vec3(1.055), vec3(2.4)); + let lower = srgb / vec3(12.92); + return select(higher, lower, cutoff); +} + +fn srgb_to_linear_component(a: f32) -> f32 { + let cutoff = a < 0.04045; + let higher = pow((a + 0.055) / 1.055, 2.4); + let lower = a / 12.92; + return select(higher, lower, cutoff); +} + +fn linear_to_srgb(linear: vec3) -> vec3 { + let cutoff = linear < vec3(0.0031308); + let higher = vec3(1.055) * pow(linear, vec3(1.0 / 2.4)) - vec3(0.055); + let lower = linear * vec3(12.92); + return select(higher, lower, cutoff); +} + +/// Convert a linear color to sRGBA space. +fn linear_to_srgba(color: vec4) -> vec4 { + return vec4(linear_to_srgb(color.rgb), color.a); +} + +/// Convert a sRGBA color to linear space. +fn srgba_to_linear(color: vec4) -> vec4 { + return vec4(srgb_to_linear(color.rgb), color.a); +} + +/// Hsla to linear RGBA conversion. +fn hsla_to_rgba(hsla: Hsla) -> vec4 { + let h = hsla.h * 6.0; // Now, it's an angle but scaled in [0, 6) range + let s = hsla.s; + let l = hsla.l; + let a = hsla.a; + + let c = (1.0 - abs(2.0 * l - 1.0)) * s; + let x = c * (1.0 - abs(h % 2.0 - 1.0)); + let m = l - c / 2.0; + var color = vec3(m); + + if (h >= 0.0 && h < 1.0) { + color.r += c; + color.g += x; + } else if (h >= 1.0 && h < 2.0) { + color.r += x; + color.g += c; + } else if (h >= 2.0 && h < 3.0) { + color.g += c; + color.b += x; + } else if (h >= 3.0 && h < 4.0) { + color.g += x; + color.b += c; + } else if (h >= 4.0 && h < 5.0) { + color.r += x; + color.b += c; + } else { + color.r += c; + color.b += x; + } + + return vec4(color, a); +} + +/// Convert a linear sRGB to Oklab space. +/// Reference: https://bottosson.github.io/posts/oklab/#converting-from-linear-srgb-to-oklab +fn linear_srgb_to_oklab(color: vec4) -> vec4 { + let l = 0.4122214708 * color.r + 0.5363325363 * color.g + 0.0514459929 * color.b; + let m = 0.2119034982 * color.r + 0.6806995451 * color.g + 0.1073969566 * color.b; + let s = 0.0883024619 * color.r + 0.2817188376 * color.g + 0.6299787005 * color.b; + + let l_ = pow(l, 1.0 / 3.0); + let m_ = pow(m, 1.0 / 3.0); + let s_ = pow(s, 1.0 / 3.0); + + return vec4( + 0.2104542553 * l_ + 0.7936177850 * m_ - 0.0040720468 * s_, + 1.9779984951 * l_ - 2.4285922050 * m_ + 0.4505937099 * s_, + 0.0259040371 * l_ + 0.7827717662 * m_ - 0.8086757660 * s_, + color.a + ); +} + +/// Convert an Oklab color to linear sRGB space. +fn oklab_to_linear_srgb(color: vec4) -> vec4 { + let l_ = color.r + 0.3963377774 * color.g + 0.2158037573 * color.b; + let m_ = color.r - 0.1055613458 * color.g - 0.0638541728 * color.b; + let s_ = color.r - 0.0894841775 * color.g - 1.2914855480 * color.b; + + let l = l_ * l_ * l_; + let m = m_ * m_ * m_; + let s = s_ * s_ * s_; + + return vec4( + 4.0767416621 * l - 3.3077115913 * m + 0.2309699292 * s, + -1.2684380046 * l + 2.6097574011 * m - 0.3413193965 * s, + -0.0041960863 * l - 0.7034186147 * m + 1.7076147010 * s, + color.a + ); +} + +fn over(below: vec4, above: vec4) -> vec4 { + let alpha = above.a + below.a * (1.0 - above.a); + let color = (above.rgb * above.a + below.rgb * below.a * (1.0 - above.a)) / alpha; + return vec4(color, alpha); +} + +// A standard gaussian function, used for weighting samples +fn gaussian(x: f32, sigma: f32) -> f32{ + return exp(-(x * x) / (2.0 * sigma * sigma)) / (sqrt(2.0 * M_PI_F) * sigma); +} + +// This approximates the error function, needed for the gaussian integral +fn erf(v: vec2) -> vec2 { + let s = sign(v); + let a = abs(v); + let r1 = 1.0 + (0.278393 + (0.230389 + (0.000972 + 0.078108 * a) * a) * a) * a; + let r2 = r1 * r1; + return s - s / (r2 * r2); +} + +fn blur_along_x(x: f32, y: f32, sigma: f32, corner: f32, half_size: vec2) -> f32 { + let delta = min(half_size.y - corner - abs(y), 0.0); + let curved = half_size.x - corner + sqrt(max(0.0, corner * corner - delta * delta)); + let integral = 0.5 + 0.5 * erf((x + vec2(-curved, curved)) * (sqrt(0.5) / sigma)); + return integral.y - integral.x; +} + +// Selects corner radius based on quadrant. +fn pick_corner_radius(center_to_point: vec2, radii: Corners) -> f32 { + if (center_to_point.x < 0.0) { + if (center_to_point.y < 0.0) { + return radii.top_left; + } else { + return radii.bottom_left; + } + } else { + if (center_to_point.y < 0.0) { + return radii.top_right; + } else { + return radii.bottom_right; + } + } +} + +// Signed distance of the point to the quad's border - positive outside the +// border, and negative inside. +// +// See comments on similar code using `quad_sdf_impl` in `fs_quad` for +// explanation. +fn quad_sdf(point: vec2, bounds: Bounds, corner_radii: Corners) -> f32 { + let half_size = bounds.size / 2.0; + let center = bounds.origin + half_size; + let center_to_point = point - center; + let corner_radius = pick_corner_radius(center_to_point, corner_radii); + let corner_to_point = abs(center_to_point) - half_size; + let corner_center_to_point = corner_to_point + corner_radius; + return quad_sdf_impl(corner_center_to_point, corner_radius); +} + +fn quad_sdf_impl(corner_center_to_point: vec2, corner_radius: f32) -> f32 { + if (corner_radius == 0.0) { + // Fast path for unrounded corners. + return max(corner_center_to_point.x, corner_center_to_point.y); + } else { + // Signed distance of the point from a quad that is inset by corner_radius. + // It is negative inside this quad, and positive outside. + let signed_distance_to_inset_quad = + // 0 inside the inset quad, and positive outside. + length(max(vec2(0.0), corner_center_to_point)) + + // 0 outside the inset quad, and negative inside. + min(0.0, max(corner_center_to_point.x, corner_center_to_point.y)); + + return signed_distance_to_inset_quad - corner_radius; + } +} + +// Abstract away the final color transformation based on the +// target alpha compositing mode. +fn blend_color(color: vec4, alpha_factor: f32) -> vec4 { + let alpha = color.a * alpha_factor; + let multiplier = select(1.0, alpha, globals.premultiplied_alpha != 0u); + return vec4(color.rgb * multiplier, alpha); +} + + +struct GradientColor { + solid: vec4, + color0: vec4, + color1: vec4, +} + +fn prepare_gradient_color(tag: u32, color_space: u32, + solid: Hsla, colors: array) -> GradientColor { + var result = GradientColor(); + + if (tag == 0u || tag == 2u || tag == 3u) { + result.solid = hsla_to_rgba(solid); + } else if (tag == 1u) { + // The hsla_to_rgba is returns a linear sRGB color + result.color0 = hsla_to_rgba(colors[0].color); + result.color1 = hsla_to_rgba(colors[1].color); + + // Prepare color space in vertex for avoid conversion + // in fragment shader for performance reasons + if (color_space == 0u) { + // sRGB + result.color0 = linear_to_srgba(result.color0); + result.color1 = linear_to_srgba(result.color1); + } else if (color_space == 1u) { + // Oklab + result.color0 = linear_srgb_to_oklab(result.color0); + result.color1 = linear_srgb_to_oklab(result.color1); + } + } + + return result; +} + +fn gradient_color(background: Background, position: vec2, bounds: Bounds, + solid_color: vec4, color0: vec4, color1: vec4) -> vec4 { + var background_color = vec4(0.0); + + switch (background.tag) { + default: { + return solid_color; + } + case 1u: { + // Linear gradient background. + // -90 degrees to match the CSS gradient angle. + let angle = background.gradient_angle_or_pattern_height; + let radians = (angle % 360.0 - 90.0) * M_PI_F / 180.0; + var direction = vec2(cos(radians), sin(radians)); + let stop0_percentage = background.colors[0].percentage; + let stop1_percentage = background.colors[1].percentage; + + // Expand the short side to be the same as the long side + if (bounds.size.x > bounds.size.y) { + direction.y *= bounds.size.y / bounds.size.x; + } else { + direction.x *= bounds.size.x / bounds.size.y; + } + + // Get the t value for the linear gradient with the color stop percentages. + let half_size = bounds.size / 2.0; + let center = bounds.origin + half_size; + let center_to_point = position - center; + var t = dot(center_to_point, direction) / length(direction); + // Check the direct to determine the use x or y + if (abs(direction.x) > abs(direction.y)) { + t = (t + half_size.x) / bounds.size.x; + } else { + t = (t + half_size.y) / bounds.size.y; + } + + // Adjust t based on the stop percentages + t = (t - stop0_percentage) / (stop1_percentage - stop0_percentage); + t = clamp(t, 0.0, 1.0); + + switch (background.color_space) { + default: { + background_color = srgba_to_linear(mix(color0, color1, t)); + } + case 1u: { + let oklab_color = mix(color0, color1, t); + background_color = oklab_to_linear_srgb(oklab_color); + } + } + } + case 2u: { + // pattern slash + let gradient_angle_or_pattern_height = background.gradient_angle_or_pattern_height; + let pattern_width = (gradient_angle_or_pattern_height / 65535.0f) / 255.0f; + let pattern_interval = (gradient_angle_or_pattern_height % 65535.0f) / 255.0f; + let pattern_height = pattern_width + pattern_interval; + let stripe_angle = M_PI_F / 4.0; + let pattern_period = pattern_height * sin(stripe_angle); + let rotation = mat2x2( + cos(stripe_angle), -sin(stripe_angle), + sin(stripe_angle), cos(stripe_angle) + ); + let relative_position = position - bounds.origin; + let rotated_point = rotation * relative_position; + let pattern = rotated_point.x % pattern_period; + let distance = min(pattern, pattern_period - pattern) - pattern_period * (pattern_width / pattern_height) / 2.0f; + background_color = solid_color; + background_color.a *= saturate(0.5 - distance); + } + case 3u: { + // checkerboard + let size = background.gradient_angle_or_pattern_height; + let relative_position = position - bounds.origin; + + let x_index = floor(relative_position.x / size); + let y_index = floor(relative_position.y / size); + let should_be_colored = (x_index + y_index) % 2.0; + + background_color = solid_color; + background_color.a *= saturate(should_be_colored); + } + } + + return background_color; +} + +// --- quads --- // + +struct Quad { + order: u32, + border_style: u32, + bounds: Bounds, + content_mask: Bounds, + background: Background, + border_color: Hsla, + corner_radii: Corners, + border_widths: Edges, +} + +struct QuadVarying { + @builtin(position) position: vec4, + @location(0) @interpolate(flat) border_color: vec4, + @location(1) @interpolate(flat) quad_id: u32, + // TODO: use `clip_distance` once Naga supports it + @location(2) clip_distances: vec4, + @location(3) @interpolate(flat) background_solid: vec4, + @location(4) @interpolate(flat) background_color0: vec4, + @location(5) @interpolate(flat) background_color1: vec4, +} + +@vertex +fn vs_quad(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> QuadVarying { + let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); + let quad = load_quad(instance_id); + + var out = QuadVarying(); + out.position = to_device_position(unit_vertex, quad.bounds); + + let gradient = prepare_gradient_color( + quad.background.tag, + quad.background.color_space, + quad.background.solid, + quad.background.colors + ); + out.background_solid = gradient.solid; + out.background_color0 = gradient.color0; + out.background_color1 = gradient.color1; + out.border_color = hsla_to_rgba(quad.border_color); + out.quad_id = instance_id; + out.clip_distances = distance_from_clip_rect(unit_vertex, quad.bounds, quad.content_mask); + return out; +} + +@fragment +fn fs_quad(input: QuadVarying) -> @location(0) vec4 { + // Alpha clip first, since we don't have `clip_distance`. + if (any(input.clip_distances < vec4(0.0))) { + return vec4(0.0); + } + + let quad = load_quad(input.quad_id); + + let background_color = gradient_color(quad.background, input.position.xy, quad.bounds, + input.background_solid, input.background_color0, input.background_color1); + + let unrounded = quad.corner_radii.top_left == 0.0 && + quad.corner_radii.bottom_left == 0.0 && + quad.corner_radii.top_right == 0.0 && + quad.corner_radii.bottom_right == 0.0; + + // Fast path when the quad is not rounded and doesn't have any border + if (quad.border_widths.top == 0.0 && + quad.border_widths.left == 0.0 && + quad.border_widths.right == 0.0 && + quad.border_widths.bottom == 0.0 && + unrounded) { + return blend_color(background_color, 1.0); + } + + let size = quad.bounds.size; + let half_size = size / 2.0; + let point = input.position.xy - quad.bounds.origin; + let center_to_point = point - half_size; + + // Signed distance field threshold for inclusion of pixels. 0.5 is the + // minimum distance between the center of the pixel and the edge. + let antialias_threshold = 0.5; + + // Radius of the nearest corner + let corner_radius = pick_corner_radius(center_to_point, quad.corner_radii); + + // Width of the nearest borders + let border = vec2( + select( + quad.border_widths.right, + quad.border_widths.left, + center_to_point.x < 0.0), + select( + quad.border_widths.bottom, + quad.border_widths.top, + center_to_point.y < 0.0)); + + // 0-width borders are reduced so that `inner_sdf >= antialias_threshold`. + // The purpose of this is to not draw antialiasing pixels in this case. + let reduced_border = + vec2(select(border.x, -antialias_threshold, border.x == 0.0), + select(border.y, -antialias_threshold, border.y == 0.0)); + + // Vector from the corner of the quad bounds to the point, after mirroring + // the point into the bottom right quadrant. Both components are <= 0. + let corner_to_point = abs(center_to_point) - half_size; + + // Vector from the point to the center of the rounded corner's circle, also + // mirrored into bottom right quadrant. + let corner_center_to_point = corner_to_point + corner_radius; + + // Whether the nearest point on the border is rounded + let is_near_rounded_corner = + corner_center_to_point.x >= 0 && + corner_center_to_point.y >= 0; + + // Vector from straight border inner corner to point. + let straight_border_inner_corner_to_point = corner_to_point + reduced_border; + + // Whether the point is beyond the inner edge of the straight border. + let is_beyond_inner_straight_border = + straight_border_inner_corner_to_point.x > 0 || + straight_border_inner_corner_to_point.y > 0; + + // Whether the point is far enough inside the quad, such that the pixels are + // not affected by the straight border. + let is_within_inner_straight_border = + straight_border_inner_corner_to_point.x < -antialias_threshold && + straight_border_inner_corner_to_point.y < -antialias_threshold; + + // Fast path for points that must be part of the background. + // + // This could be optimized further for large rounded corners by including + // points in an inscribed rectangle, or some other quick linear check. + // However, that might negatively impact performance in the case of + // reasonable sizes for rounded corners. + if (is_within_inner_straight_border && !is_near_rounded_corner) { + return blend_color(background_color, 1.0); + } + + // Signed distance of the point to the outside edge of the quad's border. It + // is positive outside this edge, and negative inside. + let outer_sdf = quad_sdf_impl(corner_center_to_point, corner_radius); + + // Approximate signed distance of the point to the inside edge of the quad's + // border. It is negative outside this edge (within the border), and + // positive inside. + // + // This is not always an accurate signed distance: + // * The rounded portions with varying border width use an approximation of + // nearest-point-on-ellipse. + // * When it is quickly known to be outside the edge, -1.0 is used. + var inner_sdf = 0.0; + if (corner_center_to_point.x <= 0 || corner_center_to_point.y <= 0) { + // Fast paths for straight borders. + inner_sdf = -max(straight_border_inner_corner_to_point.x, + straight_border_inner_corner_to_point.y); + } else if (is_beyond_inner_straight_border) { + // Fast path for points that must be outside the inner edge. + inner_sdf = -1.0; + } else if (reduced_border.x == reduced_border.y) { + // Fast path for circular inner edge. + inner_sdf = -(outer_sdf + reduced_border.x); + } else { + let ellipse_radii = max(vec2(0.0), corner_radius - reduced_border); + inner_sdf = quarter_ellipse_sdf(corner_center_to_point, ellipse_radii); + } + + // Negative when inside the border + let border_sdf = max(inner_sdf, outer_sdf); + + var color = background_color; + if (border_sdf < antialias_threshold) { + var border_color = input.border_color; + + // Dashed border logic when border_style == 1 + if (quad.border_style == 1) { + // Position along the perimeter in "dash space", where each dash + // period has length 1 + var t = 0.0; + + // Total number of dash periods, so that the dash spacing can be + // adjusted to evenly divide it + var max_t = 0.0; + + // Border width is proportional to dash size. This is the behavior + // used by browsers, but also avoids dashes from different segments + // overlapping when dash size is smaller than the border width. + // + // Dash pattern: (2 * border width) dash, (1 * border width) gap + let dash_length_per_width = 2.0; + let dash_gap_per_width = 1.0; + let dash_period_per_width = dash_length_per_width + dash_gap_per_width; + + // Since the dash size is determined by border width, the density of + // dashes varies. Multiplying a pixel distance by this returns a + // position in dash space - it has units (dash period / pixels). So + // a dash velocity of (1 / 10) is 1 dash every 10 pixels. + var dash_velocity = 0.0; + + // Dividing this by the border width gives the dash velocity + let dv_numerator = 1.0 / dash_period_per_width; + + if (unrounded) { + // When corners aren't rounded, the dashes are separately laid + // out on each straight line, rather than around the whole + // perimeter. This way each line starts and ends with a dash. + let is_horizontal = + corner_center_to_point.x < + corner_center_to_point.y; + + // When applying dashed borders to just some, not all, the sides. + // The way we chose border widths above sometimes comes with a 0 width value. + // So we choose again to avoid division by zero. + // TODO: A better solution exists taking a look at the whole file. + // this does not fix single dashed borders at the corners + let dashed_border = vec2( + max( + quad.border_widths.bottom, + quad.border_widths.top, + ), + max( + quad.border_widths.right, + quad.border_widths.left, + ) + ); + + let border_width = select(dashed_border.y, dashed_border.x, is_horizontal); + dash_velocity = dv_numerator / border_width; + t = select(point.y, point.x, is_horizontal) * dash_velocity; + max_t = select(size.y, size.x, is_horizontal) * dash_velocity; + } else { + // When corners are rounded, the dashes are laid out clockwise + // around the whole perimeter. + + let r_tr = quad.corner_radii.top_right; + let r_br = quad.corner_radii.bottom_right; + let r_bl = quad.corner_radii.bottom_left; + let r_tl = quad.corner_radii.top_left; + + let w_t = quad.border_widths.top; + let w_r = quad.border_widths.right; + let w_b = quad.border_widths.bottom; + let w_l = quad.border_widths.left; + + // Straight side dash velocities + let dv_t = select(dv_numerator / w_t, 0.0, w_t <= 0.0); + let dv_r = select(dv_numerator / w_r, 0.0, w_r <= 0.0); + let dv_b = select(dv_numerator / w_b, 0.0, w_b <= 0.0); + let dv_l = select(dv_numerator / w_l, 0.0, w_l <= 0.0); + + // Straight side lengths in dash space + let s_t = (size.x - r_tl - r_tr) * dv_t; + let s_r = (size.y - r_tr - r_br) * dv_r; + let s_b = (size.x - r_br - r_bl) * dv_b; + let s_l = (size.y - r_bl - r_tl) * dv_l; + + let corner_dash_velocity_tr = corner_dash_velocity(dv_t, dv_r); + let corner_dash_velocity_br = corner_dash_velocity(dv_b, dv_r); + let corner_dash_velocity_bl = corner_dash_velocity(dv_b, dv_l); + let corner_dash_velocity_tl = corner_dash_velocity(dv_t, dv_l); + + // Corner lengths in dash space + let c_tr = r_tr * (M_PI_F / 2.0) * corner_dash_velocity_tr; + let c_br = r_br * (M_PI_F / 2.0) * corner_dash_velocity_br; + let c_bl = r_bl * (M_PI_F / 2.0) * corner_dash_velocity_bl; + let c_tl = r_tl * (M_PI_F / 2.0) * corner_dash_velocity_tl; + + // Cumulative dash space upto each segment + let upto_tr = s_t; + let upto_r = upto_tr + c_tr; + let upto_br = upto_r + s_r; + let upto_b = upto_br + c_br; + let upto_bl = upto_b + s_b; + let upto_l = upto_bl + c_bl; + let upto_tl = upto_l + s_l; + max_t = upto_tl + c_tl; + + if (is_near_rounded_corner) { + let radians = atan2(corner_center_to_point.y, + corner_center_to_point.x); + let corner_t = radians * corner_radius; + + if (center_to_point.x >= 0.0) { + if (center_to_point.y < 0.0) { + dash_velocity = corner_dash_velocity_tr; + // Subtracted because radians is pi/2 to 0 when + // going clockwise around the top right corner, + // since the y axis has been flipped + t = upto_r - corner_t * dash_velocity; + } else { + dash_velocity = corner_dash_velocity_br; + // Added because radians is 0 to pi/2 when going + // clockwise around the bottom-right corner + t = upto_br + corner_t * dash_velocity; + } + } else { + if (center_to_point.y >= 0.0) { + dash_velocity = corner_dash_velocity_bl; + // Subtracted because radians is pi/2 to 0 when + // going clockwise around the bottom-left corner, + // since the x axis has been flipped + t = upto_l - corner_t * dash_velocity; + } else { + dash_velocity = corner_dash_velocity_tl; + // Added because radians is 0 to pi/2 when going + // clockwise around the top-left corner, since both + // axis were flipped + t = upto_tl + corner_t * dash_velocity; + } + } + } else { + // Straight borders + let is_horizontal = + corner_center_to_point.x < + corner_center_to_point.y; + if (is_horizontal) { + if (center_to_point.y < 0.0) { + dash_velocity = dv_t; + t = (point.x - r_tl) * dash_velocity; + } else { + dash_velocity = dv_b; + t = upto_bl - (point.x - r_bl) * dash_velocity; + } + } else { + if (center_to_point.x < 0.0) { + dash_velocity = dv_l; + t = upto_tl - (point.y - r_tl) * dash_velocity; + } else { + dash_velocity = dv_r; + t = upto_r + (point.y - r_tr) * dash_velocity; + } + } + } + } + + let dash_length = dash_length_per_width / dash_period_per_width; + let desired_dash_gap = dash_gap_per_width / dash_period_per_width; + + // Straight borders should start and end with a dash, so max_t is + // reduced to cause this. + max_t -= select(0.0, dash_length, unrounded); + if (max_t >= 1.0) { + // Adjust dash gap to evenly divide max_t. + let dash_count = floor(max_t); + let dash_period = max_t / dash_count; + border_color.a *= dash_alpha( + t, + dash_period, + dash_length, + dash_velocity, + antialias_threshold); + } else if (unrounded) { + // When there isn't enough space for the full gap between the + // two start / end dashes of a straight border, reduce gap to + // make them fit. + let dash_gap = max_t - dash_length; + if (dash_gap > 0.0) { + let dash_period = dash_length + dash_gap; + border_color.a *= dash_alpha( + t, + dash_period, + dash_length, + dash_velocity, + antialias_threshold); + } + } + } + + // Blend the border on top of the background and then linearly interpolate + // between the two as we slide inside the background. + let blended_border = over(background_color, border_color); + color = mix(background_color, blended_border, + saturate(antialias_threshold - inner_sdf)); + } + + return blend_color(color, saturate(antialias_threshold - outer_sdf)); +} + +// Returns the dash velocity of a corner given the dash velocity of the two +// sides, by returning the slower velocity (larger dashes). +// +// Since 0 is used for dash velocity when the border width is 0 (instead of +// +inf), this returns the other dash velocity in that case. +// +// An alternative to this might be to appropriately interpolate the dash +// velocity around the corner, but that seems overcomplicated. +fn corner_dash_velocity(dv1: f32, dv2: f32) -> f32 { + if (dv1 == 0.0) { + return dv2; + } else if (dv2 == 0.0) { + return dv1; + } else { + return min(dv1, dv2); + } +} + +// Returns alpha used to render antialiased dashes. +// `t` is within the dash when `fmod(t, period) < length`. +fn dash_alpha(t: f32, period: f32, length: f32, dash_velocity: f32, antialias_threshold: f32) -> f32 { + let half_period = period / 2; + let half_length = length / 2; + // Value in [-half_period, half_period]. + // The dash is in [-half_length, half_length]. + let centered = fmod(t + half_period - half_length, period) - half_period; + // Signed distance for the dash, negative values are inside the dash. + let signed_distance = abs(centered) - half_length; + // Antialiased alpha based on the signed distance. + return saturate(antialias_threshold - signed_distance / dash_velocity); +} + +// This approximates distance to the nearest point to a quarter ellipse in a way +// that is sufficient for anti-aliasing when the ellipse is not very eccentric. +// The components of `point` are expected to be positive. +// +// Negative on the outside and positive on the inside. +fn quarter_ellipse_sdf(point: vec2, radii: vec2) -> f32 { + // Scale the space to treat the ellipse like a unit circle. + let circle_vec = point / radii; + let unit_circle_sdf = length(circle_vec) - 1.0; + // Approximate up-scaling of the length by using the average of the radii. + // + // TODO: A better solution would be to use the gradient of the implicit + // function for an ellipse to approximate a scaling factor. + return unit_circle_sdf * (radii.x + radii.y) * -0.5; +} + +// Modulus that has the same sign as `a`. +fn fmod(a: f32, b: f32) -> f32 { + return a - b * trunc(a / b); +} + +// --- shadows --- // + +struct Shadow { + order: u32, + blur_radius: f32, + // The shadow rect for drop shadows; the "hole" rect for inset shadows. + bounds: Bounds, + corner_radii: Corners, + content_mask: Bounds, + color: Hsla, + // Only consulted when `inset == 1u`: the element's own bounds, used as a rounded-rect + // clip so the shadow never escapes the element. + element_bounds: Bounds, + element_corner_radii: Corners, + // 0 = drop shadow, 1 = inset shadow. + inset: u32, + pad: u32, // align to 8 bytes +} + +struct ShadowVarying { + @builtin(position) position: vec4, + @location(0) @interpolate(flat) color: vec4, + @location(1) @interpolate(flat) shadow_id: u32, + //TODO: use `clip_distance` once Naga supports it + @location(3) clip_distances: vec4, +} + +@vertex +fn vs_shadow(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> ShadowVarying { + let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); + var shadow = load_shadow(instance_id); + + var geometry: Bounds; + if (shadow.inset != 0u) { + geometry = shadow.element_bounds; + } else { + // Leave room for the gaussian tail outside the shadow rect. + let margin = 3.0 * shadow.blur_radius; + geometry = shadow.bounds; + geometry.origin -= vec2(margin); + geometry.size += 2.0 * vec2(margin); + } + + var out = ShadowVarying(); + out.position = to_device_position(unit_vertex, geometry); + out.color = hsla_to_rgba(shadow.color); + out.shadow_id = instance_id; + out.clip_distances = distance_from_clip_rect(unit_vertex, geometry, shadow.content_mask); + return out; +} + +@fragment +fn fs_shadow(input: ShadowVarying) -> @location(0) vec4 { + // Alpha clip first, since we don't have `clip_distance`. + if (any(input.clip_distances < vec4(0.0))) { + return vec4(0.0); + } + + let shadow = load_shadow(input.shadow_id); + let half_size = shadow.bounds.size / 2.0; + let center = shadow.bounds.origin + half_size; + let center_to_point = input.position.xy - center; + + let corner_radius = pick_corner_radius(center_to_point, shadow.corner_radii); + + var alpha: f32; + if (shadow.blur_radius == 0.0) { + let distance = quad_sdf(input.position.xy, shadow.bounds, shadow.corner_radii); + alpha = saturate(0.5 - distance); + } else { + // The signal is only non-zero in a limited range, so don't waste samples + let low = center_to_point.y - half_size.y; + let high = center_to_point.y + half_size.y; + let start = clamp(-3.0 * shadow.blur_radius, low, high); + let end = clamp(3.0 * shadow.blur_radius, low, high); + + // Accumulate samples (we can get away with surprisingly few samples) + let step = (end - start) / 4.0; + var y = start + step * 0.5; + alpha = 0.0; + for (var i = 0; i < 4; i += 1) { + let blur = blur_along_x(center_to_point.x, center_to_point.y - y, + shadow.blur_radius, corner_radius, half_size); + alpha += blur * gaussian(y, shadow.blur_radius) * step; + y += step; + } + } + + if (shadow.inset != 0u) { + // The inset shadow is the complement of the (blurred) hole rect, clipped to the element. + // `saturate(0.5 - d)` gives a 1-pixel antialiased edge: d <= -0.5 -> 1, d >= 0.5 -> 0. + alpha = 1.0 - alpha; + let element_distance = quad_sdf(input.position.xy, shadow.element_bounds, + shadow.element_corner_radii); + alpha *= saturate(0.5 - element_distance); + } + + return blend_color(input.color, alpha); +} + +// --- path rasterization --- // + +struct PathRasterizationVertex { + xy_position: vec2, + st_position: vec2, + color: Background, + bounds: Bounds, +} + + + +struct PathRasterizationVarying { + @builtin(position) position: vec4, + @location(0) st_position: vec2, + @location(1) @interpolate(flat) vertex_id: u32, + //TODO: use `clip_distance` once Naga supports it + @location(3) clip_distances: vec4, +} + +@vertex +fn vs_path_rasterization(@builtin(vertex_index) vertex_id: u32) -> PathRasterizationVarying { + let v = load_path_vertex(vertex_id); + + var out = PathRasterizationVarying(); + out.position = to_device_position_impl(v.xy_position); + out.st_position = v.st_position; + out.vertex_id = vertex_id; + out.clip_distances = distance_from_clip_rect_impl(v.xy_position, v.bounds); + return out; +} + +@fragment +fn fs_path_rasterization(input: PathRasterizationVarying) -> @location(0) vec4 { + let dx = dpdx(input.st_position); + let dy = dpdy(input.st_position); + if (any(input.clip_distances < vec4(0.0))) { + return vec4(0.0); + } + + let v = load_path_vertex(input.vertex_id); + let background = v.color; + let bounds = v.bounds; + + var alpha: f32; + if (length(vec2(dx.x, dy.x)) < 0.001) { + // If the gradient is too small, return a solid color. + alpha = 1.0; + } else { + let gradient = 2.0 * input.st_position.xx * vec2(dx.x, dy.x) - vec2(dx.y, dy.y); + let f = input.st_position.x * input.st_position.x - input.st_position.y; + let distance = f / length(gradient); + alpha = saturate(0.5 - distance); + } + let prepared_gradient = prepare_gradient_color( + background.tag, + background.color_space, + background.solid, + background.colors, + ); + let color = gradient_color(background, input.position.xy, bounds, + prepared_gradient.solid, prepared_gradient.color0, prepared_gradient.color1); + return vec4(color.rgb * color.a * alpha, color.a * alpha); +} + +// --- paths --- // + +struct PathSprite { + bounds: Bounds, +} + + +struct PathVarying { + @builtin(position) position: vec4, + @location(0) texture_coords: vec2, +} + +@vertex +fn vs_path(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> PathVarying { + let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); + let sprite = load_path_sprite(instance_id); + // Don't apply content mask because it was already accounted for when rasterizing the path. + let device_position = to_device_position(unit_vertex, sprite.bounds); + // For screen-space intermediate texture, convert screen position to texture coordinates + let screen_position = sprite.bounds.origin + unit_vertex * sprite.bounds.size; + let texture_coords = screen_position / globals.viewport_size; + + var out = PathVarying(); + out.position = device_position; + out.texture_coords = texture_coords; + + return out; +} + +@fragment +fn fs_path(input: PathVarying) -> @location(0) vec4 { + let sample = textureSample(t_sprite, s_sprite, input.texture_coords); + return sample; +} + +// --- underlines --- // + +struct Underline { + order: u32, + pad: u32, + bounds: Bounds, + content_mask: Bounds, + color: Hsla, + thickness: f32, + wavy: u32, +} + + +struct UnderlineVarying { + @builtin(position) position: vec4, + @location(0) @interpolate(flat) color: vec4, + @location(1) @interpolate(flat) underline_id: u32, + //TODO: use `clip_distance` once Naga supports it + @location(3) clip_distances: vec4, +} + +@vertex +fn vs_underline(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> UnderlineVarying { + let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); + let underline = load_underline(instance_id); + + var out = UnderlineVarying(); + out.position = to_device_position(unit_vertex, underline.bounds); + out.color = hsla_to_rgba(underline.color); + out.underline_id = instance_id; + out.clip_distances = distance_from_clip_rect(unit_vertex, underline.bounds, underline.content_mask); + return out; +} + +@fragment +fn fs_underline(input: UnderlineVarying) -> @location(0) vec4 { + const WAVE_FREQUENCY: f32 = 2.0; + const WAVE_HEIGHT_RATIO: f32 = 0.8; + + // Alpha clip first, since we don't have `clip_distance`. + if (any(input.clip_distances < vec4(0.0))) { + return vec4(0.0); + } + + let underline = load_underline(input.underline_id); + if (underline.wavy == 0u) + { + return blend_color(input.color, input.color.a); + } + + let half_thickness = underline.thickness * 0.5; + + let st = (input.position.xy - underline.bounds.origin) / underline.bounds.size.y - vec2(0.0, 0.5); + let frequency = M_PI_F * WAVE_FREQUENCY * underline.thickness / underline.bounds.size.y; + let amplitude = (underline.thickness * WAVE_HEIGHT_RATIO) / underline.bounds.size.y; + + let sine = sin(st.x * frequency) * amplitude; + let dSine = cos(st.x * frequency) * amplitude * frequency; + let distance = (st.y - sine) / sqrt(1.0 + dSine * dSine); + let distance_in_pixels = distance * underline.bounds.size.y; + let distance_from_top_border = distance_in_pixels - half_thickness; + let distance_from_bottom_border = distance_in_pixels + half_thickness; + let alpha = saturate(0.5 - max(-distance_from_bottom_border, distance_from_top_border)); + return blend_color(input.color, alpha * input.color.a); +} + +// --- monochrome sprites --- // + +struct MonochromeSprite { + order: u32, + pad: u32, + bounds: Bounds, + content_mask: Bounds, + color: Hsla, + tile: AtlasTile, + transformation: TransformationMatrix, +} + + +struct MonoSpriteVarying { + @builtin(position) position: vec4, + @location(0) tile_position: vec2, + @location(1) @interpolate(flat) color: vec4, + @location(3) clip_distances: vec4, +} + +@vertex +fn vs_mono_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> MonoSpriteVarying { + let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); + let sprite = load_mono_sprite(instance_id); + + var out = MonoSpriteVarying(); + out.position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation); + + out.tile_position = to_tile_position(unit_vertex, sprite.tile); + out.color = hsla_to_rgba(sprite.color); + out.clip_distances = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); + return out; +} + +@fragment +fn fs_mono_sprite(input: MonoSpriteVarying) -> @location(0) vec4 { + let sample = textureSample(t_sprite, s_sprite, input.tile_position).r; + let alpha_corrected = apply_contrast_and_gamma_correction(sample, input.color.rgb, gamma_params.grayscale_enhanced_contrast, gamma_params.gamma_ratios); + + // Alpha clip after using the derivatives. + if (any(input.clip_distances < vec4(0.0))) { + return vec4(0.0); + } + + return blend_color(input.color, alpha_corrected); +} + +// --- polychrome sprites --- // + +struct PolychromeSprite { + order: u32, + pad: u32, + grayscale: u32, + opacity: f32, + bounds: Bounds, + content_mask: Bounds, + corner_radii: Corners, + tile: AtlasTile, +} + + +struct PolySpriteVarying { + @builtin(position) position: vec4, + @location(0) tile_position: vec2, + @location(1) @interpolate(flat) sprite_id: u32, + @location(3) clip_distances: vec4, +} + +@vertex +fn vs_poly_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> PolySpriteVarying { + let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); + let sprite = load_poly_sprite(instance_id); + + var out = PolySpriteVarying(); + out.position = to_device_position(unit_vertex, sprite.bounds); + out.tile_position = to_tile_position(unit_vertex, sprite.tile); + out.sprite_id = instance_id; + out.clip_distances = distance_from_clip_rect(unit_vertex, sprite.bounds, sprite.content_mask); + return out; +} + +@fragment +fn fs_poly_sprite(input: PolySpriteVarying) -> @location(0) vec4 { + let sample = textureSample(t_sprite, s_sprite, input.tile_position); + // Alpha clip after using the derivatives. + if (any(input.clip_distances < vec4(0.0))) { + return vec4(0.0); + } + + let sprite = load_poly_sprite(input.sprite_id); + let distance = quad_sdf(input.position.xy, sprite.bounds, sprite.corner_radii); + + var color = sample; + if (sprite.grayscale != 0u) { + let grayscale = dot(color.rgb, GRAYSCALE_FACTORS); + color = vec4(vec3(grayscale), sample.a); + } + return blend_color(color, sprite.opacity * saturate(0.5 - distance)); +} + +// --- surfaces --- // + +struct SurfaceParams { + bounds: Bounds, + content_mask: Bounds, +} + +@group(1) @binding(0) var surface_locals: SurfaceParams; +@group(1) @binding(1) var t_y: texture_2d; +@group(1) @binding(2) var t_cb_cr: texture_2d; +@group(1) @binding(3) var s_surface: sampler; + +const ycbcr_to_RGB = mat4x4( + vec4( 1.0000f, 1.0000f, 1.0000f, 0.0), + vec4( 0.0000f, -0.3441f, 1.7720f, 0.0), + vec4( 1.4020f, -0.7141f, 0.0000f, 0.0), + vec4(-0.7010f, 0.5291f, -0.8860f, 1.0), +); + +struct SurfaceVarying { + @builtin(position) position: vec4, + @location(0) texture_position: vec2, + @location(3) clip_distances: vec4, +} + +@vertex +fn vs_surface(@builtin(vertex_index) vertex_id: u32) -> SurfaceVarying { + let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); + + var out = SurfaceVarying(); + out.position = to_device_position(unit_vertex, surface_locals.bounds); + out.texture_position = unit_vertex; + out.clip_distances = distance_from_clip_rect(unit_vertex, surface_locals.bounds, surface_locals.content_mask); + return out; +} + +@fragment +fn fs_surface(input: SurfaceVarying) -> @location(0) vec4 { + // Alpha clip after using the derivatives. + if (any(input.clip_distances < vec4(0.0))) { + return vec4(0.0); + } + + let y_cb_cr = vec4( + textureSampleLevel(t_y, s_surface, input.texture_position, 0.0).r, + textureSampleLevel(t_cb_cr, s_surface, input.texture_position, 0.0).rg, + 1.0); + + return ycbcr_to_RGB * y_cb_cr; +} diff --git a/vendor/gpui-pre-wgpu/src/shaders_storage.wgsl b/vendor/gpui-pre-wgpu/src/shaders_storage.wgsl new file mode 100644 index 00000000..55b80802 --- /dev/null +++ b/vendor/gpui-pre-wgpu/src/shaders_storage.wgsl @@ -0,0 +1,42 @@ +// Storage-buffer instance transport. Concatenated after `shaders.wgsl` on backends +// with storage buffer support; `shaders_webgl.wgsl` is the WebGL2 counterpart that +// decodes the same records from a texture instead. +// +// All buffers share `@group(1) @binding(0)` because each pipeline binds only the +// buffer its own entry points read. + +@group(1) @binding(0) var b_quads: array; +@group(1) @binding(0) var b_shadows: array; +@group(1) @binding(0) var b_path_vertices: array; +@group(1) @binding(0) var b_path_sprites: array; +@group(1) @binding(0) var b_underlines: array; +@group(1) @binding(0) var b_mono_sprites: array; +@group(1) @binding(0) var b_poly_sprites: array; + +fn load_quad(instance_id: u32) -> Quad { + return b_quads[instance_id]; +} + +fn load_shadow(instance_id: u32) -> Shadow { + return b_shadows[instance_id]; +} + +fn load_path_vertex(vertex_id: u32) -> PathRasterizationVertex { + return b_path_vertices[vertex_id]; +} + +fn load_path_sprite(instance_id: u32) -> PathSprite { + return b_path_sprites[instance_id]; +} + +fn load_underline(instance_id: u32) -> Underline { + return b_underlines[instance_id]; +} + +fn load_mono_sprite(instance_id: u32) -> MonochromeSprite { + return b_mono_sprites[instance_id]; +} + +fn load_poly_sprite(instance_id: u32) -> PolychromeSprite { + return b_poly_sprites[instance_id]; +} diff --git a/vendor/gpui-pre-wgpu/src/shaders_subpixel.wgsl b/vendor/gpui-pre-wgpu/src/shaders_subpixel.wgsl new file mode 100644 index 00000000..8fd93646 --- /dev/null +++ b/vendor/gpui-pre-wgpu/src/shaders_subpixel.wgsl @@ -0,0 +1,56 @@ +// --- subpixel sprites --- // + +struct SubpixelSprite { + order: u32, + pad: u32, + bounds: Bounds, + content_mask: Bounds, + color: Hsla, + tile: AtlasTile, + transformation: TransformationMatrix, +} +@group(1) @binding(0) var b_subpixel_sprites: array; + +struct SubpixelSpriteOutput { + @builtin(position) position: vec4, + @location(0) tile_position: vec2, + @location(1) @interpolate(flat) color: vec4, + @location(3) clip_distances: vec4, +} + +struct SubpixelSpriteFragmentOutput { + @location(0) @blend_src(0) foreground: vec4, + @location(0) @blend_src(1) alpha: vec4, +} + +@vertex +fn vs_subpixel_sprite(@builtin(vertex_index) vertex_id: u32, @builtin(instance_index) instance_id: u32) -> SubpixelSpriteOutput { + let unit_vertex = vec2(f32(vertex_id & 1u), 0.5 * f32(vertex_id & 2u)); + let sprite = b_subpixel_sprites[instance_id]; + + var out = SubpixelSpriteOutput(); + out.position = to_device_position_transformed(unit_vertex, sprite.bounds, sprite.transformation); + out.tile_position = to_tile_position(unit_vertex, sprite.tile); + out.color = hsla_to_rgba(sprite.color); + out.clip_distances = distance_from_clip_rect_transformed(unit_vertex, sprite.bounds, sprite.content_mask, sprite.transformation); + return out; +} + +@fragment +fn fs_subpixel_sprite(input: SubpixelSpriteOutput) -> SubpixelSpriteFragmentOutput { + var sample = textureSample(t_sprite, s_sprite, input.tile_position).rgb; + if (gamma_params.is_bgr != 0u) { + sample = sample.bgr; + } + let alpha_corrected = apply_contrast_and_gamma_correction3(sample, input.color.rgb, gamma_params.subpixel_enhanced_contrast, gamma_params.gamma_ratios); + + // Alpha clip after using the derivatives. + if (any(input.clip_distances < vec4(0.0))) { + return SubpixelSpriteFragmentOutput(vec4(0.0), vec4(0.0)); + } + + var out = SubpixelSpriteFragmentOutput(); + out.foreground = vec4(input.color.rgb, 1.0); + out.alpha = vec4(input.color.a * alpha_corrected, 1.0); + return out; +} diff --git a/vendor/gpui-pre-wgpu/src/shaders_webgl.wgsl b/vendor/gpui-pre-wgpu/src/shaders_webgl.wgsl new file mode 100644 index 00000000..bf5f629c --- /dev/null +++ b/vendor/gpui-pre-wgpu/src/shaders_webgl.wgsl @@ -0,0 +1,262 @@ +@group(1) @binding(0) var t_instances: texture_2d; + +// Each texel of `t_instances` packs four 32-bit words of instance data. Most +// records are read strictly front to back through a cursor that keeps the +// most recently fetched texel and only touches the texture again when the next +// word crosses a texel boundary. This fetches each texel exactly once per +// record load instead of once per word. The `read_*` functions must therefore +// consume their words in struct declaration order; skipping or re-reading words +// would still return correct data (the cursor re-fetches whenever the texel +// index changes) but would forfeit the single-fetch-per-texel guarantee. +struct InstanceCursor { + word_index: u32, + texel_index: u32, + texel: vec4, + width: u32, +} + +fn fetch_instance_texel(texel_index: u32, width: u32) -> vec4 { + let coordinate = vec2( + i32(texel_index % width), + i32(texel_index / width), + ); + return textureLoad(t_instances, coordinate, 0); +} + +fn instance_cursor(word_index: u32) -> InstanceCursor { + let width = textureDimensions(t_instances).x; + let texel_index = word_index / 4u; + return InstanceCursor( + word_index, + texel_index, + fetch_instance_texel(texel_index, width), + width, + ); +} + +fn read_word(cursor: ptr) -> u32 { + let word_index = (*cursor).word_index; + let texel_index = word_index / 4u; + if texel_index != (*cursor).texel_index { + (*cursor).texel = fetch_instance_texel(texel_index, (*cursor).width); + (*cursor).texel_index = texel_index; + } + (*cursor).word_index = word_index + 1u; + return (*cursor).texel[word_index % 4u]; +} + +fn read_f32(cursor: ptr) -> f32 { + return bitcast(read_word(cursor)); +} + +fn read_i32(cursor: ptr) -> i32 { + return bitcast(read_word(cursor)); +} + +fn read_vec2_f32(cursor: ptr) -> vec2 { + return vec2(read_f32(cursor), read_f32(cursor)); +} + +fn read_vec2_i32(cursor: ptr) -> vec2 { + return vec2(read_i32(cursor), read_i32(cursor)); +} + +fn read_hsla(cursor: ptr) -> Hsla { + return Hsla( + read_f32(cursor), + read_f32(cursor), + read_f32(cursor), + read_f32(cursor), + ); +} + +fn read_bounds(cursor: ptr) -> Bounds { + return Bounds(read_vec2_f32(cursor), read_vec2_f32(cursor)); +} + +fn read_corners(cursor: ptr) -> Corners { + return Corners( + read_f32(cursor), + read_f32(cursor), + read_f32(cursor), + read_f32(cursor), + ); +} + +fn read_edges(cursor: ptr) -> Edges { + return Edges( + read_f32(cursor), + read_f32(cursor), + read_f32(cursor), + read_f32(cursor), + ); +} + +fn read_color_stop(cursor: ptr) -> LinearColorStop { + return LinearColorStop(read_hsla(cursor), read_f32(cursor)); +} + +fn read_background(cursor: ptr) -> Background { + return Background( + read_word(cursor), + read_word(cursor), + read_hsla(cursor), + read_f32(cursor), + array( + read_color_stop(cursor), + read_color_stop(cursor), + ), + read_word(cursor), + ); +} + +fn read_atlas_tile(cursor: ptr) -> AtlasTile { + return AtlasTile( + AtlasTextureId( + read_word(cursor), + read_word(cursor), + ), + read_word(cursor), + read_word(cursor), + AtlasBounds( + read_vec2_i32(cursor), + read_vec2_i32(cursor), + ), + ); +} + +fn read_transformation(cursor: ptr) -> TransformationMatrix { + return TransformationMatrix( + mat2x2( + read_vec2_f32(cursor), + read_vec2_f32(cursor), + ), + read_vec2_f32(cursor), + ); +} + +fn load_quad(instance_id: u32) -> Quad { + // Keep this fixed-layout decoder explicit. Some WebGL shader compilers fail + // to optimize the cursor's branches and dynamic vector indexing for quads. + let first_texel_index = instance_id * 10u; + let width = textureDimensions(t_instances).x; + let texel0 = fetch_instance_texel(first_texel_index, width); + let texel1 = fetch_instance_texel(first_texel_index + 1u, width); + let texel2 = fetch_instance_texel(first_texel_index + 2u, width); + let texel3 = fetch_instance_texel(first_texel_index + 3u, width); + let texel4 = fetch_instance_texel(first_texel_index + 4u, width); + let texel5 = fetch_instance_texel(first_texel_index + 5u, width); + let texel6 = fetch_instance_texel(first_texel_index + 6u, width); + let texel7 = fetch_instance_texel(first_texel_index + 7u, width); + let texel8 = fetch_instance_texel(first_texel_index + 8u, width); + let texel9 = fetch_instance_texel(first_texel_index + 9u, width); + + let values0 = bitcast>(texel0); + let values1 = bitcast>(texel1); + let values2 = bitcast>(texel2); + let values3 = bitcast>(texel3); + let values4 = bitcast>(texel4); + let values5 = bitcast>(texel5); + let values6 = bitcast>(texel6); + let values7 = bitcast>(texel7); + let values8 = bitcast>(texel8); + let values9 = bitcast>(texel9); + + return Quad( + texel0.x, + texel0.y, + Bounds(values0.zw, values1.xy), + Bounds(values1.zw, values2.xy), + Background( + texel2.z, + texel2.w, + Hsla(values3.x, values3.y, values3.z, values3.w), + values4.x, + array( + LinearColorStop( + Hsla(values4.y, values4.z, values4.w, values5.x), + values5.y, + ), + LinearColorStop( + Hsla(values5.z, values5.w, values6.x, values6.y), + values6.z, + ), + ), + texel6.w, + ), + Hsla(values7.x, values7.y, values7.z, values7.w), + Corners(values8.x, values8.y, values8.z, values8.w), + Edges(values9.x, values9.y, values9.z, values9.w), + ); +} + +fn load_shadow(instance_id: u32) -> Shadow { + var cursor = instance_cursor(instance_id * 28u); + return Shadow( + read_word(&cursor), + read_f32(&cursor), + read_bounds(&cursor), + read_corners(&cursor), + read_bounds(&cursor), + read_hsla(&cursor), + read_bounds(&cursor), + read_corners(&cursor), + read_word(&cursor), + read_word(&cursor), + ); +} + +fn load_path_vertex(vertex_id: u32) -> PathRasterizationVertex { + var cursor = instance_cursor(vertex_id * 26u); + return PathRasterizationVertex( + read_vec2_f32(&cursor), + read_vec2_f32(&cursor), + read_background(&cursor), + read_bounds(&cursor), + ); +} + +fn load_path_sprite(instance_id: u32) -> PathSprite { + var cursor = instance_cursor(instance_id * 4u); + return PathSprite(read_bounds(&cursor)); +} + +fn load_underline(instance_id: u32) -> Underline { + var cursor = instance_cursor(instance_id * 16u); + return Underline( + read_word(&cursor), + read_word(&cursor), + read_bounds(&cursor), + read_bounds(&cursor), + read_hsla(&cursor), + read_f32(&cursor), + read_word(&cursor), + ); +} + +fn load_mono_sprite(instance_id: u32) -> MonochromeSprite { + var cursor = instance_cursor(instance_id * 28u); + return MonochromeSprite( + read_word(&cursor), + read_word(&cursor), + read_bounds(&cursor), + read_bounds(&cursor), + read_hsla(&cursor), + read_atlas_tile(&cursor), + read_transformation(&cursor), + ); +} + +fn load_poly_sprite(instance_id: u32) -> PolychromeSprite { + var cursor = instance_cursor(instance_id * 24u); + return PolychromeSprite( + read_word(&cursor), + read_word(&cursor), + read_word(&cursor), + read_f32(&cursor), + read_bounds(&cursor), + read_bounds(&cursor), + read_corners(&cursor), + read_atlas_tile(&cursor), + ); +} diff --git a/vendor/gpui-pre-wgpu/src/wgpu_atlas.rs b/vendor/gpui-pre-wgpu/src/wgpu_atlas.rs new file mode 100644 index 00000000..b541607b --- /dev/null +++ b/vendor/gpui-pre-wgpu/src/wgpu_atlas.rs @@ -0,0 +1,566 @@ +use anyhow::{Context as _, Result}; +use etagere::{BucketedAtlasAllocator, size2}; +use gpui::{ + AtlasBackend, AtlasKey, AtlasState, AtlasTextureId, AtlasTextureKind, AtlasTextureList, + AtlasTile, Bounds, DevicePixels, PlatformAtlas, Point, Size, +}; +use parking_lot::Mutex; +use std::{borrow::Cow, ops, sync::Arc}; + +use crate::WgpuContext; + +fn device_size_to_etagere(size: Size) -> etagere::Size { + size2(size.width.0, size.height.0) +} + +fn etagere_point_to_device(point: etagere::Point) -> Point { + Point { + x: DevicePixels(point.x), + y: DevicePixels(point.y), + } +} + +pub struct WgpuAtlas(Mutex>); + +struct PendingUpload { + id: AtlasTextureId, + bounds: Bounds, + data: Vec, +} + +struct WgpuAtlasTextures { + device: Arc, + queue: Arc, + max_texture_size: u32, + color_texture_format: wgpu::TextureFormat, + storage: WgpuAtlasStorage, + pending_uploads: Vec, + next_texture_generation: u64, +} + +pub struct WgpuTextureInfo { + pub view: wgpu::TextureView, + /// Distinguishes new textures that reuse an [`AtlasTextureId`]. + pub generation: u64, +} + +impl WgpuAtlas { + pub fn new( + device: Arc, + queue: Arc, + color_texture_format: wgpu::TextureFormat, + ) -> Self { + let max_texture_size = device.limits().max_texture_dimension_2d; + WgpuAtlas(Mutex::new(AtlasState::new(WgpuAtlasTextures { + device, + queue, + max_texture_size, + color_texture_format, + storage: WgpuAtlasStorage::default(), + pending_uploads: Vec::new(), + next_texture_generation: 0, + }))) + } + + pub fn from_context(context: &WgpuContext) -> Self { + Self::new( + context.device.clone(), + context.queue.clone(), + context.color_texture_format(), + ) + } + + pub fn before_frame(&self) { + let mut lock = self.0.lock(); + lock.backend.flush_uploads(); + } + + /// Returns the view backing `id`, or `None` once every tile in it has been + /// removed. A scene can still reference such a texture when a cached view + /// replays a paint from before the image was dropped, so callers must skip + /// those sprites rather than assume the texture exists. + pub fn get_texture_info(&self, id: AtlasTextureId) -> Option { + let lock = self.0.lock(); + let texture = lock.backend.storage.get(id)?; + Some(WgpuTextureInfo { + view: texture.view.clone(), + generation: texture.generation, + }) + } + + /// Clears all cached textures and tiles, forcing them to be recreated. + /// Use this for incremental recovery when the device is still valid. + pub fn clear(&self) { + self.0.lock().clear(|textures| { + textures.storage = WgpuAtlasStorage::default(); + textures.pending_uploads.clear(); + }); + } + + /// Handles device lost by clearing all textures and cached tiles. + /// The atlas will lazily recreate textures as needed on subsequent frames. + pub fn handle_device_lost(&self, context: &WgpuContext) { + self.0.lock().clear(|textures| { + textures.device = context.device.clone(); + textures.queue = context.queue.clone(); + textures.color_texture_format = context.color_texture_format(); + textures.storage = WgpuAtlasStorage::default(); + textures.pending_uploads.clear(); + }); + } +} + +impl PlatformAtlas for WgpuAtlas { + fn get_or_insert_with<'a>( + &self, + key: AtlasKey, + build: &mut dyn FnMut() -> Result, Cow<'a, [u8]>)>>, + ) -> Result> { + self.0.lock().get_or_insert_with(key, build) + } + + fn remove(&self, key: &AtlasKey) { + self.0.lock().remove(key); + } +} + +impl AtlasBackend for WgpuAtlasTextures { + fn insert( + &mut self, + kind: AtlasTextureKind, + size: Size, + bytes: &[u8], + ) -> Result { + let tile = self.allocate(size, kind).context("failed to allocate")?; + self.upload_texture(tile.texture_id, tile.bounds, bytes); + Ok(tile) + } + + fn remove(&mut self, tile: AtlasTile) { + let id = tile.texture_id; + let Some(texture_slot) = self.storage[id.kind].textures.get_mut(id.index as usize) else { + return; + }; + + if let Some(mut texture) = texture_slot.take() { + texture.allocator.deallocate(tile.tile_id.into()); + texture.decrement_ref_count(); + if texture.is_unreferenced() { + self.pending_uploads + .retain(|upload| upload.id != texture.id); + self.storage[id.kind] + .free_list + .push(texture.id.index as usize); + } else { + *texture_slot = Some(texture); + } + } + } +} + +impl WgpuAtlasTextures { + fn allocate( + &mut self, + size: Size, + texture_kind: AtlasTextureKind, + ) -> Option { + { + let textures = &mut self.storage[texture_kind]; + + if let Some(tile) = textures + .iter_mut() + .rev() + .find_map(|texture| texture.allocate(size)) + { + return Some(tile); + } + } + + let texture = self.push_texture(size, texture_kind); + texture.allocate(size) + } + + fn push_texture( + &mut self, + min_size: Size, + kind: AtlasTextureKind, + ) -> &mut WgpuAtlasTexture { + const DEFAULT_ATLAS_SIZE: Size = Size { + width: DevicePixels(1024), + height: DevicePixels(1024), + }; + let max_texture_size = self.max_texture_size as i32; + let max_atlas_size = Size { + width: DevicePixels(max_texture_size), + height: DevicePixels(max_texture_size), + }; + + let size = min_size.min(&max_atlas_size).max(&DEFAULT_ATLAS_SIZE); + let format = match kind { + AtlasTextureKind::Monochrome => wgpu::TextureFormat::R8Unorm, + AtlasTextureKind::Subpixel | AtlasTextureKind::Polychrome => self.color_texture_format, + }; + + let texture = self.device.create_texture(&wgpu::TextureDescriptor { + label: Some("atlas"), + size: wgpu::Extent3d { + width: size.width.0 as u32, + height: size.height.0 as u32, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + + let texture_list = &mut self.storage[kind]; + let index = texture_list.free_list.pop(); + let generation = self.next_texture_generation; + self.next_texture_generation = self.next_texture_generation.wrapping_add(1); + + let atlas_texture = WgpuAtlasTexture { + id: AtlasTextureId { + index: index.unwrap_or(texture_list.textures.len()) as u32, + kind, + }, + generation, + allocator: BucketedAtlasAllocator::new(device_size_to_etagere(size)), + format, + texture, + view, + live_atlas_keys: 0, + }; + + if let Some(ix) = index { + texture_list.textures[ix] = Some(atlas_texture); + texture_list + .textures + .get_mut(ix) + .and_then(|t| t.as_mut()) + .expect("texture must exist") + } else { + texture_list.textures.push(Some(atlas_texture)); + texture_list + .textures + .last_mut() + .and_then(|t| t.as_mut()) + .expect("texture must exist") + } + } + + fn upload_texture(&mut self, id: AtlasTextureId, bounds: Bounds, bytes: &[u8]) { + let data = self + .storage + .get(id) + .map(|texture| swizzle_upload_data(bytes, texture.format)) + .unwrap_or_else(|| bytes.to_vec()); + + self.pending_uploads + .push(PendingUpload { id, bounds, data }); + } + + fn flush_uploads(&mut self) { + for upload in self.pending_uploads.drain(..) { + let Some(texture) = self.storage.get(upload.id) else { + continue; + }; + let bytes_per_pixel = texture.bytes_per_pixel(); + + self.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture: &texture.texture, + mip_level: 0, + origin: wgpu::Origin3d { + x: upload.bounds.origin.x.0 as u32, + y: upload.bounds.origin.y.0 as u32, + z: 0, + }, + aspect: wgpu::TextureAspect::All, + }, + &upload.data, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(upload.bounds.size.width.0 as u32 * bytes_per_pixel as u32), + rows_per_image: None, + }, + wgpu::Extent3d { + width: upload.bounds.size.width.0 as u32, + height: upload.bounds.size.height.0 as u32, + depth_or_array_layers: 1, + }, + ); + } + } +} + +#[derive(Default)] +struct WgpuAtlasStorage { + monochrome_textures: AtlasTextureList, + subpixel_textures: AtlasTextureList, + polychrome_textures: AtlasTextureList, +} + +impl ops::Index for WgpuAtlasStorage { + type Output = AtlasTextureList; + fn index(&self, kind: AtlasTextureKind) -> &Self::Output { + match kind { + AtlasTextureKind::Monochrome => &self.monochrome_textures, + AtlasTextureKind::Subpixel => &self.subpixel_textures, + AtlasTextureKind::Polychrome => &self.polychrome_textures, + } + } +} + +impl ops::IndexMut for WgpuAtlasStorage { + fn index_mut(&mut self, kind: AtlasTextureKind) -> &mut Self::Output { + match kind { + AtlasTextureKind::Monochrome => &mut self.monochrome_textures, + AtlasTextureKind::Subpixel => &mut self.subpixel_textures, + AtlasTextureKind::Polychrome => &mut self.polychrome_textures, + } + } +} + +impl WgpuAtlasStorage { + fn get(&self, id: AtlasTextureId) -> Option<&WgpuAtlasTexture> { + self[id.kind] + .textures + .get(id.index as usize) + .and_then(|t| t.as_ref()) + } +} + +struct WgpuAtlasTexture { + id: AtlasTextureId, + generation: u64, + allocator: BucketedAtlasAllocator, + texture: wgpu::Texture, + view: wgpu::TextureView, + format: wgpu::TextureFormat, + live_atlas_keys: u32, +} + +impl WgpuAtlasTexture { + fn allocate(&mut self, size: Size) -> Option { + let allocation = self.allocator.allocate(device_size_to_etagere(size))?; + let tile = AtlasTile { + texture_id: self.id, + tile_id: allocation.id.into(), + padding: 0, + bounds: Bounds { + origin: etagere_point_to_device(allocation.rectangle.min), + size, + }, + }; + self.live_atlas_keys += 1; + Some(tile) + } + + fn bytes_per_pixel(&self) -> u8 { + match self.format { + wgpu::TextureFormat::R8Unorm => 1, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Rgba8Unorm => 4, + _ => 4, + } + } + + fn decrement_ref_count(&mut self) { + self.live_atlas_keys -= 1; + } + + fn is_unreferenced(&self) -> bool { + self.live_atlas_keys == 0 + } +} + +fn swizzle_upload_data(bytes: &[u8], format: wgpu::TextureFormat) -> Vec { + match format { + wgpu::TextureFormat::Rgba8Unorm => { + let mut data = bytes.to_vec(); + for pixel in data.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + data + } + _ => bytes.to_vec(), + } +} + +#[cfg(all(test, not(target_family = "wasm")))] +mod tests { + use super::*; + use gpui::block_on; + use gpui::{ImageId, RenderImageParams}; + use std::sync::Arc; + + fn test_device_and_queue() -> anyhow::Result<(Arc, Arc)> { + block_on(async { + let instance = wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::all(), + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }); + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::LowPower, + compatible_surface: None, + force_fallback_adapter: false, + }) + .await + .map_err(|error| anyhow::anyhow!("failed to request adapter: {error}"))?; + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("wgpu_atlas_test_device"), + required_features: wgpu::Features::empty(), + required_limits: wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }) + .await + .map_err(|error| anyhow::anyhow!("failed to request device: {error}"))?; + Ok((Arc::new(device), Arc::new(queue))) + }) + } + + #[test] + fn before_frame_skips_uploads_for_removed_texture() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + let key = AtlasKey::Image(RenderImageParams { + image_id: ImageId(1), + frame_index: 0, + }); + let size = Size { + width: DevicePixels(1), + height: DevicePixels(1), + }; + let mut build = || Ok(Some((size, Cow::Owned(vec![0, 0, 0, 255])))); + + // Regression test: before the fix, this panicked in flush_uploads + atlas + .get_or_insert_with(key.clone(), &mut build)? + .expect("tile should be created"); + atlas.remove(&key); + atlas.before_frame(); + Ok(()) + } + + #[test] + fn remove_deallocates_tile_space_for_reuse() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + + let small = Size { + width: DevicePixels(64), + height: DevicePixels(64), + }; + let big = Size { + width: DevicePixels(700), + height: DevicePixels(700), + }; + + let make_key = |image_id: usize| { + AtlasKey::Image(RenderImageParams { + image_id: ImageId(image_id), + frame_index: 0, + }) + }; + let insert = |key: AtlasKey, size: Size| { + let byte_count = (size.width.0 as usize) * (size.height.0 as usize) * 4; + atlas + .get_or_insert_with(key, &mut || { + Ok(Some((size, Cow::Owned(vec![0u8; byte_count])))) + }) + .expect("allocation should succeed") + .expect("callback returns Some") + }; + + let keeper_key = make_key(1); + let big_key_a = make_key(2); + let big_key_b = make_key(3); + + let keeper_tile = insert(keeper_key, small); + let tile_a = insert(big_key_a.clone(), big); + assert_eq!(keeper_tile.texture_id, tile_a.texture_id); + + atlas.remove(&big_key_a); + let tile_b = insert(big_key_b, big); + assert_eq!(tile_b.texture_id, keeper_tile.texture_id); + Ok(()) + } + + #[test] + fn reused_texture_id_has_new_generation() -> anyhow::Result<()> { + let (device, queue) = test_device_and_queue()?; + let atlas = WgpuAtlas::new(device, queue, wgpu::TextureFormat::Bgra8Unorm); + let size = Size { + width: DevicePixels(700), + height: DevicePixels(700), + }; + let make_key = |image_id| { + AtlasKey::Image(RenderImageParams { + image_id: ImageId(image_id), + frame_index: 0, + }) + }; + let insert = |key: AtlasKey| { + atlas + .get_or_insert_with(key, &mut || { + Ok(Some(( + size, + Cow::Owned(vec![0; size.width.0 as usize * size.height.0 as usize * 4]), + ))) + }) + .expect("allocation should succeed") + .expect("callback returns Some") + }; + + let first_key = make_key(1); + let first_tile = insert(first_key.clone()); + let first_generation = atlas + .get_texture_info(first_tile.texture_id) + .context("first texture should exist")? + .generation; + atlas.remove(&first_key); + assert!(atlas.get_texture_info(first_tile.texture_id).is_none()); + + let second_tile = insert(make_key(2)); + let second_generation = atlas + .get_texture_info(second_tile.texture_id) + .context("second texture should exist")? + .generation; + + assert_eq!(second_tile.texture_id, first_tile.texture_id); + assert_ne!(second_generation, first_generation); + Ok(()) + } + + #[test] + fn swizzle_upload_data_preserves_bgra_uploads() { + let input = vec![0x10, 0x20, 0x30, 0x40]; + assert_eq!( + swizzle_upload_data(&input, wgpu::TextureFormat::Bgra8Unorm), + input + ); + } + + #[test] + fn swizzle_upload_data_converts_bgra_to_rgba() { + let input = vec![0x10, 0x20, 0x30, 0x40, 0xAA, 0xBB, 0xCC, 0xDD]; + assert_eq!( + swizzle_upload_data(&input, wgpu::TextureFormat::Rgba8Unorm), + vec![0x30, 0x20, 0x10, 0x40, 0xCC, 0xBB, 0xAA, 0xDD] + ); + } +} diff --git a/vendor/gpui-pre-wgpu/src/wgpu_context.rs b/vendor/gpui-pre-wgpu/src/wgpu_context.rs new file mode 100644 index 00000000..81931aba --- /dev/null +++ b/vendor/gpui-pre-wgpu/src/wgpu_context.rs @@ -0,0 +1,852 @@ +#[cfg(not(target_family = "wasm"))] +use anyhow::Context as _; +#[cfg(not(target_family = "wasm"))] +use gpui_util::ResultExt; +use parking_lot::Mutex; +use std::sync::Arc; +use std::sync::atomic::{AtomicBool, Ordering}; +use wgpu::TextureFormat; + +#[derive(Clone)] +pub struct WgpuContext { + pub instance: wgpu::Instance, + pub adapter: wgpu::Adapter, + pub device: Arc, + pub queue: Arc, + backend: WgpuBackend, + dual_source_blending: bool, + color_texture_format: wgpu::TextureFormat, + errors: Arc, +} + +/// Errors reported by wgpu's device-wide callbacks. +/// +/// A device has exactly one lost callback and one uncaptured-error callback, so this +/// state is installed once per device and shared by every renderer using it. Renderers +/// keep their own handle so they can keep observing a loss after the context that +/// produced it has been dropped for recovery. +#[derive(Default)] +pub struct DeviceErrorState { + lost: AtomicBool, + last_error: Mutex<(u64, Option)>, +} + +impl DeviceErrorState { + fn install(device: &wgpu::Device) -> Arc { + let errors = Arc::new(Self::default()); + device.set_device_lost_callback({ + let errors = Arc::clone(&errors); + move |reason, message| { + log::error!("wgpu device lost: reason={reason:?}, message={message}"); + if reason != wgpu::DeviceLostReason::Destroyed { + errors.lost.store(true, Ordering::Relaxed); + } + } + }); + device.on_uncaptured_error(Arc::new({ + let errors = Arc::clone(&errors); + move |error| errors.record(error.to_string()) + })); + errors + } + + /// Returns true if the GPU device was lost (e.g., due to driver crash, suspend/resume). + pub fn device_lost(&self) -> bool { + self.lost.load(Ordering::Relaxed) + } + + fn record(&self, error: String) { + let mut last_error = self.last_error.lock(); + last_error.0 = last_error.0.wrapping_add(1); + last_error.1 = Some(error); + } + + /// Returns the latest error once per observer, without consuming shared state. + /// Start each observer at zero, including after switching devices, so errors + /// raised during construction are observed. Multiple errors between observations + /// coalesce into the latest error; this is not an error queue. + pub fn observe_error(&self, generation: &mut u64) -> Option { + let last_error = self.last_error.lock(); + if *generation == last_error.0 { + return None; + } + *generation = last_error.0; + last_error.1.clone() + } + + /// The generation an observer should start from to see only errors raised after + /// this call, for a renderer joining a device other renderers already used. + pub fn current_generation(&self) -> u64 { + self.last_error.lock().0 + } +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq)] +pub enum WgpuBackend { + BrowserWebGpu, + Gl, + Native(wgpu::Backend), +} + +#[cfg(target_family = "wasm")] +#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)] +pub enum WebBackendPreference { + #[default] + Auto, + WebGpu, + WebGl, +} + +#[cfg(target_family = "wasm")] +pub struct PreparedWebGraphics { + pub context: WgpuContext, + pub surface: wgpu::Surface<'static>, +} + +/// wgpu-core refuses to create a surface when neither the instance nor the surface +/// target carries a display handle, and `SurfaceTarget::Canvas` always passes `None`. +/// The WebGL2 backend never reads the handle (WebGPU bypasses wgpu-core entirely), so +/// a unit web display handle on the instance satisfies the check. +#[cfg(target_family = "wasm")] +#[derive(Debug)] +struct WebDisplaySource; + +#[cfg(target_family = "wasm")] +impl raw_window_handle::HasDisplayHandle for WebDisplaySource { + fn display_handle( + &self, + ) -> Result, raw_window_handle::HandleError> { + Ok(raw_window_handle::DisplayHandle::web()) + } +} + +#[derive(Clone, Copy)] +pub struct CompositorGpuHint { + pub vendor_id: u32, + pub device_id: u32, +} + +impl WgpuContext { + #[cfg(not(target_family = "wasm"))] + pub fn new( + instance: wgpu::Instance, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option, + ) -> anyhow::Result { + Self::new_with_options(instance, surface, compositor_gpu, false) + } + + #[cfg(not(target_family = "wasm"))] + pub fn new_rejecting_software( + instance: wgpu::Instance, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option, + ) -> anyhow::Result { + Self::new_with_options(instance, surface, compositor_gpu, true) + } + + #[cfg(not(target_family = "wasm"))] + fn new_with_options( + instance: wgpu::Instance, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option, + reject_software: bool, + ) -> anyhow::Result { + let device_id_filter = Self::device_id_filter(); + + // Select an adapter by actually testing surface configuration with the real device. + // This is the only reliable way to determine compatibility on hybrid GPU systems. + let (adapter, device, queue, dual_source_blending, color_texture_format) = + gpui::block_on(Self::select_adapter_and_device( + &instance, + device_id_filter, + surface, + compositor_gpu.as_ref(), + reject_software, + ))?; + + Ok(Self::from_native_adapter_and_device( + instance, + adapter, + device, + queue, + dual_source_blending, + color_texture_format, + )) + } + + /// Unlike [`Self::new_headless`], creates a fresh device from `instance` on every + /// call instead of sharing one, since callers choose different backends. + #[cfg(all(not(target_family = "wasm"), feature = "offscreen"))] + pub(crate) fn new_headless_with_instance( + instance: wgpu::Instance, + ) -> anyhow::Result<(Self, wgpu::TextureFormat)> { + Self::create_headless(instance) + } + + #[cfg(all( + not(target_family = "wasm"), + any( + test, + feature = "bench-support", + feature = "test-support", + feature = "offscreen" + ) + ))] + pub(crate) fn new_headless() -> anyhow::Result<(Self, wgpu::TextureFormat)> { + // Creating a device takes tens of milliseconds, and benchmarks create a headless + // renderer for every window they open, so the process shares one device until it + // is lost. A static rather than a thread-local: the cached device is never dropped, + // because wgpu's queue reads its own thread-locals on drop and would panic if + // dropped while a thread's thread-locals are being destroyed. + static HEADLESS: Mutex> = Mutex::new(None); + let mut headless = HEADLESS.lock(); + if let Some((context, format)) = headless.as_ref() + && !context.device_lost() + { + return Ok((context.clone(), *format)); + } + let created = Self::create_headless(Self::instance(None))?; + *headless = Some(created.clone()); + Ok(created) + } + + #[cfg(all( + not(target_family = "wasm"), + any( + test, + feature = "bench-support", + feature = "test-support", + feature = "offscreen" + ) + ))] + fn create_headless(instance: wgpu::Instance) -> anyhow::Result<(Self, wgpu::TextureFormat)> { + let device_id_filter = Self::device_id_filter(); + let (adapter, device, queue, dual_source_blending, color_texture_format, target_format) = + gpui::block_on(async { + let mut adapters = instance.enumerate_adapters(wgpu::Backends::all()).await; + Self::sort_adapters(&mut adapters, device_id_filter, None); + + for adapter in adapters { + let adapter_info = adapter.get_info(); + let Some(target_format) = Self::headless_target_format(&adapter) else { + log::warn!( + "Adapter {:?} has no supported headless render target format", + adapter_info.name + ); + continue; + }; + + match Self::create_device(&adapter).await { + Ok((device, queue, dual_source_blending, color_texture_format)) => { + #[cfg(feature = "bench-support")] + if adapter_info.device_type == wgpu::DeviceType::Cpu { + log::error!( + "Headless renderer selected software adapter {:?}; \ + benchmark results measure CPU software rendering, not hardware GPU rendering", + adapter_info.name + ); + } + return Ok(( + adapter, + device, + queue, + dual_source_blending, + color_texture_format, + target_format, + )); + } + Err(error) => { + log::warn!( + "Failed to create a headless device for adapter {:?}: {error:#}", + adapter_info.name + ); + } + } + } + + anyhow::bail!("No usable headless GPU adapter found") + })?; + + Ok(( + Self::from_native_adapter_and_device( + instance, + adapter, + device, + queue, + dual_source_blending, + color_texture_format, + ), + target_format, + )) + } + + #[cfg(all( + not(target_family = "wasm"), + any( + test, + feature = "bench-support", + feature = "test-support", + feature = "offscreen" + ) + ))] + /// Both candidates are 8-bit RGBA-ordered or BGRA-ordered formats: headless readback + /// copies rows as 4 bytes per pixel and only swizzles, so no other formats may be added + /// here without updating it. + fn headless_target_format(adapter: &wgpu::Adapter) -> Option { + let required_usages = + wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC; + [ + wgpu::TextureFormat::Rgba8Unorm, + wgpu::TextureFormat::Bgra8Unorm, + ] + .into_iter() + .find(|format| { + adapter + .get_texture_format_features(*format) + .allowed_usages + .contains(required_usages) + }) + } + + #[cfg(not(target_family = "wasm"))] + fn from_native_adapter_and_device( + instance: wgpu::Instance, + adapter: wgpu::Adapter, + device: wgpu::Device, + queue: wgpu::Queue, + dual_source_blending: bool, + color_texture_format: TextureFormat, + ) -> Self { + let errors = DeviceErrorState::install(&device); + + log::info!( + "Selected GPU adapter: {:?} ({:?})", + adapter.get_info().name, + adapter.get_info().backend + ); + let backend = WgpuBackend::Native(adapter.get_info().backend); + + Self { + instance, + adapter, + device: Arc::new(device), + queue: Arc::new(queue), + backend, + dual_source_blending, + color_texture_format, + errors, + } + } + + #[cfg(not(target_family = "wasm"))] + fn device_id_filter() -> Option { + match std::env::var("ZED_DEVICE_ID") { + Ok(value) => parse_pci_id(&value) + .context("Failed to parse device ID from `ZED_DEVICE_ID` environment variable") + .log_err(), + Err(std::env::VarError::NotPresent) => None, + error => { + error + .context("Failed to read value of `ZED_DEVICE_ID` environment variable") + .log_err(); + None + } + } + } + + #[cfg(target_family = "wasm")] + pub async fn new_web( + canvas: &web_sys::HtmlCanvasElement, + preference: WebBackendPreference, + ) -> anyhow::Result { + Self::new_web_with_backend(canvas, preference).await + } + + #[cfg(target_family = "wasm")] + #[allow(clippy::arc_with_non_send_sync)] + async fn new_web_with_backend( + canvas: &web_sys::HtmlCanvasElement, + preference: WebBackendPreference, + ) -> anyhow::Result { + let backends = match preference { + WebBackendPreference::Auto => wgpu::Backends::BROWSER_WEBGPU | wgpu::Backends::GL, + WebBackendPreference::WebGpu => wgpu::Backends::BROWSER_WEBGPU, + WebBackendPreference::WebGl => wgpu::Backends::GL, + }; + let descriptor = wgpu::InstanceDescriptor { + backends, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: Some(Box::new(WebDisplaySource)), + }; + let instance = if preference == WebBackendPreference::Auto { + wgpu::util::new_instance_with_webgpu_detection(descriptor).await + } else { + wgpu::Instance::new(descriptor) + }; + let surface = instance + .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) + .map_err(|error| { + anyhow::anyhow!("Failed to create browser graphics surface: {error}") + })?; + + let adapter = instance + .request_adapter(&wgpu::RequestAdapterOptions { + power_preference: wgpu::PowerPreference::HighPerformance, + compatible_surface: Some(&surface), + force_fallback_adapter: false, + }) + .await + .map_err(|error| { + anyhow::anyhow!( + "Failed to request a {preference:?} adapter compatible with the canvas: {error}" + ) + })?; + let adapter_info = adapter.get_info(); + let backend = match adapter_info.backend { + wgpu::Backend::BrowserWebGpu => WgpuBackend::BrowserWebGpu, + wgpu::Backend::Gl => WgpuBackend::Gl, + backend => { + anyhow::bail!( + "Browser graphics initialization selected unexpected backend {backend:?}" + ) + } + }; + + let (device, queue, dual_source_blending, color_texture_format) = + Self::create_device(&adapter).await?; + let errors = DeviceErrorState::install(&device); + log::info!( + "Browser graphics initialized: requested={preference:?}, selected={backend:?}, \ + adapter={:?}, limits={:?}, dual_source_blending={dual_source_blending}", + adapter_info.name, + device.limits(), + ); + + let context = Self { + instance, + adapter, + device: Arc::new(device), + queue: Arc::new(queue), + backend, + dual_source_blending, + color_texture_format, + errors, + }; + Ok(PreparedWebGraphics { context, surface }) + } + + async fn create_device( + adapter: &wgpu::Adapter, + ) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool, TextureFormat)> { + let dual_source_blending = adapter + .features() + .contains(wgpu::Features::DUAL_SOURCE_BLENDING); + + let mut required_features = wgpu::Features::empty(); + if dual_source_blending { + required_features |= wgpu::Features::DUAL_SOURCE_BLENDING; + } else { + log::warn!( + "Dual-source blending not available on this GPU. \ + Subpixel text antialiasing will be disabled." + ); + } + + let color_atlas_texture_format = Self::select_color_texture_format(adapter)?; + #[cfg(target_family = "wasm")] + let required_limits = if adapter.get_info().backend == wgpu::Backend::Gl { + wgpu::Limits::downlevel_webgl2_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()) + } else { + wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()) + }; + #[cfg(not(target_family = "wasm"))] + let required_limits = wgpu::Limits::downlevel_defaults() + .using_resolution(adapter.limits()) + .using_alignment(adapter.limits()); + + let (device, queue) = adapter + .request_device(&wgpu::DeviceDescriptor { + label: Some("gpui_device"), + required_features, + required_limits, + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + }) + .await + .map_err(|e| anyhow::anyhow!("Failed to create wgpu device: {e}"))?; + + Ok(( + device, + queue, + dual_source_blending, + color_atlas_texture_format, + )) + } + + #[cfg(not(target_family = "wasm"))] + pub fn instance(display: Option>) -> wgpu::Instance { + wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::VULKAN | wgpu::Backends::GL, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display, + }) + } + + pub fn check_compatible_with_surface(&self, surface: &wgpu::Surface<'_>) -> anyhow::Result<()> { + let caps = surface.get_capabilities(&self.adapter); + if caps.formats.is_empty() { + let info = self.adapter.get_info(); + anyhow::bail!( + "Adapter {:?} (backend={:?}, device={:#06x}) is not compatible with the \ + display surface for this window.", + info.name, + info.backend, + info.device, + ); + } + Ok(()) + } + + /// Select an adapter and create a device, testing that the surface can actually be configured. + /// This is the only reliable way to determine compatibility on hybrid GPU systems, where + /// adapters may report surface compatibility via get_capabilities() but fail when actually + /// configuring (e.g., NVIDIA reporting Vulkan Wayland support but failing because the + /// Wayland compositor runs on the Intel GPU). + #[cfg(not(target_family = "wasm"))] + async fn select_adapter_and_device( + instance: &wgpu::Instance, + device_id_filter: Option, + surface: &wgpu::Surface<'_>, + compositor_gpu: Option<&CompositorGpuHint>, + reject_software: bool, + ) -> anyhow::Result<( + wgpu::Adapter, + wgpu::Device, + wgpu::Queue, + bool, + TextureFormat, + )> { + let mut adapters: Vec<_> = instance.enumerate_adapters(wgpu::Backends::all()).await; + + if adapters.is_empty() { + anyhow::bail!("No GPU adapters found"); + } + + if let Some(device_id) = device_id_filter { + log::info!("ZED_DEVICE_ID filter: {:#06x}", device_id); + } + + Self::sort_adapters(&mut adapters, device_id_filter, compositor_gpu); + + // Log all available adapters (in sorted order) + log::info!("Found {} GPU adapter(s):", adapters.len()); + for adapter in &adapters { + let info = adapter.get_info(); + log::info!( + " - {} (vendor={:#06x}, device={:#06x}, backend={:?}, type={:?})", + info.name, + info.vendor, + info.device, + info.backend, + info.device_type, + ); + } + + // Test each adapter by creating a device and configuring the surface + for adapter in adapters { + let info = adapter.get_info(); + + if reject_software && info.device_type == wgpu::DeviceType::Cpu { + log::info!( + "Skipping software renderer: {} ({:?})", + info.name, + info.backend + ); + continue; + } + + log::info!("Testing adapter: {} ({:?})...", info.name, info.backend); + + match Self::try_adapter_with_surface(&adapter, surface).await { + Ok((device, queue, dual_source_blending, color_atlas_texture_format)) => { + log::info!( + "Selected GPU (passed configuration test): {} ({:?})", + info.name, + info.backend + ); + return Ok(( + adapter, + device, + queue, + dual_source_blending, + color_atlas_texture_format, + )); + } + Err(e) => { + log::info!( + " Adapter {} ({:?}) failed: {}, trying next...", + info.name, + info.backend, + e + ); + } + } + } + + anyhow::bail!("No GPU adapter found that can configure the display surface") + } + + /// Sort adapters into a single priority order. Tiers (from highest to lowest): + /// + /// 1. ZED_DEVICE_ID match — explicit user override + /// 2. Compositor GPU match — the GPU the display server is rendering on + /// 3. Device type (Discrete > Integrated > Other > Virtual > Cpu). + /// "Other" ranks above "Virtual" because OpenGL seems to count as "Other". + /// 4. Backend — prefer Vulkan/Metal/Dx12 over GL/etc. + #[cfg(not(target_family = "wasm"))] + fn sort_adapters( + adapters: &mut [wgpu::Adapter], + device_id_filter: Option, + compositor_gpu: Option<&CompositorGpuHint>, + ) { + adapters.sort_by_key(|adapter| { + let info = adapter.get_info(); + // Backends like OpenGL report device=0 for all adapters, so + // device-based matching is only meaningful when non-zero. + let device_known = info.device != 0; + let user_override: u8 = match device_id_filter { + Some(id) if device_known && info.device == id => 0, + _ => 1, + }; + let compositor_match: u8 = match compositor_gpu { + Some(hint) + if device_known + && info.vendor == hint.vendor_id + && info.device == hint.device_id => + { + 0 + } + _ => 1, + }; + let type_priority: u8 = match info.device_type { + wgpu::DeviceType::DiscreteGpu => 0, + wgpu::DeviceType::IntegratedGpu => 1, + wgpu::DeviceType::Other => 2, + wgpu::DeviceType::VirtualGpu => 3, + wgpu::DeviceType::Cpu => 4, + }; + let backend_priority: u8 = match info.backend { + wgpu::Backend::Vulkan | wgpu::Backend::Metal | wgpu::Backend::Dx12 => 0, + _ => 1, + }; + + ( + user_override, + compositor_match, + type_priority, + backend_priority, + ) + }); + } + + /// Try to use an adapter with a surface by creating a device and testing configuration. + /// Returns the device and queue if successful, allowing them to be reused. + #[cfg(not(target_family = "wasm"))] + async fn try_adapter_with_surface( + adapter: &wgpu::Adapter, + surface: &wgpu::Surface<'_>, + ) -> anyhow::Result<(wgpu::Device, wgpu::Queue, bool, TextureFormat)> { + let caps = surface.get_capabilities(adapter); + if caps.formats.is_empty() { + anyhow::bail!("no compatible surface formats"); + } + if caps.alpha_modes.is_empty() { + anyhow::bail!("no compatible alpha modes"); + } + + let (device, queue, dual_source_blending, color_atlas_texture_format) = + Self::create_device(adapter).await?; + let error_scope = device.push_error_scope(wgpu::ErrorFilter::Validation); + + let test_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: caps.formats[0], + width: 64, + height: 64, + present_mode: wgpu::PresentMode::Fifo, + desired_maximum_frame_latency: 2, + alpha_mode: caps.alpha_modes[0], + view_formats: vec![], + }; + + surface.configure(&device, &test_config); + + let error = error_scope.pop().await; + if let Some(e) = error { + anyhow::bail!("surface configuration failed: {e}"); + } + + Ok(( + device, + queue, + dual_source_blending, + color_atlas_texture_format, + )) + } + + fn select_color_texture_format(adapter: &wgpu::Adapter) -> anyhow::Result { + let required_usages = wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST; + let bgra_features = adapter.get_texture_format_features(wgpu::TextureFormat::Bgra8Unorm); + let rgba_features = adapter.get_texture_format_features(wgpu::TextureFormat::Rgba8Unorm); + #[cfg(target_family = "wasm")] + if adapter.get_info().backend == wgpu::Backend::Gl + && rgba_features.allowed_usages.contains(required_usages) + { + return Ok(wgpu::TextureFormat::Rgba8Unorm); + } + if bgra_features.allowed_usages.contains(required_usages) { + return Ok(wgpu::TextureFormat::Bgra8Unorm); + } + if rgba_features.allowed_usages.contains(required_usages) { + let info = adapter.get_info(); + log::warn!( + "Adapter {} ({:?}) does not support Bgra8Unorm atlas textures with usages {:?}; \ + falling back to Rgba8Unorm atlas textures.", + info.name, + info.backend, + required_usages, + ); + return Ok(wgpu::TextureFormat::Rgba8Unorm); + } + + let info = adapter.get_info(); + Err(anyhow::anyhow!( + "Adapter {} ({:?}, device={:#06x}) does not support a usable color atlas texture \ + format with usages {:?}. Bgra8Unorm allowed usages: {:?}; \ + Rgba8Unorm allowed usages: {:?}.", + info.name, + info.backend, + info.device, + required_usages, + bgra_features.allowed_usages, + rgba_features.allowed_usages, + )) + } + pub fn backend(&self) -> WgpuBackend { + self.backend + } + + pub fn uses_webgl_instance_data(&self) -> bool { + matches!(self.backend, WgpuBackend::Gl) && cfg!(target_family = "wasm") + } + + pub fn supports_dual_source_blending(&self) -> bool { + self.dual_source_blending + } + + pub fn color_texture_format(&self) -> wgpu::TextureFormat { + self.color_texture_format + } + + /// Returns true if the GPU device was lost (e.g., due to driver crash, suspend/resume). + /// When this returns true, the context should be recreated. + pub fn device_lost(&self) -> bool { + self.errors.device_lost() + } + + /// The device-wide error state, shared with renderers on this device. + pub fn errors(&self) -> &Arc { + &self.errors + } +} + +#[cfg(not(target_family = "wasm"))] +fn parse_pci_id(id: &str) -> anyhow::Result { + let mut id = id.trim(); + + if id.starts_with("0x") || id.starts_with("0X") { + id = &id[2..]; + } + let is_hex_string = id.chars().all(|c| c.is_ascii_hexdigit()); + let is_4_chars = id.len() == 4; + anyhow::ensure!( + is_4_chars && is_hex_string, + "Expected a 4 digit PCI ID in hexadecimal format" + ); + + u32::from_str_radix(id, 16).context("parsing PCI ID as hex") +} + +#[cfg(test)] +mod tests { + use super::{DeviceErrorState, parse_pci_id}; + + #[test] + fn device_errors_are_observed_independently() { + let errors = DeviceErrorState::default(); + let mut first = 0; + let mut second = 0; + assert_eq!(errors.observe_error(&mut first), None); + errors.record("first".into()); + assert_eq!(errors.observe_error(&mut first).as_deref(), Some("first")); + assert_eq!(errors.observe_error(&mut first), None); + assert_eq!(errors.observe_error(&mut second).as_deref(), Some("first")); + assert_eq!(errors.observe_error(&mut second), None); + + errors.record("second".into()); + assert_eq!(errors.observe_error(&mut second).as_deref(), Some("second")); + assert_eq!(errors.observe_error(&mut first).as_deref(), Some("second")); + assert_eq!(errors.observe_error(&mut first), None); + assert_eq!(errors.observe_error(&mut second), None); + } + + #[test] + fn device_errors_coalesce_and_new_observers_see_latest() { + let errors = DeviceErrorState::default(); + errors.record("older".into()); + errors.record("latest".into()); + let mut generation = 0; + assert_eq!( + errors.observe_error(&mut generation).as_deref(), + Some("latest") + ); + assert_eq!(errors.observe_error(&mut generation), None); + + let replacement = DeviceErrorState::default(); + replacement.record("replacement".into()); + generation = 0; + assert_eq!( + replacement.observe_error(&mut generation).as_deref(), + Some("replacement") + ); + assert_eq!(replacement.observe_error(&mut generation), None); + } + + #[test] + fn test_parse_device_id() { + assert!(parse_pci_id("0xABCD").is_ok()); + assert!(parse_pci_id("ABCD").is_ok()); + assert!(parse_pci_id("abcd").is_ok()); + assert!(parse_pci_id("1234").is_ok()); + assert!(parse_pci_id("123").is_err()); + assert_eq!( + parse_pci_id(&format!("{:x}", 0x1234)).unwrap(), + parse_pci_id(&format!("{:X}", 0x1234)).unwrap(), + ); + + assert_eq!( + parse_pci_id(&format!("{:#x}", 0x1234)).unwrap(), + parse_pci_id(&format!("{:#X}", 0x1234)).unwrap(), + ); + } +} diff --git a/vendor/gpui-pre-wgpu/src/wgpu_renderer.rs b/vendor/gpui-pre-wgpu/src/wgpu_renderer.rs new file mode 100644 index 00000000..218f94f8 --- /dev/null +++ b/vendor/gpui-pre-wgpu/src/wgpu_renderer.rs @@ -0,0 +1,2986 @@ +use crate::{CompositorGpuHint, DeviceErrorState, WgpuAtlas, WgpuContext}; +use anyhow::{Context as _, Result}; +use bytemuck::{Pod, Zeroable}; +use collections::FxHashMap; +use gpui::{ + AtlasTextureId, Background, Bounds, DevicePixels, GpuSpecs, Path, Point, PrimitiveBatch, + ScaledPixels, Scene, Size, get_gamma_correction_ratios, +}; +use log::warn; +#[cfg(not(target_family = "wasm"))] +use raw_window_handle::{HasDisplayHandle, HasWindowHandle}; +use smallvec::SmallVec; +use std::cell::RefCell; +use std::num::NonZeroU64; +use std::ops::Range; +use std::rc::Rc; +use std::sync::Arc; + +const MAX_INSTANCE_BUFFER_SIZE: u64 = 256 * 1024 * 1024; + +const INSTANCE_TEXTURE_TEXEL_SIZE: u64 = 16; + +/// Shader variant for backends with storage buffer support: the shared shader +/// logic plus the storage-buffer instance transport. +const STORAGE_BUFFER_SHADERS: &str = concat!( + include_str!("shaders.wgsl"), + include_str!("shaders_storage.wgsl"), +); + +/// Shader variant for WebGL2, which has no storage buffers: the shared shader +/// logic plus the texture-based instance transport. +const WEBGL_SHADERS: &str = concat!( + include_str!("shaders.wgsl"), + include_str!("shaders_webgl.wgsl"), +); + +/// Subpixel text rendering requires dual-source blending, which WebGL2 lacks, so +/// this variant only ever runs with the storage-buffer transport. The `enable` +/// directive must precede all declarations. +const SUBPIXEL_SHADERS: &str = concat!( + "enable dual_source_blending;\n", + include_str!("shaders.wgsl"), + include_str!("shaders_storage.wgsl"), + include_str!("shaders_subpixel.wgsl"), +); + +fn least_common_multiple(left: u64, right: u64) -> u64 { + let mut first = left; + let mut second = right; + while second != 0 { + let remainder = first % second; + first = second; + second = remainder; + } + left / first * right +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct GlobalParams { + viewport_size: [f32; 2], + premultiplied_alpha: u32, + pad: u32, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct PodBounds { + origin: [f32; 2], + size: [f32; 2], +} + +impl From> for PodBounds { + fn from(bounds: Bounds) -> Self { + Self { + origin: [bounds.origin.x.0, bounds.origin.y.0], + size: [bounds.size.width.0, bounds.size.height.0], + } + } +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct SurfaceParams { + bounds: PodBounds, + content_mask: PodBounds, +} + +#[repr(C)] +#[derive(Clone, Copy, Pod, Zeroable)] +struct GammaParams { + gamma_ratios: [f32; 4], + grayscale_enhanced_contrast: f32, + subpixel_enhanced_contrast: f32, + is_bgr: u32, + _pad: u32, +} + +#[derive(Clone, Debug)] +#[repr(C)] +struct PathSprite { + bounds: Bounds, +} + +#[derive(Clone, Debug)] +#[repr(C)] +struct PathRasterizationVertex { + xy_position: Point, + st_position: Point, + color: Background, + bounds: Bounds, +} + +pub struct WgpuSurfaceConfig { + pub size: Size, + pub transparent: bool, + /// Preferred presentation mode. When `Some`, the renderer will use this + /// mode if supported by the surface, falling back to `Fifo`. + /// When `None`, defaults to `Fifo` (VSync). + /// + /// Mobile platforms may prefer `Mailbox` (triple-buffering) to avoid + /// blocking in `get_current_texture()` during lifecycle transitions. + pub preferred_present_mode: Option, +} + +struct WgpuPipelines { + quads: wgpu::RenderPipeline, + shadows: wgpu::RenderPipeline, + path_rasterization: wgpu::RenderPipeline, + paths: wgpu::RenderPipeline, + underlines: wgpu::RenderPipeline, + mono_sprites: wgpu::RenderPipeline, + subpixel_sprites: Option, + poly_sprites: wgpu::RenderPipeline, + #[allow(dead_code)] + surfaces: wgpu::RenderPipeline, +} + +/// One frame allocation of instance data, ready to bind. +struct InstanceBinding { + bind_group: wgpu::BindGroup, + /// Index of the allocation's first instance within the bound data. Always + /// zero on the storage-buffer path, where the binding offset already + /// positions the array; on the WebGL texture path the shader indexes the + /// shared instance texture absolutely, so draws must offset their + /// instance (or vertex) ranges by this value. + first_instance: u32, +} + +struct InstanceBindings { + quads: InstanceBinding, + shadows: InstanceBinding, + underlines: InstanceBinding, + monochrome_sprites: InstanceBinding, + subpixel_sprites: InstanceBinding, + polychrome_sprites: InstanceBinding, +} + +struct WgpuBindGroupLayouts { + globals: wgpu::BindGroupLayout, + instances: wgpu::BindGroupLayout, + texture: wgpu::BindGroupLayout, + surfaces: wgpu::BindGroupLayout, +} + +/// Shared GPU context reference, used to coordinate device recovery across multiple windows. +pub type GpuContext = Rc>>; + +enum InstanceData { + Storage(wgpu::Buffer), + // WebGL2 has no storage buffers. A uint texture keeps the records available to both shader + // stages while preserving integer and floating-point bit patterns exactly. + Texture { + texture: wgpu::Texture, + view: wgpu::TextureView, + width: u32, + height: u32, + }, +} + +/// GPU resources that must be dropped together during device recovery. +struct WgpuResources { + device: Arc, + queue: Arc, + pipelines: WgpuPipelines, + bind_group_layouts: WgpuBindGroupLayouts, + atlas_sampler: wgpu::Sampler, + atlas_texture_bind_groups: FxHashMap, + globals_buffer: wgpu::Buffer, + globals_bind_group: wgpu::BindGroup, + path_globals_bind_group: wgpu::BindGroup, + instance_data: InstanceData, + path_intermediate_texture: Option, + path_intermediate_view: Option, + path_msaa_texture: Option, + path_msaa_view: Option, +} + +struct CachedTextureBindGroup { + texture_generation: u64, + bind_group: wgpu::BindGroup, +} + +impl WgpuResources { + fn invalidate_intermediate_textures(&mut self) { + self.path_intermediate_texture = None; + self.path_intermediate_view = None; + self.path_msaa_texture = None; + self.path_msaa_view = None; + } +} + +struct WgpuRendererCore { + resources: WgpuResources, + atlas: Arc, + path_globals_offset: u64, + gamma_offset: u64, + instance_data_capacity: u64, + max_instance_data_size: u64, + instance_data_alignment: u64, + uses_webgl_instance_data: bool, + rendering_params: RenderingParameters, + is_bgr: bool, + dual_source_blending: bool, + adapter_info: wgpu::AdapterInfo, + target_format: wgpu::TextureFormat, + max_texture_size: u32, +} + +/// GPU resources of a windowed renderer. A surface is only ever configured against the +/// device that owns `core`, so it cannot outlive it: there is no surface-only state. +enum RendererState { + /// Frames can be drawn. + Ready { + surface: wgpu::Surface<'static>, + core: WgpuRendererCore, + }, + /// The native surface is gone (Android `TerminateWindow`, browser context loss) but + /// the device, pipelines, and atlas remain so `replace_surface` can resume without + /// re-uploading cached textures. + Unconfigured { core: WgpuRendererCore }, + /// Released by `destroy`, or dropped for a device recovery that has not succeeded yet. + Released, +} + +pub struct WgpuRenderer { + /// Shared GPU context for device recovery coordination (unused on WASM). + #[allow(dead_code)] + context: Option, + /// Compositor GPU hint for adapter selection (unused on WASM). + #[allow(dead_code)] + compositor_gpu: Option, + state: RendererState, + surface_config: wgpu::SurfaceConfiguration, + atlas: Arc, + transparent_alpha_mode: wgpu::CompositeAlphaMode, + opaque_alpha_mode: wgpu::CompositeAlphaMode, + max_texture_size: u32, + is_bgr: bool, + failed_frame_count: u32, + device_errors: Arc, + observed_error_generation: u64, + last_surface_error: Option, + needs_redraw: bool, +} + +impl WgpuRenderer { + fn core(&self) -> Option<&WgpuRendererCore> { + match &self.state { + RendererState::Ready { core, .. } | RendererState::Unconfigured { core } => Some(core), + RendererState::Released => None, + } + } + + fn core_mut(&mut self) -> Option<&mut WgpuRendererCore> { + match &mut self.state { + RendererState::Ready { core, .. } | RendererState::Unconfigured { core } => Some(core), + RendererState::Released => None, + } + } + + /// Creates a new WgpuRenderer from raw window handles. + /// + /// The `gpu_context` is a shared reference that coordinates GPU context across + /// multiple windows. The first window to create a renderer will initialize the + /// context; subsequent windows will share it. + /// + /// # Safety + /// The caller must ensure that the window handle remains valid for the lifetime + /// of the returned renderer. + #[cfg(not(target_family = "wasm"))] + pub fn new( + gpu_context: GpuContext, + window: &W, + config: WgpuSurfaceConfig, + compositor_gpu: Option, + ) -> anyhow::Result + where + W: HasWindowHandle + HasDisplayHandle + std::fmt::Debug + Send + Sync + Clone + 'static, + { + let window_handle = window + .window_handle() + .map_err(|e| anyhow::anyhow!("Failed to get window handle: {e}"))?; + + let target = wgpu::SurfaceTargetUnsafe::RawHandle { + // Fall back to the display handle already provided via InstanceDescriptor::display. + raw_display_handle: None, + raw_window_handle: window_handle.as_raw(), + }; + + // Use the existing context's instance if available, otherwise create a new one. + // The surface must be created with the same instance that will be used for + // adapter selection, otherwise wgpu will panic. + let instance = gpu_context + .borrow() + .as_ref() + .map(|ctx| ctx.instance.clone()) + .unwrap_or_else(|| WgpuContext::instance(Some(Box::new(window.clone())))); + + // Safety: The caller guarantees that the window handle is valid for the + // lifetime of this renderer. In practice, the RawWindow struct is created + // from the native window handles and the surface is dropped before the window. + let surface = unsafe { + instance + .create_surface_unsafe(target) + .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))? + }; + + let mut ctx_ref = gpu_context.borrow_mut(); + let context = match ctx_ref.as_mut() { + Some(context) => { + context.check_compatible_with_surface(&surface)?; + context + } + None => ctx_ref.insert(WgpuContext::new(instance, &surface, compositor_gpu)?), + }; + + let atlas = Arc::new(WgpuAtlas::from_context(context)); + + Self::new_internal( + Some(Rc::clone(&gpu_context)), + context, + surface, + config, + compositor_gpu, + atlas, + ) + } + + #[cfg(target_family = "wasm")] + pub fn new_from_canvas( + context: &WgpuContext, + canvas: &web_sys::HtmlCanvasElement, + config: WgpuSurfaceConfig, + ) -> anyhow::Result { + let surface = context + .instance + .create_surface(wgpu::SurfaceTarget::Canvas(canvas.clone())) + .map_err(|e| anyhow::anyhow!("Failed to create surface: {e}"))?; + Self::new_from_surface(context, surface, config) + } + + #[cfg(target_family = "wasm")] + #[allow(clippy::arc_with_non_send_sync)] + pub fn new_from_surface( + context: &WgpuContext, + surface: wgpu::Surface<'static>, + config: WgpuSurfaceConfig, + ) -> anyhow::Result { + let atlas = Arc::new(WgpuAtlas::from_context(context)); + Self::new_internal(None, context, surface, config, None, atlas) + } + + fn new_internal( + gpu_context: Option, + context: &WgpuContext, + surface: wgpu::Surface<'static>, + config: WgpuSurfaceConfig, + compositor_gpu: Option, + atlas: Arc, + ) -> anyhow::Result { + let surface_caps = surface.get_capabilities(&context.adapter); + let preferred_formats = [ + wgpu::TextureFormat::Bgra8Unorm, + wgpu::TextureFormat::Rgba8Unorm, + ]; + let surface_format = preferred_formats + .iter() + .find(|f| surface_caps.formats.contains(f)) + .copied() + .or_else(|| surface_caps.formats.iter().find(|f| !f.is_srgb()).copied()) + .or_else(|| surface_caps.formats.first().copied()) + .ok_or_else(|| { + anyhow::anyhow!( + "Surface reports no supported texture formats for adapter {:?}", + context.adapter.get_info().name + ) + })?; + + let pick_alpha_mode = + |preferences: &[wgpu::CompositeAlphaMode]| -> anyhow::Result { + preferences + .iter() + .find(|p| surface_caps.alpha_modes.contains(p)) + .copied() + .or_else(|| surface_caps.alpha_modes.first().copied()) + .ok_or_else(|| { + anyhow::anyhow!( + "Surface reports no supported alpha modes for adapter {:?}", + context.adapter.get_info().name + ) + }) + }; + + let transparent_alpha_mode = pick_alpha_mode(&[ + wgpu::CompositeAlphaMode::PreMultiplied, + wgpu::CompositeAlphaMode::Inherit, + ])?; + + let opaque_alpha_mode = pick_alpha_mode(&[ + wgpu::CompositeAlphaMode::Opaque, + wgpu::CompositeAlphaMode::Inherit, + ])?; + + let alpha_mode = if config.transparent { + transparent_alpha_mode + } else { + opaque_alpha_mode + }; + + let device = Arc::clone(&context.device); + let max_texture_size = device.limits().max_texture_dimension_2d; + + let requested_width = config.size.width.0 as u32; + let requested_height = config.size.height.0 as u32; + let clamped_width = requested_width.min(max_texture_size); + let clamped_height = requested_height.min(max_texture_size); + + if clamped_width != requested_width || clamped_height != requested_height { + warn!( + "Requested surface size ({}, {}) exceeds maximum texture dimension {}. \ + Clamping to ({}, {}). Window content may not fill the entire window.", + requested_width, requested_height, max_texture_size, clamped_width, clamped_height + ); + } + + let surface_config = wgpu::SurfaceConfiguration { + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + format: surface_format, + width: clamped_width.max(1), + height: clamped_height.max(1), + present_mode: config + .preferred_present_mode + .filter(|mode| surface_caps.present_modes.contains(mode)) + .unwrap_or(wgpu::PresentMode::Fifo), + desired_maximum_frame_latency: 2, + alpha_mode, + view_formats: vec![], + }; + // Configure the surface immediately. The adapter selection process already validated + // that this adapter can successfully configure this surface. + surface.configure(&context.device, &surface_config); + + let core = WgpuRendererCore::new(context, atlas.clone(), surface_format, alpha_mode); + + Ok(Self { + context: gpu_context, + compositor_gpu, + state: RendererState::Ready { surface, core }, + surface_config, + atlas, + transparent_alpha_mode, + opaque_alpha_mode, + max_texture_size, + is_bgr: false, + failed_frame_count: 0, + device_errors: Arc::clone(context.errors()), + observed_error_generation: 0, + last_surface_error: None, + needs_redraw: false, + }) + } +} + +impl WgpuRendererCore { + fn create_bind_group_layouts( + device: &wgpu::Device, + uses_webgl_instance_data: bool, + ) -> WgpuBindGroupLayouts { + let globals = + device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("globals_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new( + std::mem::size_of::() as u64 + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new( + std::mem::size_of::() as u64 + ), + }, + count: None, + }, + ], + }); + + let instance_data_entry = wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: if uses_webgl_instance_data { + wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Uint, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + } + } else { + wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Storage { read_only: true }, + has_dynamic_offset: false, + min_binding_size: None, + } + }, + count: None, + }; + + let instances = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("instances_layout"), + entries: &[instance_data_entry], + }); + + let texture = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("texture_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + let surfaces = device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: Some("surfaces_layout"), + entries: &[ + wgpu::BindGroupLayoutEntry { + binding: 0, + visibility: wgpu::ShaderStages::VERTEX_FRAGMENT, + ty: wgpu::BindingType::Buffer { + ty: wgpu::BufferBindingType::Uniform, + has_dynamic_offset: false, + min_binding_size: NonZeroU64::new( + std::mem::size_of::() as u64 + ), + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 1, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 2, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Texture { + sample_type: wgpu::TextureSampleType::Float { filterable: true }, + view_dimension: wgpu::TextureViewDimension::D2, + multisampled: false, + }, + count: None, + }, + wgpu::BindGroupLayoutEntry { + binding: 3, + visibility: wgpu::ShaderStages::FRAGMENT, + ty: wgpu::BindingType::Sampler(wgpu::SamplerBindingType::Filtering), + count: None, + }, + ], + }); + + WgpuBindGroupLayouts { + globals, + instances, + texture, + surfaces, + } + } + + fn create_instance_texture( + device: &wgpu::Device, + requested_capacity: u64, + max_texture_dimension: u32, + ) -> (InstanceData, u64) { + let texel_count = requested_capacity.div_ceil(INSTANCE_TEXTURE_TEXEL_SIZE); + let width = texel_count.min(u64::from(max_texture_dimension)).max(1) as u32; + let height = texel_count + .div_ceil(u64::from(width)) + .min(u64::from(max_texture_dimension)) + .max(1) as u32; + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("instance_texture"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: wgpu::TextureFormat::Rgba32Uint, + usage: wgpu::TextureUsages::TEXTURE_BINDING | wgpu::TextureUsages::COPY_DST, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + let capacity = u64::from(width) * u64::from(height) * INSTANCE_TEXTURE_TEXEL_SIZE; + ( + InstanceData::Texture { + texture, + view, + width, + height, + }, + capacity, + ) + } + + fn create_pipelines( + device: &wgpu::Device, + layouts: &WgpuBindGroupLayouts, + surface_format: wgpu::TextureFormat, + alpha_mode: wgpu::CompositeAlphaMode, + path_sample_count: u32, + dual_source_blending: bool, + uses_webgl_instance_data: bool, + ) -> WgpuPipelines { + // Diagnostic guard: verify the device actually has + // DUAL_SOURCE_BLENDING. We have a crash report (ZED-5G1) where a + // feature mismatch caused a wgpu-hal abort, but we haven't + // identified the code path that produces the mismatch. This + // guard prevents the crash and logs more evidence. + // Remove this check once: + // a) We find and fix the root cause, or + // b) There are no reports of this warning appearing for some time. + let device_has_feature = device + .features() + .contains(wgpu::Features::DUAL_SOURCE_BLENDING); + if dual_source_blending && !device_has_feature { + log::error!( + "BUG: dual_source_blending flag is true but device does not \ + have DUAL_SOURCE_BLENDING enabled (device features: {:?}). \ + Falling back to mono text rendering. Please report this at \ + https://github.com/zed-industries/zed/issues", + device.features(), + ); + } + let dual_source_blending = + dual_source_blending && device_has_feature && !uses_webgl_instance_data; + + let shader_source = if uses_webgl_instance_data { + WEBGL_SHADERS + } else { + STORAGE_BUFFER_SHADERS + }; + let shader_module = device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpui_shaders"), + source: wgpu::ShaderSource::Wgsl(shader_source.into()), + }); + + let subpixel_shader_module = if dual_source_blending { + Some(device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: Some("gpui_subpixel_shaders"), + source: wgpu::ShaderSource::Wgsl(SUBPIXEL_SHADERS.into()), + })) + } else { + None + }; + + let blend_mode = match alpha_mode { + wgpu::CompositeAlphaMode::PreMultiplied => { + wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING + } + _ => wgpu::BlendState::ALPHA_BLENDING, + }; + + let color_target = wgpu::ColorTargetState { + format: surface_format, + blend: Some(blend_mode), + write_mask: wgpu::ColorWrites::ALL, + }; + + let create_pipeline = |name: &str, + vs_entry: &str, + fs_entry: &str, + globals_layout: &wgpu::BindGroupLayout, + data_layout: &wgpu::BindGroupLayout, + texture_layout: Option<&wgpu::BindGroupLayout>, + topology: wgpu::PrimitiveTopology, + color_targets: &[Option], + sample_count: u32, + module: &wgpu::ShaderModule| { + let mut bind_group_layouts = vec![Some(globals_layout), Some(data_layout)]; + bind_group_layouts.extend(texture_layout.map(Some)); + let pipeline_layout = device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: Some(&format!("{name}_layout")), + bind_group_layouts: &bind_group_layouts, + immediate_size: 0, + }); + + device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: Some(name), + layout: Some(&pipeline_layout), + vertex: wgpu::VertexState { + module, + entry_point: Some(vs_entry), + buffers: &[], + compilation_options: wgpu::PipelineCompilationOptions::default(), + }, + fragment: Some(wgpu::FragmentState { + module, + entry_point: Some(fs_entry), + targets: color_targets, + compilation_options: wgpu::PipelineCompilationOptions::default(), + }), + primitive: wgpu::PrimitiveState { + topology, + strip_index_format: None, + front_face: wgpu::FrontFace::Ccw, + cull_mode: None, + polygon_mode: wgpu::PolygonMode::Fill, + unclipped_depth: false, + conservative: false, + }, + depth_stencil: None, + multisample: wgpu::MultisampleState { + count: sample_count, + mask: !0, + alpha_to_coverage_enabled: false, + }, + multiview_mask: None, + cache: None, + }) + }; + + let quads = create_pipeline( + "quads", + "vs_quad", + "fs_quad", + &layouts.globals, + &layouts.instances, + None, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + &shader_module, + ); + + let shadows = create_pipeline( + "shadows", + "vs_shadow", + "fs_shadow", + &layouts.globals, + &layouts.instances, + None, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + &shader_module, + ); + + let path_rasterization = create_pipeline( + "path_rasterization", + "vs_path_rasterization", + "fs_path_rasterization", + &layouts.globals, + &layouts.instances, + None, + wgpu::PrimitiveTopology::TriangleList, + &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(wgpu::BlendState::PREMULTIPLIED_ALPHA_BLENDING), + write_mask: wgpu::ColorWrites::ALL, + })], + path_sample_count, + &shader_module, + ); + + let paths_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::One, + operation: wgpu::BlendOperation::Add, + }, + }; + + let paths = create_pipeline( + "paths", + "vs_path", + "fs_path", + &layouts.globals, + &layouts.instances, + Some(&layouts.texture), + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(paths_blend), + write_mask: wgpu::ColorWrites::ALL, + })], + 1, + &shader_module, + ); + + let underlines = create_pipeline( + "underlines", + "vs_underline", + "fs_underline", + &layouts.globals, + &layouts.instances, + None, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + &shader_module, + ); + + let mono_sprites = create_pipeline( + "mono_sprites", + "vs_mono_sprite", + "fs_mono_sprite", + &layouts.globals, + &layouts.instances, + Some(&layouts.texture), + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + &shader_module, + ); + + let subpixel_sprites = if let Some(subpixel_module) = &subpixel_shader_module { + let subpixel_blend = wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::Src1, + dst_factor: wgpu::BlendFactor::OneMinusSrc1, + operation: wgpu::BlendOperation::Add, + }, + alpha: wgpu::BlendComponent { + src_factor: wgpu::BlendFactor::One, + dst_factor: wgpu::BlendFactor::OneMinusSrcAlpha, + operation: wgpu::BlendOperation::Add, + }, + }; + + Some(create_pipeline( + "subpixel_sprites", + "vs_subpixel_sprite", + "fs_subpixel_sprite", + &layouts.globals, + &layouts.instances, + Some(&layouts.texture), + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(wgpu::ColorTargetState { + format: surface_format, + blend: Some(subpixel_blend), + write_mask: wgpu::ColorWrites::COLOR, + })], + 1, + subpixel_module, + )) + } else { + None + }; + + let poly_sprites = create_pipeline( + "poly_sprites", + "vs_poly_sprite", + "fs_poly_sprite", + &layouts.globals, + &layouts.instances, + Some(&layouts.texture), + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target.clone())], + 1, + &shader_module, + ); + + let surfaces = create_pipeline( + "surfaces", + "vs_surface", + "fs_surface", + &layouts.globals, + &layouts.surfaces, + None, + wgpu::PrimitiveTopology::TriangleStrip, + &[Some(color_target)], + 1, + &shader_module, + ); + + WgpuPipelines { + quads, + shadows, + path_rasterization, + paths, + underlines, + mono_sprites, + subpixel_sprites, + poly_sprites, + surfaces, + } + } + + fn create_path_intermediate( + device: &wgpu::Device, + format: wgpu::TextureFormat, + width: u32, + height: u32, + ) -> (wgpu::Texture, wgpu::TextureView) { + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("path_intermediate"), + size: wgpu::Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::TEXTURE_BINDING, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + (texture, view) + } + + fn create_msaa_if_needed( + device: &wgpu::Device, + format: wgpu::TextureFormat, + width: u32, + height: u32, + sample_count: u32, + ) -> Option<(wgpu::Texture, wgpu::TextureView)> { + if sample_count <= 1 { + return None; + } + let texture = device.create_texture(&wgpu::TextureDescriptor { + label: Some("path_msaa"), + size: wgpu::Extent3d { + width: width.max(1), + height: height.max(1), + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count, + dimension: wgpu::TextureDimension::D2, + format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + Some((texture, view)) + } +} + +impl WgpuRenderer { + /// Records the new drawable size and reconfigures the surface when one is present. + /// The size is kept even without GPU resources so that recovery restores it. + pub fn update_drawable_size(&mut self, size: Size) { + let width = size.width.0 as u32; + let height = size.height.0 as u32; + + if width == self.surface_config.width && height == self.surface_config.height { + return; + } + + let clamped_width = width.min(self.max_texture_size); + let clamped_height = height.min(self.max_texture_size); + if clamped_width != width || clamped_height != height { + warn!( + "Requested surface size ({}, {}) exceeds maximum texture dimension {}. \ + Clamping to ({}, {}). Window content may not fill the entire window.", + width, height, self.max_texture_size, clamped_width, clamped_height + ); + } + self.surface_config.width = clamped_width.max(1); + self.surface_config.height = clamped_height.max(1); + + let Some(core) = self.core_mut() else { + return; + }; + let resources = &mut core.resources; + + // Wait for any in-flight GPU work to complete before destroying textures + if let Err(e) = resources.device.poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }) { + warn!("Failed to poll device during resize: {e:?}"); + } + + // Destroy old textures before allocating new ones to avoid GPU memory spikes + if let Some(ref texture) = resources.path_intermediate_texture { + texture.destroy(); + } + if let Some(ref texture) = resources.path_msaa_texture { + texture.destroy(); + } + + // Invalidate intermediate textures - they will be lazily recreated + // in draw() after we confirm the surface is healthy. This avoids + // panics when the device/surface is in an invalid state during resize. + resources.invalidate_intermediate_textures(); + + if let RendererState::Ready { surface, core } = &self.state { + surface.configure(&core.resources.device, &self.surface_config); + } + } + + pub fn set_subpixel_layout(&mut self, is_bgr: bool) { + self.is_bgr = is_bgr; + if let Some(core) = self.core_mut() { + core.is_bgr = is_bgr; + } + } + + pub fn update_transparency(&mut self, transparent: bool) { + let new_alpha_mode = if transparent { + self.transparent_alpha_mode + } else { + self.opaque_alpha_mode + }; + if new_alpha_mode == self.surface_config.alpha_mode { + return; + } + self.surface_config.alpha_mode = new_alpha_mode; + let format = self.surface_config.format; + + let Some(core) = self.core_mut() else { + return; + }; + let resources = &mut core.resources; + resources.pipelines = WgpuRendererCore::create_pipelines( + &resources.device, + &resources.bind_group_layouts, + format, + new_alpha_mode, + core.rendering_params.path_sample_count, + core.dual_source_blending, + core.uses_webgl_instance_data, + ); + + if let RendererState::Ready { surface, core } = &self.state { + surface.configure(&core.resources.device, &self.surface_config); + } + } + + #[allow(dead_code)] + pub fn viewport_size(&self) -> Size { + Size { + width: DevicePixels(self.surface_config.width as i32), + height: DevicePixels(self.surface_config.height as i32), + } + } + + pub fn sprite_atlas(&self) -> &Arc { + &self.atlas + } + + pub fn supports_dual_source_blending(&self) -> bool { + self.core().is_some_and(|core| core.dual_source_blending) + } + + /// Returns `None` once GPU resources have been released by `destroy` or a pending + /// device recovery. + pub fn gpu_specs(&self) -> Option { + let adapter_info = &self.core()?.adapter_info; + Some(GpuSpecs { + is_software_emulated: adapter_info.device_type == wgpu::DeviceType::Cpu, + device_name: adapter_info.name.clone(), + driver_name: adapter_info.driver.clone(), + driver_info: adapter_info.driver_info.clone(), + }) + } + + pub fn max_texture_size(&self) -> u32 { + self.max_texture_size + } + + pub fn draw(&mut self, scene: &Scene) -> bool { + #[cfg(target_family = "wasm")] + if self.device_lost() { + if matches!(self.state, RendererState::Ready { .. }) { + log::error!( + "Browser graphics context was lost; rendering has stopped. Reload the page to recover." + ); + self.unconfigure_surface(); + } + return false; + } + + // Bail out early if the surface has been unconfigured (e.g. during + // Android background/rotation transitions). Attempting to acquire + // a texture from an unconfigured surface can block indefinitely on + // some drivers (Adreno). + let RendererState::Ready { surface, core } = &mut self.state else { + return false; + }; + + if let Some(error) = self.last_surface_error.take().or_else(|| { + self.device_errors + .observe_error(&mut self.observed_error_generation) + }) { + self.failed_frame_count += 1; + log::error!( + "GPU error during frame (failure {} of 10): {error}", + self.failed_frame_count + ); + + // TBD. Does retrying more actually help? + if self.failed_frame_count > 10 { + panic!("Too many consecutive GPU errors. Last error: {error}"); + } else if self.failed_frame_count > 5 { + core.resources.invalidate_intermediate_textures(); + self.atlas.clear(); + self.needs_redraw = true; + self.failed_frame_count = 0; + return false; + } + } else { + self.failed_frame_count = 0; + } + + let frame = match surface.get_current_texture() { + wgpu::CurrentSurfaceTexture::Success(frame) => frame, + wgpu::CurrentSurfaceTexture::Suboptimal(frame) => { + // Textures must be destroyed before the surface can be reconfigured. + drop(frame); + surface.configure(&core.resources.device, &self.surface_config); + return false; + } + wgpu::CurrentSurfaceTexture::Lost | wgpu::CurrentSurfaceTexture::Outdated => { + surface.configure(&core.resources.device, &self.surface_config); + return false; + } + wgpu::CurrentSurfaceTexture::Timeout | wgpu::CurrentSurfaceTexture::Occluded => { + return false; + } + wgpu::CurrentSurfaceTexture::Validation => { + self.last_surface_error = Some("Surface texture validation error".to_string()); + return false; + } + }; + + // The acquired texture is the authority on frame dimensions; the surface + // configuration is only a request. + let size = Size { + width: DevicePixels(frame.texture.width() as i32), + height: DevicePixels(frame.texture.height() as i32), + }; + let frame_view = frame + .texture + .create_view(&wgpu::TextureViewDescriptor::default()); + let premultiplied_alpha = + self.surface_config.alpha_mode == wgpu::CompositeAlphaMode::PreMultiplied; + if let Err(error) = core.render_frame( + scene, + &frame_view, + size, + premultiplied_alpha, + wgpu::Color::TRANSPARENT, + ) { + log::error!("{error:#}"); + return false; + } + + frame.present(); + true + } +} + +impl WgpuRendererCore { + fn new( + context: &WgpuContext, + atlas: Arc, + target_format: wgpu::TextureFormat, + alpha_mode: wgpu::CompositeAlphaMode, + ) -> Self { + let device = Arc::clone(&context.device); + let queue = Arc::clone(&context.queue); + let rendering_params = RenderingParameters::new(&context.adapter, target_format); + let uses_webgl_instance_data = context.uses_webgl_instance_data(); + let dual_source_blending = + context.supports_dual_source_blending() && !uses_webgl_instance_data; + let bind_group_layouts = Self::create_bind_group_layouts(&device, uses_webgl_instance_data); + let pipelines = Self::create_pipelines( + &device, + &bind_group_layouts, + target_format, + alpha_mode, + rendering_params.path_sample_count, + dual_source_blending, + uses_webgl_instance_data, + ); + let atlas_sampler = device.create_sampler(&wgpu::SamplerDescriptor { + label: Some("atlas_sampler"), + mag_filter: wgpu::FilterMode::Linear, + min_filter: wgpu::FilterMode::Linear, + ..Default::default() + }); + let uniform_alignment = device.limits().min_uniform_buffer_offset_alignment as u64; + let globals_size = std::mem::size_of::() as u64; + let gamma_size = std::mem::size_of::() as u64; + let path_globals_offset = globals_size.next_multiple_of(uniform_alignment); + let gamma_offset = (path_globals_offset + globals_size).next_multiple_of(uniform_alignment); + let globals_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("globals_buffer"), + size: gamma_offset + gamma_size, + usage: wgpu::BufferUsages::UNIFORM | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + let ( + instance_data, + instance_data_capacity, + max_instance_data_size, + instance_data_alignment, + ) = if uses_webgl_instance_data { + let max_texture_dimension = device.limits().max_texture_dimension_2d; + let max_instance_data_size = (u64::from(max_texture_dimension).pow(2) + * INSTANCE_TEXTURE_TEXEL_SIZE) + .min(MAX_INSTANCE_BUFFER_SIZE); + let initial_capacity = (2 * 1024 * 1024).min(max_instance_data_size); + let (instance_data, capacity) = + Self::create_instance_texture(&device, initial_capacity, max_texture_dimension); + ( + instance_data, + capacity, + max_instance_data_size, + INSTANCE_TEXTURE_TEXEL_SIZE, + ) + } else { + let max_buffer_size = device + .limits() + .max_buffer_size + .min(device.limits().max_storage_buffer_binding_size) + .min(MAX_INSTANCE_BUFFER_SIZE); + let initial_capacity = (2 * 1024 * 1024).min(max_buffer_size); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("instance_buffer"), + size: initial_capacity, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + }); + ( + InstanceData::Storage(buffer), + initial_capacity, + max_buffer_size, + device.limits().min_storage_buffer_offset_alignment as u64, + ) + }; + let globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("globals_bind_group"), + layout: &bind_group_layouts.globals, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: 0, + size: NonZeroU64::new(globals_size), + }), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: gamma_offset, + size: NonZeroU64::new(gamma_size), + }), + }, + ], + }); + let path_globals_bind_group = device.create_bind_group(&wgpu::BindGroupDescriptor { + label: Some("path_globals_bind_group"), + layout: &bind_group_layouts.globals, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: path_globals_offset, + size: NonZeroU64::new(globals_size), + }), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &globals_buffer, + offset: gamma_offset, + size: NonZeroU64::new(gamma_size), + }), + }, + ], + }); + let max_texture_size = device.limits().max_texture_dimension_2d; + + Self { + resources: WgpuResources { + device, + queue, + pipelines, + bind_group_layouts, + atlas_sampler, + atlas_texture_bind_groups: FxHashMap::default(), + globals_buffer, + globals_bind_group, + path_globals_bind_group, + instance_data, + path_intermediate_texture: None, + path_intermediate_view: None, + path_msaa_texture: None, + path_msaa_view: None, + }, + atlas, + path_globals_offset, + gamma_offset, + instance_data_capacity, + max_instance_data_size, + instance_data_alignment, + uses_webgl_instance_data, + rendering_params, + is_bgr: false, + dual_source_blending, + adapter_info: context.adapter.get_info(), + target_format, + max_texture_size, + } + } + + fn resources(&self) -> &WgpuResources { + &self.resources + } + + fn resources_mut(&mut self) -> &mut WgpuResources { + &mut self.resources + } + + /// Path rendering goes through an intermediate texture that must match the frame + /// target's dimensions, so the textures are keyed by size and rebuilt on mismatch. + fn ensure_intermediate_textures(&mut self, size: Size) { + let width = size.width.0 as u32; + let height = size.height.0 as u32; + if self + .resources + .path_intermediate_texture + .as_ref() + .is_some_and(|texture| texture.width() == width && texture.height() == height) + { + return; + } + + let format = self.target_format; + let path_sample_count = self.rendering_params.path_sample_count; + let resources = &mut self.resources; + + let (texture, view) = + Self::create_path_intermediate(&resources.device, format, width, height); + resources.path_intermediate_texture = Some(texture); + resources.path_intermediate_view = Some(view); + + let (path_msaa_texture, path_msaa_view) = Self::create_msaa_if_needed( + &resources.device, + format, + width, + height, + path_sample_count, + ) + .map(|(texture, view)| (Some(texture), Some(view))) + .unwrap_or((None, None)); + resources.path_msaa_texture = path_msaa_texture; + resources.path_msaa_view = path_msaa_view; + } + + fn render_frame( + &mut self, + scene: &Scene, + target_view: &wgpu::TextureView, + size: Size, + premultiplied_alpha: bool, + clear_color: wgpu::Color, + ) -> Result { + anyhow::ensure!( + size.width.0 > 0 && size.height.0 > 0, + "invalid render target size: {size:?}" + ); + anyhow::ensure!( + size.width.0 as u32 <= self.max_texture_size + && size.height.0 as u32 <= self.max_texture_size, + "render target size {size:?} exceeds maximum texture dimension {}", + self.max_texture_size + ); + + self.atlas.before_frame(); + self.ensure_intermediate_textures(size); + + let gamma_params = GammaParams { + gamma_ratios: self.rendering_params.gamma_ratios, + grayscale_enhanced_contrast: self.rendering_params.grayscale_enhanced_contrast, + subpixel_enhanced_contrast: self.rendering_params.subpixel_enhanced_contrast, + is_bgr: self.is_bgr as u32, + _pad: 0, + }; + let globals = GlobalParams { + viewport_size: [size.width.0 as f32, size.height.0 as f32], + premultiplied_alpha: premultiplied_alpha as u32, + pad: 0, + }; + let path_globals = GlobalParams { + premultiplied_alpha: 0, + ..globals + }; + self.resources.queue.write_buffer( + &self.resources.globals_buffer, + 0, + bytemuck::bytes_of(&globals), + ); + self.resources.queue.write_buffer( + &self.resources.globals_buffer, + self.path_globals_offset, + bytemuck::bytes_of(&path_globals), + ); + self.resources.queue.write_buffer( + &self.resources.globals_buffer, + self.gamma_offset, + bytemuck::bytes_of(&gamma_params), + ); + + self.record_frame(scene, target_view, clear_color) + .inspect_err(|_| { + // Queue writes are staged before encoding; flush them even if the frame fails. + self.resources.queue.submit(std::iter::empty()); + }) + } + + fn record_frame( + &mut self, + scene: &Scene, + frame_view: &wgpu::TextureView, + clear_color: wgpu::Color, + ) -> Result { + let mut instance_offset = 0; + let instance_bindings = self + .write_instances(scene, &mut instance_offset) + .with_context(|| { + format!( + "scene too large: {} paths, {} shadows, {} quads, {} underlines, {} monochrome sprites, {} subpixel sprites, {} polychrome sprites", + scene.paths.len(), + scene.shadows.len(), + scene.quads.len(), + scene.underlines.len(), + scene.monochrome_sprites.len(), + scene.subpixel_sprites.len(), + scene.polychrome_sprites.len(), + ) + })?; + self.prepare_texture_bind_groups(scene); + + let mut encoder = + self.resources() + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("main_encoder"), + }); + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("main_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: frame_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(clear_color), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + + for batch in scene.batches() { + match batch { + PrimitiveBatch::Quads(range) => self.draw_instances( + &instance_bindings.quads, + &self.resources().pipelines.quads, + instance_range(range), + &mut pass, + ), + PrimitiveBatch::Shadows(range) => self.draw_instances( + &instance_bindings.shadows, + &self.resources().pipelines.shadows, + instance_range(range), + &mut pass, + ), + PrimitiveBatch::Paths(range) => { + let paths = &scene.paths[range]; + if paths.is_empty() { + continue; + } + + drop(pass); + let rasterized = self.draw_paths_to_intermediate( + &mut encoder, + paths, + &mut instance_offset, + )?; + + pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("main_pass_continued"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: frame_view, + resolve_target: None, + ops: wgpu::Operations { + load: wgpu::LoadOp::Load, + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + + if rasterized { + self.draw_paths_from_intermediate( + paths, + &mut instance_offset, + &mut pass, + )?; + } + } + PrimitiveBatch::Underlines(range) => self.draw_instances( + &instance_bindings.underlines, + &self.resources().pipelines.underlines, + instance_range(range), + &mut pass, + ), + PrimitiveBatch::MonochromeSprites { texture_id, range } => { + self.draw_sprites( + &instance_bindings.monochrome_sprites, + texture_id, + &self.resources().pipelines.mono_sprites, + instance_range(range), + &mut pass, + )?; + } + PrimitiveBatch::SubpixelSprites { texture_id, range } => { + let resources = self.resources(); + self.draw_sprites( + &instance_bindings.subpixel_sprites, + texture_id, + resources + .pipelines + .subpixel_sprites + .as_ref() + .unwrap_or(&resources.pipelines.mono_sprites), + instance_range(range), + &mut pass, + )?; + } + PrimitiveBatch::PolychromeSprites { texture_id, range } => { + self.draw_sprites( + &instance_bindings.polychrome_sprites, + texture_id, + &self.resources().pipelines.poly_sprites, + instance_range(range), + &mut pass, + )?; + } + // Surfaces are macOS-only for video playback and are not + // implemented by the WGPU renderer. + PrimitiveBatch::Surfaces(_surfaces) => {} + } + } + } + + let submission = self + .resources() + .queue + .submit(std::iter::once(encoder.finish())); + Ok(submission) + } + + fn write_instances( + &mut self, + scene: &Scene, + instance_offset: &mut u64, + ) -> Result { + Ok(InstanceBindings { + quads: self.write_instance_binding( + "quads_bind_group", + instance_offset, + &scene.quads, + )?, + shadows: self.write_instance_binding( + "shadows_bind_group", + instance_offset, + &scene.shadows, + )?, + underlines: self.write_instance_binding( + "underlines_bind_group", + instance_offset, + &scene.underlines, + )?, + monochrome_sprites: self.write_instance_binding( + "monochrome_sprites_bind_group", + instance_offset, + &scene.monochrome_sprites, + )?, + subpixel_sprites: self.write_instance_binding( + "subpixel_sprites_bind_group", + instance_offset, + &scene.subpixel_sprites, + )?, + polychrome_sprites: self.write_instance_binding( + "polychrome_sprites_bind_group", + instance_offset, + &scene.polychrome_sprites, + )?, + }) + } + + fn create_texture_bind_group( + &self, + label: &str, + texture_view: &wgpu::TextureView, + ) -> wgpu::BindGroup { + let resources = self.resources(); + resources + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(label), + layout: &resources.bind_group_layouts.texture, + entries: &[ + wgpu::BindGroupEntry { + binding: 0, + resource: wgpu::BindingResource::TextureView(texture_view), + }, + wgpu::BindGroupEntry { + binding: 1, + resource: wgpu::BindingResource::Sampler(&resources.atlas_sampler), + }, + ], + }) + } + + fn prepare_texture_bind_groups(&mut self, scene: &Scene) { + let mut texture_ids = SmallVec::<[AtlasTextureId; 8]>::new(); + for batch in scene.batches() { + let texture_id = match batch { + PrimitiveBatch::MonochromeSprites { texture_id, .. } + | PrimitiveBatch::SubpixelSprites { texture_id, .. } + | PrimitiveBatch::PolychromeSprites { texture_id, .. } => texture_id, + _ => continue, + }; + if !texture_ids.contains(&texture_id) { + texture_ids.push(texture_id); + } + } + + self.resources_mut() + .atlas_texture_bind_groups + .retain(|texture_id, _| texture_ids.contains(texture_id)); + + for texture_id in texture_ids { + let Some(texture_info) = self.atlas.get_texture_info(texture_id) else { + self.resources_mut() + .atlas_texture_bind_groups + .remove(&texture_id); + continue; + }; + let is_current = self + .resources() + .atlas_texture_bind_groups + .get(&texture_id) + .is_some_and(|cached| cached.texture_generation == texture_info.generation); + if is_current { + continue; + } + + let bind_group = + self.create_texture_bind_group("atlas_texture_bind_group", &texture_info.view); + self.resources_mut().atlas_texture_bind_groups.insert( + texture_id, + CachedTextureBindGroup { + texture_generation: texture_info.generation, + bind_group, + }, + ); + } + } + + fn draw_instances( + &self, + instances: &InstanceBinding, + pipeline: &wgpu::RenderPipeline, + range: Range, + pass: &mut wgpu::RenderPass<'_>, + ) { + if range.is_empty() { + return; + } + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &self.resources().globals_bind_group, &[]); + pass.set_bind_group(1, &instances.bind_group, &[]); + pass.draw( + 0..4, + instances.first_instance + range.start..instances.first_instance + range.end, + ); + } + + fn draw_sprites( + &self, + sprite_instances: &InstanceBinding, + texture_id: AtlasTextureId, + pipeline: &wgpu::RenderPipeline, + range: Range, + pass: &mut wgpu::RenderPass<'_>, + ) -> Result<()> { + if range.is_empty() { + return Ok(()); + } + let resources = self.resources(); + // The atlas has released this texture; the batch belongs to a stale + // paint that will be replaced once its view re-renders. + let Some(texture) = resources.atlas_texture_bind_groups.get(&texture_id) else { + return Ok(()); + }; + pass.set_pipeline(pipeline); + pass.set_bind_group(0, &resources.globals_bind_group, &[]); + pass.set_bind_group(1, &sprite_instances.bind_group, &[]); + pass.set_bind_group(2, &texture.bind_group, &[]); + pass.draw( + 0..4, + sprite_instances.first_instance + range.start + ..sprite_instances.first_instance + range.end, + ); + Ok(()) + } + + unsafe fn instance_bytes(instances: &[T]) -> &[u8] { + unsafe { + std::slice::from_raw_parts( + instances.as_ptr() as *const u8, + std::mem::size_of_val(instances), + ) + } + } + + fn draw_paths_from_intermediate( + &mut self, + paths: &[Path], + instance_offset: &mut u64, + pass: &mut wgpu::RenderPass<'_>, + ) -> Result<()> { + let first_path = &paths[0]; + let sprites: Vec = if paths.last().map(|p| &p.order) == Some(&first_path.order) + { + paths + .iter() + .map(|p| PathSprite { + bounds: p.clipped_bounds(), + }) + .collect() + } else { + let mut bounds = first_path.clipped_bounds(); + for path in paths.iter().skip(1) { + bounds = bounds.union(&path.clipped_bounds()); + } + vec![PathSprite { bounds }] + }; + + let Some(path_intermediate_view) = self.resources().path_intermediate_view.clone() else { + return Ok(()); + }; + let instances = + self.write_instance_binding("path_sprites_bind_group", instance_offset, &sprites)?; + let texture = self.create_texture_bind_group( + "path_intermediate_texture_bind_group", + &path_intermediate_view, + ); + let resources = self.resources(); + pass.set_pipeline(&resources.pipelines.paths); + pass.set_bind_group(0, &resources.globals_bind_group, &[]); + pass.set_bind_group(1, &instances.bind_group, &[]); + pass.set_bind_group(2, &texture, &[]); + pass.draw( + 0..4, + instances.first_instance..instances.first_instance + sprites.len() as u32, + ); + Ok(()) + } + + fn draw_paths_to_intermediate( + &mut self, + encoder: &mut wgpu::CommandEncoder, + paths: &[Path], + instance_offset: &mut u64, + ) -> Result { + let mut vertices = Vec::new(); + for path in paths { + let bounds = path.clipped_bounds(); + vertices.extend(path.vertices.iter().map(|v| PathRasterizationVertex { + xy_position: v.xy_position, + st_position: v.st_position, + color: path.color, + bounds, + })); + } + + if vertices.is_empty() { + return Ok(false); + } + + let vertex_binding = self.write_instance_binding( + "path_rasterization_bind_group", + instance_offset, + &vertices, + )?; + + let resources = self.resources(); + let Some(path_intermediate_view) = resources.path_intermediate_view.as_ref() else { + return Ok(false); + }; + + let (target_view, resolve_target) = if let Some(ref msaa_view) = resources.path_msaa_view { + (msaa_view, Some(path_intermediate_view)) + } else { + (path_intermediate_view, None) + }; + + { + let mut pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: Some("path_rasterization_pass"), + color_attachments: &[Some(wgpu::RenderPassColorAttachment { + view: target_view, + resolve_target, + ops: wgpu::Operations { + load: wgpu::LoadOp::Clear(wgpu::Color::TRANSPARENT), + store: wgpu::StoreOp::Store, + }, + depth_slice: None, + })], + depth_stencil_attachment: None, + ..Default::default() + }); + + pass.set_pipeline(&resources.pipelines.path_rasterization); + pass.set_bind_group(0, &resources.path_globals_bind_group, &[]); + pass.set_bind_group(1, &vertex_binding.bind_group, &[]); + // The path rasterization shader loads records by vertex index + // rather than instance index, so the allocation's base shifts the + // vertex range here. + pass.draw( + vertex_binding.first_instance + ..vertex_binding.first_instance + vertices.len() as u32, + 0..1, + ); + } + + Ok(true) + } + + fn write_instance_binding( + &mut self, + label: &str, + instance_offset: &mut u64, + instances: &[T], + ) -> Result { + let data = unsafe { Self::instance_bytes(instances) }; + // wgpu rejects zero-sized bindings, so empty primitive arrays still + // reserve the 16-byte minimum. + let size = (data.len() as u64).max(16); + let stride = (std::mem::size_of::() as u64).max(1); + let (alignment, allocation_size) = if self.uses_webgl_instance_data { + // The texture transport has no binding offset: the shader indexes + // the instance texture absolutely, so each allocation must start on + // a whole instance (a stride multiple) and a whole texel, and must + // end on a texel boundary so the zero padding of its final partial + // texel cannot overlap the next allocation. + ( + least_common_multiple(self.instance_data_alignment, stride), + size.next_multiple_of(INSTANCE_TEXTURE_TEXEL_SIZE), + ) + } else { + (self.instance_data_alignment.max(1), size) + }; + let mut offset = (*instance_offset).next_multiple_of(alignment); + if offset + allocation_size > self.instance_data_capacity { + self.grow_instance_data(allocation_size)?; + offset = 0; + } + *instance_offset = offset + allocation_size; + + let first_instance = if self.uses_webgl_instance_data { + u32::try_from(offset / stride).context("instance index exceeds u32 range")? + } else { + 0 + }; + + let resources = self.resources(); + if !data.is_empty() { + match &resources.instance_data { + InstanceData::Storage(buffer) => resources.queue.write_buffer(buffer, offset, data), + InstanceData::Texture { .. } => { + Self::write_instance_texture(resources, offset, data) + } + } + } + let bind_group = resources + .device + .create_bind_group(&wgpu::BindGroupDescriptor { + label: Some(label), + layout: &resources.bind_group_layouts.instances, + entries: &[wgpu::BindGroupEntry { + binding: 0, + resource: match &resources.instance_data { + InstanceData::Storage(buffer) => { + wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer, + offset, + size: NonZeroU64::new(size), + }) + } + InstanceData::Texture { view, .. } => { + wgpu::BindingResource::TextureView(view) + } + }, + }], + }); + Ok(InstanceBinding { + bind_group, + first_instance, + }) + } + + fn write_instance_texture(resources: &WgpuResources, offset: u64, data: &[u8]) { + let InstanceData::Texture { + texture, + width, + height, + .. + } = &resources.instance_data + else { + return; + }; + let mut byte_offset = 0usize; + let mut texel_offset = offset / INSTANCE_TEXTURE_TEXEL_SIZE; + while byte_offset < data.len() { + let x = (texel_offset % u64::from(*width)) as u32; + let y = (texel_offset / u64::from(*width)) as u32; + if y >= *height { + // The capacity check in write_instance_binding should make this + // unreachable. Truncating silently would leave stale bytes in the + // texture and draw garbage for the remaining instances. + debug_assert!( + false, + "instance texture write out of bounds: row {y} >= height {}", + *height + ); + log::error!( + "instance texture write out of bounds; dropping {} bytes of instance data", + data.len() - byte_offset + ); + return; + } + let available_texels = u64::from(*width - x); + let remaining_bytes = data.len() - byte_offset; + let complete_texels = remaining_bytes as u64 / INSTANCE_TEXTURE_TEXEL_SIZE; + let texels = complete_texels.min(available_texels); + if texels > 0 { + let byte_count = (texels * INSTANCE_TEXTURE_TEXEL_SIZE) as usize; + resources.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d { x, y, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + &data[byte_offset..byte_offset + byte_count], + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(byte_count as u32), + rows_per_image: None, + }, + wgpu::Extent3d { + width: texels as u32, + height: 1, + depth_or_array_layers: 1, + }, + ); + byte_offset += byte_count; + texel_offset += texels; + continue; + } + + let mut final_texel = [0; INSTANCE_TEXTURE_TEXEL_SIZE as usize]; + final_texel[..remaining_bytes].copy_from_slice(&data[byte_offset..]); + resources.queue.write_texture( + wgpu::TexelCopyTextureInfo { + texture, + mip_level: 0, + origin: wgpu::Origin3d { x, y, z: 0 }, + aspect: wgpu::TextureAspect::All, + }, + &final_texel, + wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(INSTANCE_TEXTURE_TEXEL_SIZE as u32), + rows_per_image: None, + }, + wgpu::Extent3d { + width: 1, + height: 1, + depth_or_array_layers: 1, + }, + ); + break; + } + } + + fn grow_instance_data(&mut self, required: u64) -> Result<()> { + let capacity = (self.instance_data_capacity * 2) + .max(required.next_power_of_two()) + .min(self.max_instance_data_size); + anyhow::ensure!( + capacity >= required, + "instance data needs {required} bytes, above the maximum of {}", + self.max_instance_data_size + ); + anyhow::ensure!( + capacity > self.instance_data_capacity, + "frame instance data exceeds the {}-byte maximum", + self.max_instance_data_size + ); + log::debug!( + "instance data grown from {} to {capacity}", + self.instance_data_capacity + ); + // Bind groups created earlier in the frame keep the previous buffer or + // texture alive, so allocations written before the grow remain valid; + // only subsequent writes land in the new allocation. + let uses_webgl_instance_data = self.uses_webgl_instance_data; + let resources = self.resources_mut(); + if uses_webgl_instance_data { + let max_texture_dimension = resources.device.limits().max_texture_dimension_2d; + let (instance_data, actual_capacity) = + Self::create_instance_texture(&resources.device, capacity, max_texture_dimension); + resources.instance_data = instance_data; + self.instance_data_capacity = actual_capacity; + } else { + resources.instance_data = + InstanceData::Storage(resources.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("instance_buffer"), + size: capacity, + usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_DST, + mapped_at_creation: false, + })); + self.instance_data_capacity = capacity; + } + Ok(()) + } +} + +impl WgpuRenderer { + /// Mark the surface as unconfigured so rendering is skipped until a new + /// surface is provided via [`replace_surface`](Self::replace_surface). + /// + /// This does **not** drop the renderer — the device, queue, atlas, and + /// pipelines stay alive. Use this when the native window is destroyed + /// (e.g. Android `TerminateWindow`) but you intend to re-create the + /// surface later without losing cached atlas textures. + pub fn unconfigure_surface(&mut self) { + self.state = match std::mem::replace(&mut self.state, RendererState::Released) { + RendererState::Ready { core, surface } => { + drop(surface); + RendererState::Unconfigured { core } + } + state @ (RendererState::Unconfigured { .. } | RendererState::Released) => state, + }; + // Drop intermediate textures since they reference the old surface size. + if let Some(core) = self.core_mut() { + core.resources.invalidate_intermediate_textures(); + } + } + + /// Replace the wgpu surface with a new one (e.g. after Android destroys + /// and recreates the native window). Keeps the device, queue, atlas, and + /// all pipelines intact so cached `AtlasTextureId`s remain valid. + /// + /// The `instance` **must** be the same [`wgpu::Instance`] that was used to + /// create the adapter and device (i.e. from the [`WgpuContext`]). Using a + /// different instance will cause a "Device does not exist" panic because + /// the wgpu device is bound to its originating instance. + #[cfg(not(target_family = "wasm"))] + pub fn replace_surface( + &mut self, + window: &W, + config: WgpuSurfaceConfig, + instance: &wgpu::Instance, + ) -> anyhow::Result<()> { + let window_handle = window + .window_handle() + .map_err(|e| anyhow::anyhow!("Failed to get window handle: {e}"))?; + + let surface = create_surface(instance, window_handle.as_raw())?; + + let width = (config.size.width.0 as u32).max(1); + let height = (config.size.height.0 as u32).max(1); + + let alpha_mode = if config.transparent { + self.transparent_alpha_mode + } else { + self.opaque_alpha_mode + }; + + self.surface_config.width = width; + self.surface_config.height = height; + self.surface_config.alpha_mode = alpha_mode; + if let Some(mode) = config.preferred_present_mode { + self.surface_config.present_mode = mode; + } + + let mut core = match std::mem::replace(&mut self.state, RendererState::Released) { + RendererState::Ready { + core, + surface: old_surface, + } => { + drop(old_surface); + core + } + RendererState::Unconfigured { core } => core, + RendererState::Released => { + anyhow::bail!("Cannot replace the surface: GPU resources have been released") + } + }; + surface.configure(&core.resources.device, &self.surface_config); + core.resources.invalidate_intermediate_textures(); + self.state = RendererState::Ready { surface, core }; + + Ok(()) + } + + pub fn destroy(&mut self) { + // Release surface-bound GPU resources eagerly so the underlying native + // window can be destroyed before the renderer itself is dropped. + self.state = RendererState::Released; + } + + /// Returns true if the GPU device was lost and recovery is needed. + pub fn device_lost(&self) -> bool { + self.device_errors.device_lost() + } + + /// Returns true if a redraw is needed because GPU state was cleared. + /// Calling this method clears the flag. + pub fn needs_redraw(&mut self) -> bool { + std::mem::take(&mut self.needs_redraw) + } + + /// Recovers from a lost GPU device by recreating the renderer with a new context. + /// + /// Call this after detecting `device_lost()` returns true. + /// + /// This method coordinates recovery across multiple windows: + /// - The first window to call this will recreate the shared context + /// - Subsequent windows will adopt the already-recovered context + #[cfg(not(target_family = "wasm"))] + pub fn recover(&mut self, window: &W) -> anyhow::Result<()> + where + W: HasWindowHandle + HasDisplayHandle + std::fmt::Debug + Send + Sync + Clone + 'static, + { + let gpu_context = self.context.as_ref().expect("recover requires gpu_context"); + + // Check if another window already recovered the context + let needs_new_context = gpu_context + .borrow() + .as_ref() + .is_none_or(|ctx| ctx.device_lost()); + + let window_handle = window + .window_handle() + .map_err(|e| anyhow::anyhow!("Failed to get window handle: {e}"))?; + + let surface = if needs_new_context { + log::warn!("GPU device lost, recreating context..."); + + // Drop old resources to release Arc/Arc and GPU resources + self.state = RendererState::Released; + *gpu_context.borrow_mut() = None; + + // Wait briefly for the GPU driver to stabilize, then try to + // recreate the context without software renderers. If this fails + // the caller should request another frame and retry — the real GPU + // may need more time to come back (e.g. after suspend/resume). + std::thread::sleep(std::time::Duration::from_millis(350)); + + let instance = WgpuContext::instance(Some(Box::new(window.clone()))); + let surface = create_surface(&instance, window_handle.as_raw())?; + let new_context = + WgpuContext::new_rejecting_software(instance, &surface, self.compositor_gpu)?; + *gpu_context.borrow_mut() = Some(new_context); + surface + } else { + let ctx_ref = gpu_context.borrow(); + let instance = &ctx_ref.as_ref().unwrap().instance; + create_surface(instance, window_handle.as_raw())? + }; + + let config = WgpuSurfaceConfig { + size: gpui::Size { + width: gpui::DevicePixels(self.surface_config.width as i32), + height: gpui::DevicePixels(self.surface_config.height as i32), + }, + transparent: self.surface_config.alpha_mode != wgpu::CompositeAlphaMode::Opaque, + preferred_present_mode: Some(self.surface_config.present_mode), + }; + let gpu_context = Rc::clone(gpu_context); + let ctx_ref = gpu_context.borrow(); + let context = ctx_ref.as_ref().expect("context should exist"); + + self.state = RendererState::Released; + self.atlas.handle_device_lost(context); + + let is_bgr = self.is_bgr; + *self = Self::new_internal( + Some(gpu_context.clone()), + context, + surface, + config, + self.compositor_gpu, + self.atlas.clone(), + )?; + self.set_subpixel_layout(is_bgr); + + log::info!("GPU recovery complete"); + Ok(()) + } +} + +#[cfg(all( + not(target_family = "wasm"), + any( + test, + feature = "bench-support", + feature = "test-support", + feature = "offscreen" + ) +))] +struct HeadlessRenderTarget { + texture: wgpu::Texture, + view: wgpu::TextureView, +} + +#[cfg(all( + not(target_family = "wasm"), + any( + test, + feature = "bench-support", + feature = "test-support", + feature = "offscreen" + ) +))] +impl HeadlessRenderTarget { + fn size(&self) -> Size { + Size { + width: DevicePixels(self.texture.width() as i32), + height: DevicePixels(self.texture.height() as i32), + } + } +} + +#[cfg(all( + not(target_family = "wasm"), + any( + test, + feature = "bench-support", + feature = "test-support", + feature = "offscreen" + ) +))] +pub struct WgpuHeadlessRenderer { + context: WgpuContext, + core: WgpuRendererCore, + render_target: Option, + observed_error_generation: u64, +} + +#[cfg(all( + not(target_family = "wasm"), + any( + test, + feature = "bench-support", + feature = "test-support", + feature = "offscreen" + ) +))] +impl WgpuHeadlessRenderer { + pub fn new() -> anyhow::Result { + let (context, target_format) = WgpuContext::new_headless()?; + Ok(Self::from_headless_context(context, target_format)) + } + + /// Creates a renderer on a device from `instance` rather than the default + /// Vulkan + GL instance, which has no adapter on iOS: pass a Metal instance there. + /// Unlike [`Self::new`], the device is not shared with other renderers. + #[cfg(feature = "offscreen")] + pub fn with_instance(instance: wgpu::Instance) -> anyhow::Result { + let (context, target_format) = WgpuContext::new_headless_with_instance(instance)?; + Ok(Self::from_headless_context(context, target_format)) + } + + /// Renders `scene` into an offscreen texture of `size` and reads it back. + #[cfg(feature = "offscreen")] + pub fn render_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.render(scene, size)?; + self.read_image() + } + + /// The atlas glyphs and images are rasterized into; a window drawing through + /// this renderer must hand it to GPUI as its sprite atlas. + #[cfg(feature = "offscreen")] + pub fn sprite_atlas(&self) -> Arc { + self.core.atlas.clone() + } + + /// The largest width or height, in device pixels, one render can target. + #[cfg(feature = "offscreen")] + pub fn max_texture_size(&self) -> u32 { + self.core.max_texture_size + } + + fn from_headless_context(context: WgpuContext, target_format: wgpu::TextureFormat) -> Self { + // The device may be shared with earlier renderers; observe only errors raised from + // here on, including any while this renderer builds its pipelines. + let observed_error_generation = context.errors().current_generation(); + let atlas = Arc::new(WgpuAtlas::from_context(&context)); + let core = WgpuRendererCore::new( + &context, + atlas, + target_format, + wgpu::CompositeAlphaMode::Opaque, + ); + + Self { + context, + core, + render_target: None, + observed_error_generation, + } + } + + fn ensure_render_target(&mut self, size: Size) -> anyhow::Result<()> { + anyhow::ensure!( + size.width.0 > 0 && size.height.0 > 0, + "invalid headless render target size: {size:?}" + ); + anyhow::ensure!( + size.width.0 as u32 <= self.core.max_texture_size + && size.height.0 as u32 <= self.core.max_texture_size, + "headless render target size {size:?} exceeds maximum texture dimension {}", + self.core.max_texture_size + ); + if self + .render_target + .as_ref() + .is_some_and(|target| target.size() == size) + { + return Ok(()); + } + + let texture = self + .core + .resources + .device + .create_texture(&wgpu::TextureDescriptor { + label: Some("headless_render_target"), + size: wgpu::Extent3d { + width: size.width.0 as u32, + height: size.height.0 as u32, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.core.target_format, + usage: wgpu::TextureUsages::RENDER_ATTACHMENT | wgpu::TextureUsages::COPY_SRC, + view_formats: &[], + }); + let view = texture.create_view(&wgpu::TextureViewDescriptor::default()); + self.render_target = Some(HeadlessRenderTarget { texture, view }); + Ok(()) + } + + fn check_gpu_errors(&mut self) -> anyhow::Result<()> { + anyhow::ensure!( + !self.context.device_lost(), + "GPU device was lost during headless rendering" + ); + if let Some(error) = self + .context + .errors() + .observe_error(&mut self.observed_error_generation) + { + anyhow::bail!("GPU error during headless rendering: {error}"); + } + Ok(()) + } + + fn render(&mut self, scene: &Scene, size: Size) -> anyhow::Result<()> { + self.check_gpu_errors()?; + self.ensure_render_target(size)?; + let view = self + .render_target + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Headless render target was not created"))? + .view + .clone(); + self.core + .render_frame(scene, &view, size, false, wgpu::Color::BLACK)?; + Ok(()) + } + + /// Copies the current render target back to the CPU. Dimensions come from the + /// target texture itself, so the copy can never disagree with what was rendered. + fn read_image(&mut self) -> anyhow::Result { + let target = self + .render_target + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Headless render target was not created"))?; + let width = target.texture.width(); + let height = target.texture.height(); + let bytes_per_row = width + .checked_mul(4) + .ok_or_else(|| anyhow::anyhow!("Headless render target row size overflowed"))?; + let padded_bytes_per_row = bytes_per_row + .checked_next_multiple_of(wgpu::COPY_BYTES_PER_ROW_ALIGNMENT) + .ok_or_else(|| anyhow::anyhow!("Headless padded row size overflowed"))?; + let buffer_size = u64::from(padded_bytes_per_row) + .checked_mul(u64::from(height)) + .ok_or_else(|| anyhow::anyhow!("Headless readback buffer size overflowed"))?; + anyhow::ensure!( + buffer_size <= self.core.resources.device.limits().max_buffer_size, + "Headless readback buffer size {buffer_size} exceeds maximum buffer size {}", + self.core.resources.device.limits().max_buffer_size + ); + let readback_buffer = self + .core + .resources + .device + .create_buffer(&wgpu::BufferDescriptor { + label: Some("headless_readback_buffer"), + size: buffer_size, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = + self.core + .resources + .device + .create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("headless_readback_encoder"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &target.texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &readback_buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(padded_bytes_per_row), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + let submission = self + .core + .resources + .queue + .submit(std::iter::once(encoder.finish())); + let (sender, receiver) = std::sync::mpsc::channel(); + readback_buffer + .slice(..) + .map_async(wgpu::MapMode::Read, move |result| { + if sender.send(result).is_err() { + log::error!("Headless readback receiver was dropped before mapping completed"); + } + }); + self.core + .resources + .device + .poll(wgpu::PollType::Wait { + submission_index: Some(submission), + timeout: Some(std::time::Duration::from_secs(30)), + }) + .map_err(|error| anyhow::anyhow!("Failed to wait for headless rendering: {error}"))?; + receiver + .recv_timeout(std::time::Duration::from_secs(30)) + .map_err(|error| anyhow::anyhow!("Failed to receive headless mapping result: {error}"))? + .map_err(|error| anyhow::anyhow!("Failed to map headless readback buffer: {error}"))?; + self.check_gpu_errors()?; + + let mapped_data = readback_buffer.slice(..).get_mapped_range(); + let pixel_capacity = usize::try_from(u64::from(bytes_per_row) * u64::from(height)) + .map_err(|_| anyhow::anyhow!("Headless image size exceeds addressable memory"))?; + let mut pixels = Vec::with_capacity(pixel_capacity); + for row in mapped_data + .chunks_exact(padded_bytes_per_row as usize) + .take(height as usize) + { + pixels.extend_from_slice(&row[..bytes_per_row as usize]); + } + drop(mapped_data); + readback_buffer.unmap(); + + if self.core.target_format == wgpu::TextureFormat::Bgra8Unorm { + for pixel in pixels.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + } + + image::RgbaImage::from_raw(width, height, pixels) + .ok_or_else(|| anyhow::anyhow!("Failed to create image from headless pixel data")) + } +} + +#[cfg(all( + not(target_family = "wasm"), + any(test, feature = "bench-support", feature = "test-support") +))] +impl gpui::PlatformHeadlessRenderer for WgpuHeadlessRenderer { + fn render_scene_to_image( + &mut self, + scene: &Scene, + size: Size, + ) -> anyhow::Result { + self.render(scene, size)?; + self.read_image() + } + + fn render_scene(&mut self, scene: &Scene, size: Size) -> anyhow::Result<()> { + self.render(scene, size) + } + + fn sprite_atlas(&self) -> Arc { + self.core.atlas.clone() + } +} + +fn instance_range(range: Range) -> Range { + range.start as u32..range.end as u32 +} + +#[cfg(not(target_family = "wasm"))] +fn create_surface( + instance: &wgpu::Instance, + raw_window_handle: raw_window_handle::RawWindowHandle, +) -> anyhow::Result> { + unsafe { + instance + .create_surface_unsafe(wgpu::SurfaceTargetUnsafe::RawHandle { + // Fall back to the display handle already provided via InstanceDescriptor::display. + raw_display_handle: None, + raw_window_handle, + }) + .map_err(|e| anyhow::anyhow!("{e}")) + } +} + +struct RenderingParameters { + path_sample_count: u32, + gamma_ratios: [f32; 4], + grayscale_enhanced_contrast: f32, + subpixel_enhanced_contrast: f32, +} + +impl RenderingParameters { + fn new(adapter: &wgpu::Adapter, surface_format: wgpu::TextureFormat) -> Self { + use std::env; + + let format_features = adapter.get_texture_format_features(surface_format); + let path_sample_count = [4, 2, 1] + .into_iter() + .find(|&n| format_features.flags.sample_count_supported(n)) + .unwrap_or(1); + + let gamma = env::var("ZED_FONTS_GAMMA") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1.8_f32) + .clamp(1.0, 2.2); + let gamma_ratios = get_gamma_correction_ratios(gamma); + + let grayscale_enhanced_contrast = env::var("ZED_FONTS_GRAYSCALE_ENHANCED_CONTRAST") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(1.0_f32) + .max(0.0); + + let subpixel_enhanced_contrast = env::var("ZED_FONTS_SUBPIXEL_ENHANCED_CONTRAST") + .ok() + .and_then(|v| v.parse().ok()) + .unwrap_or(0.5_f32) + .max(0.0); + + Self { + path_sample_count, + gamma_ratios, + grayscale_enhanced_contrast, + subpixel_enhanced_contrast, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use gpui::{ + BorderStyle, ColorSpace, ContentMask, Corners, Edges, Hsla, MonochromeSprite, + PolychromeSprite, Quad, Shadow, Size, SubpixelSprite, Underline, linear_color_stop, + linear_gradient, + }; + #[cfg(target_os = "linux")] + use gpui::{DevicePixels, PlatformHeadlessRenderer, Scene}; + + #[cfg(target_os = "linux")] + fn device_size(width: i32, height: i32) -> Size { + Size { + width: DevicePixels(width), + height: DevicePixels(height), + } + } + + #[cfg(target_os = "linux")] + fn solid_quad(x: f32, y: f32, width: f32, height: f32, color: Hsla) -> Quad { + let bounds = Bounds { + origin: Point { + x: x.into(), + y: y.into(), + }, + size: Size { + width: width.into(), + height: height.into(), + }, + }; + Quad { + order: 0, + border_style: BorderStyle::Solid, + bounds, + content_mask: ContentMask { bounds }, + background: color.into(), + border_color: color, + corner_radii: Corners::default(), + border_widths: Edges::default(), + } + } + + /// Channels are compared with a small tolerance so the assertions hold across + /// drivers without pinning exact rasterizer output. + #[cfg(target_os = "linux")] + fn assert_pixel(image: &image::RgbaImage, x: u32, y: u32, expected: [u8; 4]) { + let actual = image.get_pixel(x, y).0; + assert!( + actual + .iter() + .zip(expected) + .all(|(actual, expected)| actual.abs_diff(expected) <= 3), + "pixel ({x}, {y}) was {actual:?}, expected {expected:?}" + ); + } + + #[cfg(target_os = "linux")] + const RED: [u8; 4] = [255, 0, 0, 255]; + #[cfg(target_os = "linux")] + const BLUE: [u8; 4] = [0, 0, 255, 255]; + #[cfg(target_os = "linux")] + const BLACK: [u8; 4] = [0, 0, 0, 255]; + + #[cfg(target_os = "linux")] + #[test] + fn headless_renderer_draws_quads_with_distinct_colors() -> anyhow::Result<()> { + let mut renderer = WgpuHeadlessRenderer::new()?; + let mut scene = Scene::default(); + scene.insert_primitive(solid_quad(0.0, 0.0, 32.0, 32.0, gpui::red())); + scene.insert_primitive(solid_quad(32.0, 0.0, 32.0, 32.0, gpui::blue())); + scene.finish(); + + let image = renderer.render_scene_to_image(&scene, device_size(64, 32))?; + assert_eq!(image.dimensions(), (64, 32)); + assert_pixel(&image, 8, 16, RED); + assert_pixel(&image, 24, 16, RED); + assert_pixel(&image, 40, 16, BLUE); + assert_pixel(&image, 56, 16, BLUE); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn headless_renderer_captures_each_requested_size() -> anyhow::Result<()> { + let mut renderer = WgpuHeadlessRenderer::new()?; + let mut scene = Scene::default(); + scene.insert_primitive(solid_quad(2.0, 2.0, 4.0, 3.0, gpui::red())); + scene.finish(); + + // 13 px rows are 52 bytes, forcing readback to strip copy-row padding. + let image = renderer.render_scene_to_image(&scene, device_size(13, 7))?; + assert_eq!(image.dimensions(), (13, 7)); + assert_pixel(&image, 0, 0, BLACK); + assert_pixel(&image, 3, 3, RED); + assert_pixel(&image, 12, 6, BLACK); + + let image = renderer.render_scene_to_image(&scene, device_size(17, 9))?; + assert_eq!(image.dimensions(), (17, 9)); + assert_pixel(&image, 3, 3, RED); + assert_pixel(&image, 16, 8, BLACK); + + let image = renderer.render_scene_to_image(&Scene::default(), device_size(13, 7))?; + assert_eq!(image.dimensions(), (13, 7)); + assert!(image.pixels().all(|pixel| pixel.0 == BLACK)); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn headless_renderer_reuses_target_for_same_size() -> anyhow::Result<()> { + let mut renderer = WgpuHeadlessRenderer::new()?; + let target_texture = |renderer: &WgpuHeadlessRenderer| { + renderer + .render_target + .as_ref() + .map(|target| target.texture.clone()) + }; + + renderer.render_scene(&Scene::default(), device_size(16, 16))?; + let first = target_texture(&renderer); + assert!(first.is_some()); + + renderer.render_scene(&Scene::default(), device_size(16, 16))?; + assert_eq!(target_texture(&renderer), first); + + renderer.render_scene(&Scene::default(), device_size(16, 17))?; + assert_ne!(target_texture(&renderer), first); + Ok(()) + } + + #[cfg(target_os = "linux")] + #[test] + fn headless_renderer_rejects_invalid_sizes() -> anyhow::Result<()> { + let mut renderer = WgpuHeadlessRenderer::new()?; + let too_large = renderer.core.max_texture_size as i32 + 1; + + for size in [ + device_size(0, 8), + device_size(8, 0), + device_size(-1, 8), + device_size(too_large, 8), + ] { + assert!( + renderer.render_scene(&Scene::default(), size).is_err(), + "{size:?} should be rejected" + ); + assert!( + renderer + .render_scene_to_image(&Scene::default(), size) + .is_err(), + "{size:?} should be rejected" + ); + } + + // Rejection must leave the renderer usable. + let image = renderer.render_scene_to_image(&Scene::default(), device_size(4, 4))?; + assert_eq!(image.dimensions(), (4, 4)); + Ok(()) + } + + #[test] + fn webgl_shader_is_valid_wgsl_without_storage_buffers() { + assert!(!WEBGL_SHADERS.contains("var(), 40 * 4); + assert_eq!(std::mem::size_of::(), 28 * 4); + assert_eq!(std::mem::size_of::(), 26 * 4); + assert_eq!(std::mem::size_of::(), 4 * 4); + assert_eq!(std::mem::size_of::(), 16 * 4); + assert_eq!(std::mem::size_of::(), 28 * 4); + assert_eq!(std::mem::size_of::(), 28 * 4); + assert_eq!(std::mem::size_of::(), 24 * 4); + } + + #[test] + fn webgl_quad_layout_matches_fixed_decoder() { + let quad = Quad { + order: 41, + border_style: BorderStyle::Dashed, + bounds: Bounds { + origin: Point { + x: 1.0.into(), + y: 2.0.into(), + }, + size: Size { + width: 3.0.into(), + height: 4.0.into(), + }, + }, + content_mask: ContentMask { + bounds: Bounds { + origin: Point { + x: 5.0.into(), + y: 6.0.into(), + }, + size: Size { + width: 7.0.into(), + height: 8.0.into(), + }, + }, + }, + background: linear_gradient( + 11.0, + linear_color_stop( + Hsla { + h: 12.0, + s: 13.0, + l: 14.0, + a: 15.0, + }, + 16.0, + ), + linear_color_stop( + Hsla { + h: 17.0, + s: 18.0, + l: 19.0, + a: 20.0, + }, + 21.0, + ), + ) + .color_space(ColorSpace::Oklab), + border_color: Hsla { + h: 22.0, + s: 23.0, + l: 24.0, + a: 25.0, + }, + corner_radii: Corners { + top_left: 26.0.into(), + top_right: 27.0.into(), + bottom_right: 28.0.into(), + bottom_left: 29.0.into(), + }, + border_widths: Edges { + top: 30.0.into(), + right: 31.0.into(), + bottom: 32.0.into(), + left: 33.0.into(), + }, + }; + + let bytes = unsafe { WgpuRendererCore::instance_bytes(std::slice::from_ref(&quad)) }; + let words: &[u32] = bytemuck::cast_slice(bytes); + assert_eq!( + words, + &[ + 41, + 1, + 1.0_f32.to_bits(), + 2.0_f32.to_bits(), + 3.0_f32.to_bits(), + 4.0_f32.to_bits(), + 5.0_f32.to_bits(), + 6.0_f32.to_bits(), + 7.0_f32.to_bits(), + 8.0_f32.to_bits(), + 1, + 1, + 0, + 0, + 0, + 0, + 11.0_f32.to_bits(), + 12.0_f32.to_bits(), + 13.0_f32.to_bits(), + 14.0_f32.to_bits(), + 15.0_f32.to_bits(), + 16.0_f32.to_bits(), + 17.0_f32.to_bits(), + 18.0_f32.to_bits(), + 19.0_f32.to_bits(), + 20.0_f32.to_bits(), + 21.0_f32.to_bits(), + 0, + 22.0_f32.to_bits(), + 23.0_f32.to_bits(), + 24.0_f32.to_bits(), + 25.0_f32.to_bits(), + 26.0_f32.to_bits(), + 27.0_f32.to_bits(), + 28.0_f32.to_bits(), + 29.0_f32.to_bits(), + 30.0_f32.to_bits(), + 31.0_f32.to_bits(), + 32.0_f32.to_bits(), + 33.0_f32.to_bits(), + ] + ); + } +}