diff --git a/apps/demo/src/plugin-demos/canvas-offscreen-worker.ts b/apps/demo/src/plugin-demos/canvas-offscreen-worker.ts new file mode 100644 index 000000000..b09fad2c1 --- /dev/null +++ b/apps/demo/src/plugin-demos/canvas-offscreen-worker.ts @@ -0,0 +1,45 @@ +import { EventData, Observable, Page } from '@nativescript/core'; +import { Canvas, OffscreenCanvas } from '@nativescript/canvas'; + +declare const Worker: any; + +// Two views drawn by a Worker: a transparent 2D canvas over the page's tint, and an opaque WebGL one. +export function navigatingTo(args: EventData) { + const page = args.object; + page.bindingContext = new OffscreenWorkerModel(); +} + +class OffscreenWorkerModel extends Observable { + private worker: any = null; + private sent = new Set(); + + constructor() { + super(); + this.set('status', 'waiting for the canvases…'); + } + + canvasLoaded(args: EventData) { + const canvas = args.object as Canvas; + const kind = canvas.id === 'webgl' ? 'webgl' : '2d'; + if (this.sent.has(kind)) { + return; + } + this.sent.add(kind); + this.worker ??= this.startWorker(); + const offscreen = canvas.transferControlToOffscreen(); + const transferable = typeof (global as any).__nsRegisterTransferable === 'function'; + // Runtimes without transfer hooks take a handle instead. + const message = transferable ? { kind, canvas: offscreen } : { kind, handle: OffscreenCanvas._toHandle(offscreen) }; + this.worker.postMessage(message, transferable ? [offscreen] : undefined); + } + + private startWorker() { + const worker = new Worker('./canvas-offscreen-worker.worker.ts'); + worker.onmessage = (event: any) => { + const { kind, frames, width, height } = event.data ?? {}; + console.log(`OFFSCREEN_WORKER|${kind}|frames=${frames}|${width}x${height}`); + this.set('status', `${kind}: ${frames} frames at ${width}x${height}`); + }; + return worker; + } +} diff --git a/apps/demo/src/plugin-demos/canvas-offscreen-worker.worker.ts b/apps/demo/src/plugin-demos/canvas-offscreen-worker.worker.ts new file mode 100644 index 000000000..5f98b1c94 --- /dev/null +++ b/apps/demo/src/plugin-demos/canvas-offscreen-worker.worker.ts @@ -0,0 +1,58 @@ +import '@nativescript/canvas/worker'; +import { OffscreenCanvas } from '@nativescript/canvas/worker'; + +declare const self: any; +declare function requestAnimationFrame(callback: (time: number) => void): number; + +function report(kind: string, canvas: OffscreenCanvas, frames: number) { + if (frames % 120 === 1) { + self.postMessage({ kind, frames, width: canvas.width, height: canvas.height }); + } +} + +function draw2D(canvas: OffscreenCanvas) { + const ctx = canvas.getContext('2d') as any; + let frames = 0; + const tick = (time: number) => { + frames++; + const { width, height } = canvas; + ctx.clearRect(0, 0, width, height); + const radius = Math.min(width, height) / 4; + const x = width / 2 + Math.cos(time / 500) * (width / 2 - radius); + ctx.fillStyle = 'rgba(220, 40, 60, 0.85)'; + ctx.beginPath(); + ctx.arc(x, height / 2, radius, 0, Math.PI * 2); + ctx.fill(); + report('2d', canvas, frames); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} + +function drawWebGL(canvas: OffscreenCanvas) { + const gl = canvas.getContext('webgl', { alpha: false }) as any; + if (!gl) { + self.postMessage({ kind: 'webgl', frames: -1, width: 0, height: 0 }); + return; + } + let frames = 0; + const tick = (time: number) => { + frames++; + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0.1, 0.5 + 0.4 * Math.sin(time / 400), 0.3, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + report('webgl', canvas, frames); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); +} + +self.onmessage = (event: any) => { + const { kind, canvas, handle } = event.data ?? {}; + const offscreen: OffscreenCanvas = canvas ?? OffscreenCanvas._fromHandle(handle); + if (kind === 'webgl') { + drawWebGL(offscreen); + } else { + draw2D(offscreen); + } +}; diff --git a/apps/demo/src/plugin-demos/canvas-offscreen-worker.xml b/apps/demo/src/plugin-demos/canvas-offscreen-worker.xml new file mode 100644 index 000000000..5dbca5f22 --- /dev/null +++ b/apps/demo/src/plugin-demos/canvas-offscreen-worker.xml @@ -0,0 +1,10 @@ + + + + + + + + + diff --git a/crates/canvas-2d/src/context/mod.rs b/crates/canvas-2d/src/context/mod.rs index 93abfb02f..8f6b836e4 100644 --- a/crates/canvas-2d/src/context/mod.rs +++ b/crates/canvas-2d/src/context/mod.rs @@ -585,7 +585,11 @@ impl Context { return "data:,".to_string(); } - // self.flush(); + // The render thread holds many contexts; read through this one. + #[cfg(feature = "gl")] + if let Some(ref context) = self.gl_context { + context.make_current(); + } let image = self.surface.image_snapshot(); diff --git a/crates/canvas-2d/src/context/surface_d3d.rs b/crates/canvas-2d/src/context/surface_d3d.rs index 0f2f9da00..3aa53c134 100644 --- a/crates/canvas-2d/src/context/surface_d3d.rs +++ b/crates/canvas-2d/src/context/surface_d3d.rs @@ -453,6 +453,15 @@ impl Context { target.xaml.is_some() } + pub fn detach_d3d_view(&mut self) { + let Some(target) = self.d3d.as_mut() else { return }; + target.xaml = None; + target.lost_xaml_source = None; + target.back_buffers.clear(); + target.swap_chain = None; + target.panel = None; + } + pub fn d3d_device(&self) -> Option> { self.d3d.as_ref()?.device.clone() } diff --git a/crates/canvas-2d/src/context/surface_metal.rs b/crates/canvas-2d/src/context/surface_metal.rs index 81323c68a..7acbaef58 100644 --- a/crates/canvas-2d/src/context/surface_metal.rs +++ b/crates/canvas-2d/src/context/surface_metal.rs @@ -172,6 +172,29 @@ impl Context { )) } + pub fn new_metal_layer_device_queue_sized( + layer: *mut c_void, + device: *mut c_void, + queue: *mut c_void, + width: f32, + height: f32, + density: f32, + samples: usize, + alpha: bool, + font_color: i32, + ppi: f32, + direction: TextDirection, + color_space: ColorSpace, + ) -> Option { + let mtl_context = unsafe { + MetalContext::new_layer_device_queue_sized(layer, device, queue, width as f64, height as f64) + }?; + Some(Self::with_metal_device_queue( + mtl_context, device, queue, density, samples, alpha, font_color, ppi, direction, + color_space, + )) + } + fn with_metal_device_queue( mut mtl_context: MetalContext, device: *mut c_void, @@ -343,7 +366,7 @@ impl Context { let mut info: Option = None; if let Some(context) = context.metal_context.as_mut() { samples = context.sample_count(); - if context.is_offscreen() { + if context.is_offscreen() || context.sizes_drawable() { context.set_drawable_size(width as f64, height as f64) } diff --git a/crates/canvas-android/src/jni_compat/mod.rs b/crates/canvas-android/src/jni_compat/mod.rs index 7bce97dd2..e4cc12204 100644 --- a/crates/canvas-android/src/jni_compat/mod.rs +++ b/crates/canvas-android/src/jni_compat/mod.rs @@ -1,6 +1,7 @@ #![allow(non_snake_case)] pub mod org_nativescript_canvas_NSCCanvas; +pub mod org_nativescript_canvas_NSCCanvas_offscreen; pub mod org_nativescript_canvas_NSCCanvasRenderingContext2D; pub mod org_nativescript_canvas_NSCImageAsset; pub mod org_nativescript_canvas_NSCWebGLRenderingContext; diff --git a/crates/canvas-android/src/jni_compat/org_nativescript_canvas_NSCCanvas.rs b/crates/canvas-android/src/jni_compat/org_nativescript_canvas_NSCCanvas.rs index dce969759..b2add632b 100644 --- a/crates/canvas-android/src/jni_compat/org_nativescript_canvas_NSCCanvas.rs +++ b/crates/canvas-android/src/jni_compat/org_nativescript_canvas_NSCCanvas.rs @@ -14,6 +14,8 @@ use std::ffi::c_void; use std::ptr; use std::ptr::NonNull; +use canvas_c::offscreen::android::{detach_2d_surface, update_2d_surface}; + fn to_raw_window_handler(window: &NativeWindow) -> RawWindowHandle { let handle = raw_window_handle::AndroidNdkWindowHandle::new( ptr::NonNull::new(window.ptr().as_ptr() as *mut c_void).unwrap(), @@ -158,40 +160,6 @@ pub extern "system" fn nativeDetach2DSurface(_: JNIEnv, _: JClass, context: jlon detach_2d_surface(context); } -fn detach_2d_surface(context: &mut canvas_c::CanvasRenderingContext2D) { - let context = context.get_context_mut(); - - if context.vulkan_context.is_some() { - context.detach_vulkan_view(); - return; - } - - let color_space = context.surface_data().color_space(); - let alpha = !context.surface_data().is_opaque(); - let width = context.surface_data().width() as i32; - let height = context.surface_data().height() as i32; - context.flush_and_render_to_surface(); - if let Some(gl_context) = context.gl_context.as_mut() { - let mut attr = canvas_core::context_attributes::ContextAttributes::new( - alpha, - false, - false, - false, - PowerPreference::Default, - true, - false, - false, - false, - false, - true, - false, - color_space, - ); - gl_context.resize_pbuffer(&mut attr, width, height); - gl_context.make_current(); - } -} - // #[cfg(feature = "vulkan")] #[no_mangle] pub extern "system" fn nativeCreate2dContextVulkan( @@ -515,90 +483,19 @@ pub extern "system" fn nativeUpdate2DSurface( if let Some(target) = context.render_target() { // Not waited on: blocking this callback on the render thread can deadlock the buffer queue. let window = SendWindow(window); - target.post(move |real| update_2d_surface(real, &window.0, width, height)); + target.post(move |real| { + let window = window; + update_2d_surface(real, window.0.ptr().cast(), width, height, true) + }); context.resize(width as f32, height as f32); return; } - update_2d_surface(context, &window, width, height); + update_2d_surface(context, window.ptr().cast(), width, height, true); } struct SendWindow(NativeWindow); unsafe impl Send for SendWindow {} -fn update_2d_surface( - context: &mut canvas_c::CanvasRenderingContext2D, - window: &NativeWindow, - width: jint, - height: jint, -) { - unsafe { - { - { - let context = context.get_context_mut(); - let color_space = context.surface_data().color_space(); - let alpha = !context.surface_data().is_opaque(); - // A new EGL surface starts blank; resize() only clears on a size change. - let offscreen = context.presents_through_window(); - let pixels = if context.gl_context.is_some() - && !offscreen - && context.surface_data().width() as i32 == width - && context.surface_data().height() as i32 == height - { - context.get_image() - } else { - None - }; - if let Some(context) = context.gl_context.as_mut() { - let mut attr = canvas_core::context_attributes::ContextAttributes::new( - alpha, - false, - false, - false, - PowerPreference::Default, - true, - false, - false, - false, - false, - true, - false, - color_space - ); - - let Some(nn_ptr) = NonNull::new(window.ptr().as_ptr() as _) else { return }; - let handle = raw_window_handle::AndroidNdkWindowHandle::new(nn_ptr); - let handle = RawWindowHandle::AndroidNdk(handle); - context.set_window_surface(&mut attr, width, height, handle); - context.make_current(); - } - context.use_offscreen_for_window(); - if let Some(pixels) = pixels { - context.draw_pixels(&pixels); - } - if context.presents_through_window() { - context.present_to_window(); - if let Some(gl_context) = context.gl_context.as_ref() { - gl_context.swap_buffers(); - } - } - - if let Some(vulkan_context) = context.vulkan_context.as_mut() { - vulkan_context.set_view( - window.ptr().as_ptr() as *mut std::os::raw::c_void, - width as u32, - height as u32, - ); - } - } - - let width = width as f32; - let height = height as f32; - - context.resize(width, height) - } - } -} - fn native_update_2d_surface_no_surface(width: jint, height: jint, context: jlong) { if context == 0 { return; diff --git a/crates/canvas-android/src/jni_compat/org_nativescript_canvas_NSCCanvas_offscreen.rs b/crates/canvas-android/src/jni_compat/org_nativescript_canvas_NSCCanvas_offscreen.rs new file mode 100644 index 000000000..d7b3ac457 --- /dev/null +++ b/crates/canvas-android/src/jni_compat/org_nativescript_canvas_NSCCanvas_offscreen.rs @@ -0,0 +1,132 @@ +use std::ffi::{c_void, CString}; + +use canvas_c::offscreen::android::{canvas_native_offscreen_surface_set_window, canvas_native_offscreen_surface_window_destroyed}; +use canvas_c::offscreen::*; +use canvas_c::CanvasColorSpace; +use jni::objects::{GlobalRef, JClass, JObject, JString, JValue}; +use jni::sys::{jfloat, jint, jlong, jobject, jstring}; +use jni::{JNIEnv, NativeMethod}; +use ndk::native_window::NativeWindow; + +fn surface(ptr: jlong) -> *const CanvasOffscreenSurface { + ptr as *const CanvasOffscreenSurface +} + +pub extern "system" fn nativeOffscreenSurfaceCreate( + _: JNIEnv, + _: JClass, + width: jint, + height: jint, + density: jfloat, + ppi: jfloat, + direction: jint, + color_space: jint, +) -> jlong { + let color_space = if color_space == 1 { CanvasColorSpace::P3 } else { CanvasColorSpace::Srgb }; + canvas_native_offscreen_surface_create_android( + width.max(1) as u32, + height.max(1) as u32, + density, + ppi, + direction as u32, + color_space, + ) as jlong +} + +pub extern "system" fn nativeOffscreenSurfaceSetWindow(env: JNIEnv, _: JClass, ptr: jlong, window: jobject) { + if ptr == 0 || window.is_null() { + return; + } + if let Some(window) = unsafe { NativeWindow::from_surface(env.get_native_interface(), window) } { + unsafe { canvas_native_offscreen_surface_set_window(surface(ptr), window.ptr().as_ptr() as *mut c_void) }; + } +} + +pub extern "system" fn nativeOffscreenSurfaceWindowDestroyed(_: JNIEnv, _: JClass, ptr: jlong) { + unsafe { canvas_native_offscreen_surface_window_destroyed(surface(ptr)) }; +} + +pub extern "system" fn nativeOffscreenSurfaceDetachView(_: JNIEnv, _: JClass, ptr: jlong) { + unsafe { canvas_native_offscreen_surface_detach_view(surface(ptr)) }; +} + +pub extern "system" fn nativeOffscreenSurfaceReference(_: JNIEnv, _: JClass, ptr: jlong) -> jlong { + unsafe { canvas_native_offscreen_surface_reference(surface(ptr)) }; + ptr +} + +pub extern "system" fn nativeOffscreenSurfaceRelease(_: JNIEnv, _: JClass, ptr: jlong) { + unsafe { canvas_native_offscreen_surface_release(surface(ptr)) }; +} + +pub extern "system" fn nativeOffscreenSurfaceToDataURL( + mut env: JNIEnv, + _: JClass, + ptr: jlong, + format: JString, + quality: jint, +) -> jstring { + let format: String = env.get_string(&format).map(Into::into).unwrap_or_else(|_| "image/png".into()); + let Ok(format) = CString::new(format) else { + return std::ptr::null_mut(); + }; + let url = unsafe { canvas_native_offscreen_surface_to_data_url(surface(ptr), format.as_ptr(), quality.max(0) as u32) }; + if url.is_null() { + return std::ptr::null_mut(); + } + let url = unsafe { CString::from_raw(url) }; + env.new_string(url.to_string_lossy()).map_or(std::ptr::null_mut(), |value| value.into_raw()) +} + +/// Called from the owning JS thread, which is attached. +struct Sink(GlobalRef); + +extern "C" fn sink_call(data: *mut c_void, event: CanvasOffscreenEvent, a: u32, b: u32) { + let sink = unsafe { &*(data as *const Sink) }; + let Some(vm) = crate::JVM.get() else { return }; + let Ok(mut env) = vm.attach_current_thread() else { return }; + let result = env.call_method( + sink.0.as_obj(), + "onEvent", + "(III)V", + &[JValue::Int(event as jint), JValue::Int(a as jint), JValue::Int(b as jint)], + ); + if result.is_err() { + let _ = env.exception_clear(); + } +} + +extern "C" fn sink_release(data: *mut c_void) { + // GlobalRef attaches the thread it is dropped on. + drop(unsafe { Box::from_raw(data as *mut Sink) }); +} + +pub extern "system" fn nativeOffscreenSurfaceSetSink(env: JNIEnv, _: JClass, ptr: jlong, sink: JObject) { + let Ok(global) = env.new_global_ref(sink) else { return }; + let data = Box::into_raw(Box::new(Sink(global))) as *mut c_void; + unsafe { + canvas_native_offscreen_surface_set_ui_sink( + surface(ptr), + CanvasOffscreenUiSink { data, call: Some(sink_call), release: Some(sink_release) }, + ) + }; +} + +/// Not @FastNative: these call into Java or wait on a context's thread. +pub fn register(env: &mut JNIEnv, class: &JClass) { + let methods = [ + ("nativeOffscreenSurfaceCreate", "(IIFFII)J", nativeOffscreenSurfaceCreate as *mut c_void), + ("nativeOffscreenSurfaceSetWindow", "(JLandroid/view/Surface;)V", nativeOffscreenSurfaceSetWindow as *mut c_void), + ("nativeOffscreenSurfaceWindowDestroyed", "(J)V", nativeOffscreenSurfaceWindowDestroyed as *mut c_void), + ("nativeOffscreenSurfaceDetachView", "(J)V", nativeOffscreenSurfaceDetachView as *mut c_void), + ("nativeOffscreenSurfaceSetSink", "(JLjava/lang/Object;)V", nativeOffscreenSurfaceSetSink as *mut c_void), + ("nativeOffscreenSurfaceReference", "(J)J", nativeOffscreenSurfaceReference as *mut c_void), + ("nativeOffscreenSurfaceRelease", "(J)V", nativeOffscreenSurfaceRelease as *mut c_void), + ("nativeOffscreenSurfaceToDataURL", "(JLjava/lang/String;I)Ljava/lang/String;", nativeOffscreenSurfaceToDataURL as *mut c_void), + ]; + let methods: Vec = methods + .into_iter() + .map(|(name, sig, fn_ptr)| NativeMethod { name: name.into(), sig: sig.into(), fn_ptr }) + .collect(); + let _ = env.register_native_methods(class, &methods); +} diff --git a/crates/canvas-android/src/lib.rs b/crates/canvas-android/src/lib.rs index 04b492b8b..8665b096e 100644 --- a/crates/canvas-android/src/lib.rs +++ b/crates/canvas-android/src/lib.rs @@ -261,6 +261,7 @@ pub extern "system" fn JNI_OnLoad(vm: JavaVM, _reserved: *const c_void) -> jint .collect(); let _ = env.register_native_methods(&canvas_class, canvas_native_methods.as_slice()); + crate::jni_compat::org_nativescript_canvas_NSCCanvas_offscreen::register(&mut env, &canvas_class); let text_render_class = env .find_class("org/nativescript/canvas/TextureRender") diff --git a/crates/canvas-c/src/c2d/context.rs b/crates/canvas-c/src/c2d/context.rs index 597c33807..4e70ebf38 100644 --- a/crates/canvas-c/src/c2d/context.rs +++ b/crates/canvas-c/src/c2d/context.rs @@ -131,6 +131,32 @@ pub fn resize_gl(context: &mut CanvasRenderingContext2D, width: f32, height: f32 gl.bind_drawable(); } + // A pbuffer keeps its size. + #[cfg(target_os = "android")] + let color_space = context.surface_data().color_space(); + #[cfg(target_os = "android")] + if let Some(gl) = context.gl_context.as_mut() { + if gl.is_pbuffer() { + let mut attr = canvas_core::context_attributes::ContextAttributes::new( + alpha, + false, + false, + false, + canvas_core::context_attributes::PowerPreference::Default, + true, + false, + false, + false, + false, + true, + false, + color_space, + ); + gl.resize_pbuffer(&mut attr, width as i32, height as i32); + gl.make_current(); + } + } + unsafe { gl_bindings::Viewport(0, 0, width as i32, height as i32); match context.window_framebuffer() { diff --git a/crates/canvas-c/src/c2d/render_thread.rs b/crates/canvas-c/src/c2d/render_thread.rs index 0f2eea613..6a97ee63f 100644 --- a/crates/canvas-c/src/c2d/render_thread.rs +++ b/crates/canvas-c/src/c2d/render_thread.rs @@ -3,12 +3,13 @@ use std::collections::{HashMap, VecDeque}; use std::sync::atomic::{AtomicBool, AtomicU32, Ordering}; -use std::sync::{mpsc, Arc, Condvar, Mutex, OnceLock}; +use std::sync::{mpsc, Arc, Condvar, Mutex, OnceLock, Weak}; use std::time::Duration; use canvas_2d::context::recording::Frame; use super::context::CanvasRenderingContext2D; +use crate::webgl::thread::Behind; type Job = Box; @@ -186,6 +187,10 @@ const MAX_MERGED_FRAMES: u32 = 4; /// Drop waits for the real context to go, so its window can be released afterwards. pub struct RenderTarget { + shared: Arc, +} + +struct TargetShared { id: u64, worker: Arc, open: Mutex>, @@ -193,6 +198,34 @@ pub struct RenderTarget { /// Frames merged into the open slot since this thread last took it. Non-zero means this canvas /// is behind, which holds requestAnimationFrame back (`canvas_native_canvases_behind`). merged: Arc, + behind: Behind, +} + +/// The real context without the recording one, which belongs to the JS thread. +#[derive(Clone)] +pub struct TargetHandle(Weak); + +impl TargetHandle { + pub fn post(&self, f: F) + where + F: FnOnce(&mut CanvasRenderingContext2D) + Send + 'static, + { + if let Some(shared) = self.0.upgrade() { + shared.post(f); + } + } + + pub fn sync(&self, f: F) -> Option + where + R: Send + 'static, + F: FnOnce(&mut CanvasRenderingContext2D) -> R + Send + 'static, + { + self.0.upgrade()?.sync(f) + } + + pub fn is_lost(&self) -> bool { + self.0.upgrade().is_some_and(|shared| shared.lost.load(Ordering::Acquire)) + } } impl RenderTarget { @@ -217,29 +250,37 @@ impl RenderTarget { } })); Some(Self { - id, - worker, - open: Mutex::new(None), - lost, - merged: Arc::new(AtomicU32::new(0)), + shared: Arc::new(TargetShared { + id, + worker, + open: Mutex::new(None), + lost, + merged: Arc::new(AtomicU32::new(0)), + behind: crate::webgl::thread::behind_counter(), + }), }) } + pub fn handle(&self) -> TargetHandle { + TargetHandle(Arc::downgrade(&self.shared)) + } + /// The real context's GPU device was lost, as of its last present or job. pub fn is_lost(&self) -> bool { - self.lost.load(Ordering::Acquire) + self.shared.lost.load(Ordering::Acquire) } pub fn commit(&self, frame: Frame) { + let shared = &self.shared; if frame.is_empty() { return; } - if self.merged.load(Ordering::Acquire) >= MAX_MERGED_FRAMES { + if shared.merged.load(Ordering::Acquire) >= MAX_MERGED_FRAMES { // Something is drawing faster than this thread can keep up, outside the // requestAnimationFrame hold-back: wait for the pending frame to be drawn. - self.sync(|_| ()); + shared.sync(|_| ()); } - let Ok(mut open) = self.open.lock() else { + let Ok(mut open) = shared.open.lock() else { return; }; let mut frame = Some(frame); @@ -247,8 +288,8 @@ impl RenderTarget { if let Ok(mut pending) = slot.lock() { if let Some(queued) = pending.as_mut() { queued.append(frame.take().expect("set above")); - if self.merged.fetch_add(1, Ordering::AcqRel) == 0 { - crate::webgl::thread::canvas_behind(); + if shared.merged.fetch_add(1, Ordering::AcqRel) == 0 { + crate::webgl::thread::canvas_behind(shared.behind); } return; } @@ -256,13 +297,14 @@ impl RenderTarget { } let slot: Slot = Arc::new(Mutex::new(frame)); *open = Some(Arc::clone(&slot)); - let id = self.id; - let merged = Arc::clone(&self.merged); - self.worker.push(Box::new(move |targets: &mut Targets| { + let id = shared.id; + let merged = Arc::clone(&shared.merged); + let behind = shared.behind; + shared.worker.push(Box::new(move |targets: &mut Targets| { let frame = slot.lock().ok().and_then(|mut pending| pending.take()); // Taken under the slot's lock above, so a commit merging now lands in a new slot. if merged.swap(0, Ordering::AcqRel) > 0 { - crate::webgl::thread::canvas_caught_up(); + crate::webgl::thread::canvas_caught_up(behind); } if let (Some(frame), Some(entry)) = (frame, targets.contexts.get_mut(&id)) { entry.context.get_context_mut().replay(frame); @@ -271,6 +313,23 @@ impl RenderTarget { })); } + pub fn post(&self, f: F) + where + F: FnOnce(&mut CanvasRenderingContext2D) + Send + 'static, + { + self.shared.post(f); + } + + pub fn sync(&self, f: F) -> Option + where + R: Send + 'static, + F: FnOnce(&mut CanvasRenderingContext2D) -> R + Send + 'static, + { + self.shared.sync(f) + } +} + +impl TargetShared { /// Later commits must not merge into a frame queued before other work. fn seal(&self) { if let Ok(mut open) = self.open.lock() { @@ -278,7 +337,7 @@ impl RenderTarget { } } - pub fn post(&self, f: F) + fn post(&self, f: F) where F: FnOnce(&mut CanvasRenderingContext2D) + Send + 'static, { @@ -292,7 +351,7 @@ impl RenderTarget { })); } - pub fn sync(&self, f: F) -> Option + fn sync(&self, f: F) -> Option where R: Send + 'static, F: FnOnce(&mut CanvasRenderingContext2D) -> R + Send + 'static, @@ -314,12 +373,13 @@ impl RenderTarget { impl Drop for RenderTarget { fn drop(&mut self) { - if self.merged.swap(0, Ordering::AcqRel) > 0 { - crate::webgl::thread::canvas_caught_up(); + let shared = &self.shared; + if shared.merged.swap(0, Ordering::AcqRel) > 0 { + crate::webgl::thread::canvas_caught_up(shared.behind); } let (tx, rx) = mpsc::sync_channel::<()>(1); - let id = self.id; - self.worker.push(Box::new(move |targets: &mut Targets| { + let id = shared.id; + shared.worker.push(Box::new(move |targets: &mut Targets| { targets.presents.retain(|target| *target != id); drop(targets.contexts.remove(&id)); let _ = tx.send(()); diff --git a/crates/canvas-c/src/lib.rs b/crates/canvas-c/src/lib.rs index 5b676d5b5..886e5e3ad 100644 --- a/crates/canvas-c/src/lib.rs +++ b/crates/canvas-c/src/lib.rs @@ -36,6 +36,7 @@ mod text_encoder; pub use text_encoder::*; mod webgl; pub use webgl::*; +pub mod offscreen; pub mod impl_test; /* Raf */ #[cfg(any(target_os = "android", target_os = "ios", target_os = "visionos", target_os = "tvos"))] diff --git a/crates/canvas-c/src/offscreen/android.rs b/crates/canvas-c/src/offscreen/android.rs new file mode 100644 index 000000000..9a695d3ad --- /dev/null +++ b/crates/canvas-c/src/offscreen/android.rs @@ -0,0 +1,220 @@ +//! A view's window arriving is queued to the context's thread (waiting can deadlock the buffer +//! queue); a window going is waited on, as the context must let go of it first. + +use std::ffi::c_void; +use std::ptr::NonNull; + +use canvas_core::context_attributes::{ContextAttributes, PowerPreference}; +use ndk::native_window::NativeWindow; +use raw_window_handle::{AndroidNdkWindowHandle, RawWindowHandle}; + +use super::{Binding, CanvasOffscreenSurface}; +use crate::c2d::render_thread::TargetHandle; +use crate::{CanvasRenderingContext2D, WebGLState}; + +#[derive(Default)] +pub(super) struct View { + pub(super) window: Option, +} + +struct SendWindow(NativeWindow); + +// ANativeWindow is reference counted and thread-safe. +unsafe impl Send for SendWindow {} + +fn surface_attributes(context: &canvas_2d::context::Context) -> ContextAttributes { + ContextAttributes::new( + !context.surface_data().is_opaque(), + false, + false, + false, + PowerPreference::Default, + true, + false, + false, + false, + false, + true, + false, + context.surface_data().color_space(), + ) +} + +/// `resize`: take the window's size as the bitmap's (an OffscreenCanvas keeps its own). +pub fn update_2d_surface( + context: &mut CanvasRenderingContext2D, + window: NonNull, + width: i32, + height: i32, + resize: bool, +) { + { + let context = context.get_context_mut(); + // A new EGL surface starts blank; resize() only clears on a size change. + let offscreen = context.presents_through_window(); + let pixels = if context.gl_context.is_some() + && !offscreen + && context.surface_data().width() as i32 == width + && context.surface_data().height() as i32 == height + { + context.get_image() + } else { + None + }; + let mut attr = surface_attributes(context); + if let Some(gl_context) = context.gl_context.as_mut() { + let handle = RawWindowHandle::AndroidNdk(AndroidNdkWindowHandle::new(window)); + gl_context.set_window_surface(&mut attr, width, height, handle); + gl_context.make_current(); + } + context.use_offscreen_for_window(); + if let Some(pixels) = pixels { + context.draw_pixels(&pixels); + } + if context.presents_through_window() { + context.present_to_window(); + if let Some(gl_context) = context.gl_context.as_ref() { + gl_context.swap_buffers(); + } + } + + #[cfg(feature = "vulkan")] + if let Some(vulkan_context) = context.vulkan_context.as_mut() { + vulkan_context.set_view(window.as_ptr(), width as u32, height as u32); + } + } + if resize { + context.resize(width as f32, height as f32) + } +} + +pub fn detach_2d_surface(context: &mut CanvasRenderingContext2D) { + let context = context.get_context_mut(); + + #[cfg(feature = "vulkan")] + if context.vulkan_context.is_some() { + context.detach_vulkan_view(); + return; + } + + let width = context.surface_data().width() as i32; + let height = context.surface_data().height() as i32; + let mut attr = surface_attributes(context); + context.flush_and_render_to_surface(); + if let Some(gl_context) = context.gl_context.as_mut() { + gl_context.resize_pbuffer(&mut attr, width, height); + gl_context.make_current(); + } +} + +fn window_ptr(window: &NativeWindow) -> NonNull { + window.ptr().cast() +} + +pub(super) fn attach_2d(handle: &TargetHandle, window: NativeWindow, width: u32, height: u32) { + let window = SendWindow(window); + handle.post(move |real| { + let window = window; + update_2d_surface(real, window_ptr(&window.0), width as i32, height as i32, false) + }); +} + +pub(super) fn attach_webgl(state: &WebGLState, window: NativeWindow) { + let window = SendWindow(window); + state.post(move |state| { + let window = window; + state.set_window_surface(window.0.width(), window.0.height(), window_ptr(&window.0)); + state.make_current(); + }); +} + +impl CanvasOffscreenSurface { + pub fn set_window(&self, window: NativeWindow) { + let (width, height) = self.size(); + let mut inner = self.inner(); + let Some(view) = inner.view.as_mut() else { + return; + }; + view.window = Some(window.clone()); + match &inner.binding { + Binding::TwoD(handle) => attach_2d(handle, window, width, height), + Binding::WebGL(state) => attach_webgl(state.state(), window), + Binding::WebGPU(context) => unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_context_resize( + std::sync::Arc::as_ptr(context) as *mut _, + window.ptr().as_ptr() as *mut c_void, + width, + height, + ) + }, + Binding::TwoDDirect | Binding::None => {} + } + } + + pub fn window_destroyed(&self) { + let (width, height) = self.size(); + let (binding, window) = { + let mut inner = self.inner(); + let window = inner.view.as_mut().and_then(|view| view.window.take()); + let binding = match &inner.binding { + Binding::TwoD(handle) => Some(Detach::TwoD(handle.clone())), + Binding::WebGL(state) => { + crate::canvas_native_webgl_state_reference(state.0); + Some(Detach::WebGL(super::GlRef(state.0))) + } + Binding::WebGPU(context) => Some(Detach::WebGPU(std::sync::Arc::clone(context))), + _ => None, + }; + (binding, window) + }; + if window.is_none() { + return; + } + // Outside the lock: these wait on the context's thread. + match binding { + Some(Detach::TwoD(handle)) => { + handle.sync(detach_2d_surface); + } + Some(Detach::WebGL(state)) => { + let (width, height) = (width as i32, height as i32); + state.state().detach(move |state| { + state.make_current(); + state.resize_pbuffer(width, height); + }); + } + Some(Detach::WebGPU(context)) => unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_context_detach_surface( + std::sync::Arc::as_ptr(&context), + ); + }, + None => {} + } + drop(window); + } +} + +enum Detach { + TwoD(TargetHandle), + WebGL(super::GlRef), + WebGPU(std::sync::Arc), +} + +/// UI thread. Takes its own reference to the window. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_set_window( + surface: *const CanvasOffscreenSurface, + window: *mut c_void, +) { + let (Some(surface), Some(window)) = (surface.as_ref(), NonNull::new(window)) else { + return; + }; + surface.set_window(NativeWindow::clone_from_ptr(window.cast())); +} + +/// UI thread, synchronously. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_window_destroyed(surface: *const CanvasOffscreenSurface) { + if let Some(surface) = surface.as_ref() { + surface.window_destroyed(); + } +} diff --git a/crates/canvas-c/src/offscreen/apple.rs b/crates/canvas-c/src/offscreen/apple.rs new file mode 100644 index 000000000..3947f6c72 --- /dev/null +++ b/crates/canvas-c/src/offscreen/apple.rs @@ -0,0 +1,129 @@ +//! A view's layers, used off the main thread only for drawables, drawable size and presents. + +use std::ffi::{c_void, CStr}; + +use objc2::msg_send; +use objc2::rc::Retained; +use objc2::runtime::{AnyClass, AnyObject}; + +use super::WebGLAttributes; +use crate::WebGLState; + +#[derive(Clone)] +pub(super) struct ObjRef(Retained); + +// Retain and release are thread-safe. +unsafe impl Send for ObjRef {} +unsafe impl Sync for ObjRef {} + +impl ObjRef { + unsafe fn retain(value: *mut c_void) -> Option { + Retained::retain(value as *mut AnyObject).map(Self) + } + + pub(super) fn ptr(&self) -> *mut c_void { + Retained::as_ptr(&self.0) as *mut c_void + } +} + +#[derive(Clone)] +pub(super) struct MetalLayer { + pub(super) layer: ObjRef, + pub(super) device: ObjRef, + pub(super) queue: ObjRef, + pub(super) samples: usize, +} + +pub(super) struct View { + pub(super) metal: Option, + /// None on visionOS. + pub(super) gl_layer: Option, +} + +impl View { + pub(super) fn new( + metal_layer: *mut c_void, + device: *mut c_void, + queue: *mut c_void, + samples: usize, + gl_layer: *mut c_void, + ) -> Self { + let metal = unsafe { + match (ObjRef::retain(metal_layer), ObjRef::retain(device), ObjRef::retain(queue)) { + (Some(layer), Some(device), Some(queue)) => Some(MetalLayer { + layer, + device, + queue, + samples: samples.max(1), + }), + _ => None, + } + }; + Self { + metal, + gl_layer: unsafe { ObjRef::retain(gl_layer) }, + } + } +} + +/// OpenGLES's `NSString * const` keys, looked up rather than hard-coded. +unsafe fn eagl_constant(name: &CStr) -> *const AnyObject { + let symbol = libc::dlsym(libc::RTLD_DEFAULT, name.as_ptr()); + if symbol.is_null() { + return std::ptr::null(); + } + *(symbol as *const *const AnyObject) +} + +/// Set before storage: retained backing is preserveDrawingBuffer. +fn set_drawable_properties(layer: &ObjRef, retained: bool) { + objc2::rc::autoreleasepool(|_| unsafe { + let retained_key = eagl_constant(c"kEAGLDrawablePropertyRetainedBacking"); + let format_key = eagl_constant(c"kEAGLDrawablePropertyColorFormat"); + let rgba8 = eagl_constant(c"kEAGLColorFormatRGBA8"); + let (Some(number), Some(dictionary)) = (AnyClass::get(c"NSNumber"), AnyClass::get(c"NSDictionary")) else { + return; + }; + if retained_key.is_null() || format_key.is_null() || rgba8.is_null() { + return; + } + let retained: *mut AnyObject = msg_send![number, numberWithBool: retained]; + let keys = [retained_key, format_key]; + let objects = [retained as *const AnyObject, rgba8]; + let properties: *mut AnyObject = msg_send![ + dictionary, + dictionaryWithObjects: objects.as_ptr(), + forKeys: keys.as_ptr(), + count: 2usize + ]; + let _: () = msg_send![&*layer.0, setDrawableProperties: properties]; + }); +} + +pub(super) fn create_webgl(layer: ObjRef, attributes: &WebGLAttributes) -> *mut WebGLState { + if attributes.version == 2 && !canvas_core::gpu::gl::GLContext::has_gl2support() { + return std::ptr::null_mut(); + } + let Ok(version) = canvas_webgl::prelude::WebGLVersion::try_from(attributes.version) else { + return std::ptr::null_mut(); + }; + let a = *attributes; + WebGLState::create_threaded(move || { + set_drawable_properties(&layer, a.preserve_drawing_buffer); + WebGLState::new_with_view( + layer.ptr(), + version, + a.alpha, + a.antialias, + a.depth, + a.fail_if_major_performance_caveat, + a.power_preference(), + a.premultiplied_alpha, + a.preserve_drawing_buffer, + a.stencil, + a.desynchronized, + a.xr_compatible, + false, + ) + }) +} diff --git a/crates/canvas-c/src/offscreen/mod.rs b/crates/canvas-c/src/offscreen/mod.rs new file mode 100644 index 000000000..fac0ba99f --- /dev/null +++ b/crates/canvas-c/src/offscreen/mod.rs @@ -0,0 +1,851 @@ +//! An OffscreenCanvas's surface: a transferred canvas's view, or none. The UI thread reaches the +//! owner thread's context only through the render thread, the WebGL thread or wgpu's locks. + +use std::collections::HashMap; +use std::ffi::{c_char, c_void, CStr, CString}; +use std::sync::atomic::{AtomicU32, Ordering}; +use std::sync::{Arc, LazyLock, Mutex}; + +use canvas_2d::context::text_styles::text_direction::TextDirection; +use canvas_2d::context::Context; +use canvas_core::context_attributes::{ColorSpace, PowerPreference}; + +use crate::c2d::render_thread::TargetHandle; +use crate::webgpu::gpu::CanvasWebGPUInstance; +use crate::webgpu::gpu_canvas_context::CanvasGPUCanvasContext; +use crate::{CanvasColorSpace, CanvasRenderingContext2D, WebGLState}; + +#[cfg(target_os = "android")] +pub mod android; +#[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] +mod apple; +#[cfg(all(target_os = "windows", feature = "d3d"))] +mod windows; +#[cfg(all(target_os = "windows", feature = "d3d"))] +pub use self::windows::*; + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanvasOffscreenEvent { + /// `a` x `b`. + Resize = 1, + /// `a`: a `CanvasOffscreenEngine`, `b`: 1 with alpha. + Engine = 2, +} + +#[repr(C)] +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum CanvasOffscreenEngine { + Metal = 1, + GL = 2, + GPU = 3, + D3D = 4, +} + +/// `call` comes from any thread; it must hop to the UI thread, or run inline when already there. +/// (Windows calls it on the UI thread.) +#[repr(C)] +pub struct CanvasOffscreenUiSink { + pub data: *mut c_void, + pub call: Option, + pub release: Option, +} + +struct Sink(CanvasOffscreenUiSink); + +unsafe impl Send for Sink {} +unsafe impl Sync for Sink {} + +impl Sink { + fn send(&self, event: CanvasOffscreenEvent, a: u32, b: u32) { + if let Some(call) = self.0.call { + call(self.0.data, event, a, b); + } + } +} + +impl Drop for Sink { + fn drop(&mut self) { + if let Some(release) = self.0.release { + release(self.0.data); + } + } +} + +struct GlRef(*mut WebGLState); + +// A direct (unthreaded) state only lives on a viewless surface, held by its owner thread alone. +unsafe impl Send for GlRef {} + +impl GlRef { + fn state(&self) -> &WebGLState { + unsafe { &*self.0 } + } +} + +impl Drop for GlRef { + fn drop(&mut self) { + crate::canvas_native_webgl_state_destroy(self.0); + } +} + +enum Binding { + None, + TwoD(TargetHandle), + /// Viewless and unthreaded: only the owner thread touches it. + TwoDDirect, + WebGL(GlRef), + WebGPU(Arc), +} + +struct Inner { + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + view: Option, + #[cfg(target_os = "android")] + view: Option, + #[cfg(all(target_os = "windows", feature = "d3d"))] + view: Option, + binding: Binding, + sink: Option>, +} + +pub struct CanvasOffscreenSurface { + density: f32, + ppi: f32, + direction: u32, + color_space: ColorSpace, + /// Only the owner thread changes it. + size: Mutex<(u32, u32)>, + inner: Mutex, +} + +impl CanvasOffscreenSurface { + fn new(width: u32, height: u32, density: f32, ppi: f32, direction: u32, color_space: CanvasColorSpace) -> Self { + Self { + density, + ppi, + direction, + color_space: color_space.into(), + size: Mutex::new((width, height)), + inner: Mutex::new(Inner { + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android", all(target_os = "windows", feature = "d3d")))] + view: None, + binding: Binding::None, + sink: None, + }), + } + } + + fn inner(&self) -> std::sync::MutexGuard<'_, Inner> { + self.inner.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + pub fn size(&self) -> (u32, u32) { + *self.size.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) + } + + fn direction(&self) -> TextDirection { + TextDirection::from(self.direction) + } + + pub fn has_view(&self) -> bool { + has_view(&self.inner()) + } + + pub fn has_context(&self) -> bool { + !matches!(self.inner().binding, Binding::None) + } + + /// Outside the lock: an inline sink calls back in. + fn notify(&self, event: CanvasOffscreenEvent, a: u32, b: u32) { + let sink = self.inner().sink.clone(); + if let Some(sink) = sink { + sink.send(event, a, b); + } + } + + fn bind(&self, binding: Binding, engine: Option, alpha: bool) { + self.inner().binding = binding; + if let Some(engine) = engine { + self.tell_view(CanvasOffscreenEvent::Engine, engine as u32, alpha as u32); + } + } + + fn tell_view(&self, event: CanvasOffscreenEvent, a: u32, b: u32) { + #[cfg(all(target_os = "windows", feature = "d3d"))] + self.tell_windows_view(event, a, b); + #[cfg(not(all(target_os = "windows", feature = "d3d")))] + self.notify(event, a, b); + } + + /// Owner thread, after a 2D context resized its own recording. + pub fn resize(&self, width: u32, height: u32) { + let (width, height) = (width.max(1), height.max(1)); + { + let mut size = self.size.lock().unwrap_or_else(|poisoned| poisoned.into_inner()); + if *size == (width, height) { + return; + } + *size = (width, height); + } + let has_view = { + let inner = self.inner(); + match &inner.binding { + Binding::WebGPU(context) => self.resize_webgpu(&inner, context, width, height), + Binding::WebGL(state) if !self.view_sizes_webgl(&inner) => { + resize_webgl_offscreen(state.state(), width, height) + } + _ => {} + } + has_view(&inner) + }; + if has_view { + self.tell_view(CanvasOffscreenEvent::Resize, width, height); + } + } + + /// A view's WebGL drawing buffer is its layer or window, sized by the view's layout. + fn view_sizes_webgl(&self, inner: &Inner) -> bool { + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + let sizes = inner.view.as_ref().is_some_and(|view| view.gl_layer.is_some()); + #[cfg(target_os = "android")] + let sizes = inner.view.as_ref().is_some_and(|view| view.window.is_some()); + #[cfg(not(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android")))] + let sizes = { + let _ = inner; + false + }; + sizes + } + + fn resize_webgpu(&self, inner: &Inner, context: &Arc, width: u32, height: u32) { + let context = Arc::as_ptr(context); + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + if let Some(layer) = inner.view.as_ref().and_then(|view| view.metal.as_ref()) { + unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_context_resize_layer( + context, + layer.layer.ptr(), + width, + height, + ) + }; + return; + } + #[cfg(target_os = "android")] + if let Some(window) = inner.view.as_ref().and_then(|view| view.window.as_ref()) { + unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_context_resize( + context as *mut _, + window.ptr().as_ptr() as *mut c_void, + width, + height, + ) + }; + return; + } + #[cfg(all(target_os = "windows", feature = "d3d"))] + if inner.view.is_some() { + unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_context_resize_swap_chain_panel(context, width, height) + }; + return; + } + let _ = inner; + unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_context_resize_offscreen(context, width, height) + }; + } + + /// `threaded: false` is ignored with a view: its callbacks arrive on the UI thread. + pub fn create_2d(&self, alpha: bool, font_color: i32, threaded: bool) -> *mut CanvasRenderingContext2D { + if self.has_context() { + return std::ptr::null_mut(); + } + let (width, height) = self.size(); + let (w, h) = (width as f32, height as f32); + let (density, ppi, direction, color_space) = (self.density, self.ppi, self.direction(), self.color_space); + + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + let metal = self.inner().view.as_ref().and_then(|view| view.metal.clone()); + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + if let Some(layer) = metal { + let context = CanvasRenderingContext2D::new_threaded_with( + w, h, density, alpha, font_color, ppi, direction, color_space, + move || { + let layer = layer; + let context = Context::new_metal_layer_device_queue_sized( + layer.layer.ptr(), layer.device.ptr(), layer.queue.ptr(), w, h, density, + layer.samples, alpha, font_color, ppi, direction, color_space, + )?; + Some(CanvasRenderingContext2D::new_metal(context, alpha)) + }, + ); + return match context { + Some(context) => { + let handle = context.render_target().map(|target| target.handle()); + self.bind(handle.map_or(Binding::TwoDDirect, Binding::TwoD), Some(CanvasOffscreenEngine::Metal), alpha); + Box::into_raw(Box::new(context)) + } + None => std::ptr::null_mut(), + }; + } + + #[cfg(all(target_os = "windows", feature = "d3d"))] + if self.has_view() { + if let Some(context) = windows::threaded_d3d_2d(w, h, density, alpha, font_color, ppi, direction, color_space) { + let handle = context.render_target().map(|target| target.handle()); + self.bind(handle.map_or(Binding::TwoDDirect, Binding::TwoD), Some(CanvasOffscreenEngine::D3D), alpha); + return Box::into_raw(Box::new(context)); + } + } + + #[cfg(target_os = "android")] + let window = self.inner().view.as_ref().and_then(|view| view.window.clone()); + #[cfg(target_os = "android")] + let threaded = threaded || self.has_view(); + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + let threaded = threaded || self.has_view(); + + let context = if threaded { + CanvasRenderingContext2D::new_threaded_with( + w, h, density, alpha, font_color, ppi, direction, color_space, + move || Some(viewless_2d(w, h, density, alpha, font_color, ppi, direction, color_space)), + ) + } else { + None + }; + let context = match context { + Some(context) => { + let handle = context.render_target().map(|target| target.handle()); + #[cfg(target_os = "android")] + if let (Some(handle), Some(window)) = (handle.as_ref(), window) { + android::attach_2d(handle, window, width, height); + } + #[cfg(target_os = "android")] + let engine = self.has_view().then_some(CanvasOffscreenEngine::GL); + #[cfg(not(target_os = "android"))] + let engine = None; + self.bind(handle.map_or(Binding::TwoDDirect, Binding::TwoD), engine, alpha); + context + } + None => { + self.bind(Binding::TwoDDirect, None, alpha); + viewless_2d(w, h, density, alpha, font_color, ppi, direction, color_space) + } + }; + Box::into_raw(Box::new(context)) + } + + pub fn create_webgl(&self, attributes: &WebGLAttributes, threaded: bool) -> *mut WebGLState { + if self.has_context() { + return std::ptr::null_mut(); + } + let (width, height) = self.size(); + let a = *attributes; + + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + let gl_layer = self.inner().view.as_ref().and_then(|view| view.gl_layer.clone()); + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + if let Some(layer) = gl_layer { + let state = apple::create_webgl(layer, &a); + return self.bind_webgl(state, true, a.alpha); + } + + #[cfg(target_os = "android")] + let window = self.inner().view.as_ref().and_then(|view| view.window.clone()); + let threaded = threaded || self.has_view(); + // A texture context: it resizes, and shows in a panel. + #[cfg(target_os = "windows")] + let state = { + let create = if threaded { + crate::canvas_native_webgl_create_d3d_threaded + } else { + crate::canvas_native_webgl_create_d3d + }; + create( + width as i32, height as i32, a.version, a.alpha, a.antialias, a.depth, + a.fail_if_major_performance_caveat, a.power_preference, a.premultiplied_alpha, + a.preserve_drawing_buffer, a.stencil, a.desynchronized, a.xr_compatible, + ) + }; + #[cfg(not(target_os = "windows"))] + let state = { + let create = if threaded { + crate::canvas_native_webgl_create_no_window_threaded + } else { + crate::canvas_native_webgl_create_no_window + }; + create( + width as i32, height as i32, a.version, a.alpha, a.antialias, a.depth, + a.fail_if_major_performance_caveat, a.power_preference, a.premultiplied_alpha, + a.preserve_drawing_buffer, a.stencil, a.desynchronized, a.xr_compatible, false, + ) + }; + #[cfg(target_os = "android")] + if let (false, Some(window)) = (state.is_null(), window) { + android::attach_webgl(unsafe { &*state }, window); + } + let has_view = self.has_view(); + self.bind_webgl(state, has_view, a.alpha) + } + + fn bind_webgl(&self, state: *mut WebGLState, has_view: bool, alpha: bool) -> *mut WebGLState { + if state.is_null() { + return state; + } + crate::canvas_native_webgl_state_reference(state); + let engine = has_view.then_some(CanvasOffscreenEngine::GL); + self.bind(Binding::WebGL(GlRef(state)), engine, alpha); + state + } + + pub fn create_webgpu(&self, instance: *const CanvasWebGPUInstance) -> *const CanvasGPUCanvasContext { + if instance.is_null() || self.has_context() { + return std::ptr::null(); + } + let (width, height) = self.size(); + #[allow(unused_mut)] + let mut engine = None; + let mut context: *const CanvasGPUCanvasContext = std::ptr::null(); + + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + let metal = self.inner().view.as_ref().and_then(|view| view.metal.clone()); + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + if let Some(layer) = metal { + context = unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_context_create( + instance, + layer.layer.ptr(), + width, + height, + ) + }; + engine = Some(CanvasOffscreenEngine::Metal); + } + #[cfg(target_os = "android")] + let window = self.inner().view.as_ref().and_then(|view| view.window.clone()); + #[cfg(target_os = "android")] + if let Some(window) = window { + context = unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_context_create( + instance as *mut _, + window.ptr().as_ptr() as *mut c_void, + width, + height, + ) + }; + engine = Some(CanvasOffscreenEngine::GPU); + } + #[cfg(all(target_os = "windows", feature = "d3d"))] + if let Some((panel, ui)) = self.panel() { + context = unsafe { + crate::webgpu::gpu_canvas_context::create_swap_chain_panel( + instance, + ::windows::core::Interface::as_raw(&panel), + Some(ui), + width, + height, + ) + }; + engine = Some(CanvasOffscreenEngine::GPU); + } + if context.is_null() && engine.is_none() { + context = unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_context_create_offscreen(instance, width, height) + }; + } + if context.is_null() { + return context; + } + let shared = unsafe { + Arc::increment_strong_count(context); + Arc::from_raw(context) + }; + self.bind(Binding::WebGPU(shared), engine, true); + context + } + + /// Any thread: read where the context lives. + pub fn data_url(&self, format: &str, quality: u32) -> Option { + enum Source { + TwoD(TargetHandle), + WebGL(*mut WebGLState), + WebGPU(Arc), + } + let source = match &self.inner().binding { + Binding::TwoD(handle) => Source::TwoD(handle.clone()), + Binding::WebGL(state) if state.state().is_threaded() => { + crate::canvas_native_webgl_state_reference(state.0); + Source::WebGL(state.0) + } + Binding::WebGPU(context) => Source::WebGPU(Arc::clone(context)), + _ => return None, + }; + let format = CString::new(format).ok()?; + match source { + Source::TwoD(handle) => { + let format = format.to_string_lossy().into_owned(); + handle.sync(move |real| real.data_url(&format, quality)) + } + Source::WebGL(state) => { + let url = crate::canvas_native_webgl_to_data_url(state, format.as_ptr(), quality); + crate::canvas_native_webgl_state_destroy(state); + take_c_string(url as *mut c_char) + } + Source::WebGPU(context) => { + let url = unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_to_data_url( + Arc::as_ptr(&context), + format.as_ptr(), + quality, + ) + }; + take_c_string(url) + } + } + } + + /// UI thread, synchronously. The context keeps drawing without the view. + pub fn detach_view(&self) { + #[cfg(target_os = "android")] + self.window_destroyed(); + #[cfg(all(target_os = "windows", feature = "d3d"))] + self.detach_windows_view(); + let sink = { + let mut inner = self.inner(); + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android"))] + { + inner.view = None; + } + inner.sink.take() + }; + drop(sink); + } + + fn set_sink(&self, sink: Option) { + let old = std::mem::replace(&mut self.inner().sink, sink.map(Arc::new)); + drop(old); + } +} + +fn take_c_string(value: *mut c_char) -> Option { + if value.is_null() { + return None; + } + let string = unsafe { CString::from_raw(value) }; + Some(string.to_string_lossy().into_owned()) +} + +#[allow(clippy::too_many_arguments)] +fn viewless_2d( + width: f32, + height: f32, + density: f32, + alpha: bool, + font_color: i32, + ppi: f32, + direction: TextDirection, + color_space: ColorSpace, +) -> CanvasRenderingContext2D { + #[cfg(all(feature = "metal", any(target_os = "ios", target_os = "tvos", target_os = "visionos")))] + let gpu = Some(CanvasRenderingContext2D::new_metal( + Context::new_metal_offscreen(width, height, density, 1, alpha, font_color, ppi, direction, color_space), + alpha, + )); + #[cfg(all(feature = "gl", target_os = "android"))] + let gpu = Context::new_gl(std::ptr::null_mut(), width, height, density, alpha, font_color, ppi, direction, color_space) + .map(|context| CanvasRenderingContext2D::new_gl(context, alpha)); + #[cfg(not(any( + all(feature = "metal", any(target_os = "ios", target_os = "tvos", target_os = "visionos")), + all(feature = "gl", target_os = "android") + )))] + let gpu: Option = None; + gpu.unwrap_or_else(|| { + CanvasRenderingContext2D::new( + Context::new(width, height, density, alpha, font_color, ppi, direction, color_space), + alpha, + ) + }) +} + +fn has_view(inner: &Inner) -> bool { + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android", all(target_os = "windows", feature = "d3d")))] + let has = inner.view.is_some(); + #[cfg(not(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android", all(target_os = "windows", feature = "d3d"))))] + let has = { + let _ = inner; + false + }; + has +} + +fn resize_webgl_offscreen(state: &WebGLState, width: u32, height: u32) { + let (width, height) = (width as i32, height as i32); + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + state.post(move |state| state.resize_drawable(width, height)); + #[cfg(target_os = "android")] + state.post(move |state| { + state.make_current(); + state.resize_pbuffer(width, height); + }); + #[cfg(target_os = "windows")] + state.post(move |state| { + state.resize_texture_surface(width, height); + }); + #[cfg(not(any(target_os = "ios", target_os = "tvos", target_os = "visionos", target_os = "android", target_os = "windows")))] + let _ = (state, width, height); +} + +#[repr(C)] +#[derive(Debug, Clone, Copy)] +pub struct WebGLAttributes { + pub version: i32, + pub alpha: bool, + pub antialias: bool, + pub depth: bool, + pub fail_if_major_performance_caveat: bool, + pub power_preference: i32, + pub premultiplied_alpha: bool, + pub preserve_drawing_buffer: bool, + pub stencil: bool, + pub desynchronized: bool, + pub xr_compatible: bool, +} + +impl WebGLAttributes { + #[cfg_attr(not(any(target_os = "ios", target_os = "tvos", target_os = "visionos")), allow(dead_code))] + fn power_preference(&self) -> PowerPreference { + PowerPreference::try_from(self.power_preference).unwrap_or(PowerPreference::Default) + } +} + +static HANDLES: LazyLock>>> = LazyLock::new(Default::default); + +/// Never reused, so a stale handle can't name another surface. +static NEXT_HANDLE: AtomicU32 = AtomicU32::new(1); + +fn handles() -> std::sync::MutexGuard<'static, HashMap>> { + HANDLES.lock().unwrap_or_else(|poisoned| poisoned.into_inner()) +} + +unsafe fn surface<'a>(surface: *const CanvasOffscreenSurface) -> Option<&'a CanvasOffscreenSurface> { + surface.as_ref() +} + +#[no_mangle] +pub extern "C" fn canvas_native_offscreen_surface_create( + width: u32, + height: u32, + density: f32, + ppi: f32, + direction: u32, + color_space: CanvasColorSpace, +) -> *const CanvasOffscreenSurface { + Arc::into_raw(Arc::new(CanvasOffscreenSurface::new(width.max(1), height.max(1), density, ppi, direction, color_space))) +} + +/// UI thread. Retains the layers, device and queue; any may be null. +#[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] +#[no_mangle] +pub extern "C" fn canvas_native_offscreen_surface_create_ios( + width: u32, + height: u32, + density: f32, + ppi: f32, + direction: u32, + color_space: CanvasColorSpace, + metal_layer: *mut c_void, + device: *mut c_void, + queue: *mut c_void, + samples: usize, + gl_layer: *mut c_void, +) -> *const CanvasOffscreenSurface { + let surface = CanvasOffscreenSurface::new(width.max(1), height.max(1), density, ppi, direction, color_space); + surface.inner().view = Some(apple::View::new(metal_layer, device, queue, samples, gl_layer)); + Arc::into_raw(Arc::new(surface)) +} + +/// UI thread. The window arrives with `canvas_native_offscreen_surface_set_window`. +#[cfg(target_os = "android")] +#[no_mangle] +pub extern "C" fn canvas_native_offscreen_surface_create_android( + width: u32, + height: u32, + density: f32, + ppi: f32, + direction: u32, + color_space: CanvasColorSpace, +) -> *const CanvasOffscreenSurface { + let surface = CanvasOffscreenSurface::new(width.max(1), height.max(1), density, ppi, direction, color_space); + surface.inner().view = Some(android::View::default()); + Arc::into_raw(Arc::new(surface)) +} + +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_reference(surface: *const CanvasOffscreenSurface) { + if !surface.is_null() { + Arc::increment_strong_count(surface); + } +} + +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_release(surface: *const CanvasOffscreenSurface) { + if !surface.is_null() { + Arc::decrement_strong_count(surface); + } +} + +/// UI thread. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_set_ui_sink( + surface: *const CanvasOffscreenSurface, + sink: CanvasOffscreenUiSink, +) { + match self::surface(surface) { + Some(surface) => surface.set_sink(Some(Sink(sink))), + None => drop(Sink(sink)), + } +} + +/// UI thread, synchronously. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_detach_view(surface: *const CanvasOffscreenSurface) { + if let Some(surface) = self::surface(surface) { + surface.detach_view(); + } +} + +/// UI thread, after the view laid out a resize. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_view_resized(surface: *const CanvasOffscreenSurface) { + let Some(surface) = self::surface(surface) else { return }; + let inner = surface.inner(); + if let Binding::WebGL(state) = &inner.binding { + if surface.view_sizes_webgl(&inner) { + #[cfg(any(target_os = "ios", target_os = "tvos", target_os = "visionos"))] + state.state().post(|state| state.resize_drawable(0, 0)); + #[cfg(not(any(target_os = "ios", target_os = "tvos", target_os = "visionos")))] + let _ = state; + } + } +} + +/// Owner thread. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_resize(surface: *const CanvasOffscreenSurface, width: u32, height: u32) { + if let Some(surface) = self::surface(surface) { + surface.resize(width, height); + } +} + +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_get_width(surface: *const CanvasOffscreenSurface) -> u32 { + self::surface(surface).map_or(0, |surface| surface.size().0) +} + +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_get_height(surface: *const CanvasOffscreenSurface) -> u32 { + self::surface(surface).map_or(0, |surface| surface.size().1) +} + +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_get_density(surface: *const CanvasOffscreenSurface) -> f32 { + self::surface(surface).map_or(1., |surface| surface.density) +} + +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_get_ppi(surface: *const CanvasOffscreenSurface) -> f32 { + self::surface(surface).map_or(160., |surface| surface.ppi) +} + +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_get_direction(surface: *const CanvasOffscreenSurface) -> u32 { + self::surface(surface).map_or(0, |surface| surface.direction) +} + +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_has_view(surface: *const CanvasOffscreenSurface) -> bool { + self::surface(surface).is_some_and(|surface| surface.has_view()) +} + +/// Owner thread. Null if the surface has a context. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_create_2d( + surface: *const CanvasOffscreenSurface, + alpha: bool, + font_color: i32, + threaded: bool, +) -> *mut CanvasRenderingContext2D { + match self::surface(surface) { + Some(surface) => surface.create_2d(alpha, font_color, threaded), + None => std::ptr::null_mut(), + } +} + +/// Owner thread. Null if the surface has a context. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_create_webgl( + surface: *const CanvasOffscreenSurface, + attributes: *const WebGLAttributes, + threaded: bool, +) -> *mut WebGLState { + match (self::surface(surface), attributes.as_ref()) { + (Some(surface), Some(attributes)) => surface.create_webgl(attributes, threaded), + _ => std::ptr::null_mut(), + } +} + +/// Owner thread. Null if the surface has a context. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_create_webgpu( + surface: *const CanvasOffscreenSurface, + instance: *const CanvasWebGPUInstance, +) -> *const CanvasGPUCanvasContext { + match self::surface(surface) { + Some(surface) => surface.create_webgpu(instance), + None => std::ptr::null(), + } +} + +/// Any thread. Null without a context. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_to_data_url( + surface: *const CanvasOffscreenSurface, + format: *const c_char, + quality: u32, +) -> *mut c_char { + let (Some(surface), false) = (self::surface(surface), format.is_null()) else { + return std::ptr::null_mut(); + }; + let format = CStr::from_ptr(format).to_string_lossy(); + surface + .data_url(&format, quality) + .and_then(|url| CString::new(url).ok()) + .map_or(std::ptr::null_mut(), CString::into_raw) +} + +/// 0 if the surface has a context. The handle holds a reference until adopted or released. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_to_handle(surface: *const CanvasOffscreenSurface) -> u32 { + if surface.is_null() || (*surface).has_context() { + return 0; + } + Arc::increment_strong_count(surface); + let surface = Arc::from_raw(surface); + let handle = NEXT_HANDLE.fetch_add(1, Ordering::Relaxed); + handles().insert(handle, surface); + handle +} + +/// Take-once: null after the first adopt or a release. +#[no_mangle] +pub extern "C" fn canvas_native_offscreen_surface_adopt(handle: u32) -> *const CanvasOffscreenSurface { + handles().remove(&handle).map_or(std::ptr::null(), Arc::into_raw) +} + +#[no_mangle] +pub extern "C" fn canvas_native_offscreen_surface_release_handle(handle: u32) -> bool { + let surface = handles().remove(&handle); + surface.is_some() +} diff --git a/crates/canvas-c/src/offscreen/windows.rs b/crates/canvas-c/src/offscreen/windows.rs new file mode 100644 index 000000000..fa5f847fc --- /dev/null +++ b/crates/canvas-c/src/offscreen/windows.rs @@ -0,0 +1,349 @@ +use std::ffi::c_void; +use std::sync::{Arc, Weak}; + +use canvas_2d::context::text_styles::text_direction::TextDirection; +use canvas_2d::context::Context; +use canvas_core::context_attributes::ColorSpace; +use canvas_core::fit::{surface_transform, CanvasFit, SurfaceTransform}; +use canvas_core::gpu::dxgi::{self, CompositionSwapChain, UiThread, XamlHandoff}; +use windows::core::{IUnknown, Interface}; + +use super::{Binding, CanvasOffscreenEvent, CanvasOffscreenSurface, GlRef, Inner}; +use crate::c2d::render_thread::TargetHandle; +use crate::webgpu::gpu_canvas_context::CanvasGPUCanvasContext; +use crate::CanvasRenderingContext2D; + +/// Only called on the UI thread. +struct UiCom(IUnknown); + +unsafe impl Send for UiCom {} + +/// Handed from the render thread, used by one thread at a time. +struct Sendable(T); + +unsafe impl Send for Sendable {} + +pub(super) struct View { + pub(super) ui: UiThread, + panel: UiCom, + xaml: Option<(UiCom, (u32, u32))>, + /// A 2D context's; a WebGL state keeps its own. + handoff: Option>, + fit: CanvasFit, + scale: (f32, f32), + view_size: (f32, f32), +} + +impl View { + fn end_handoff(&mut self) { + if let Some(handoff) = self.handoff.take() { + // Shows its last frame, if the render thread drew one it has not ended. + handoff.end_draw(); + } + } +} + +/// The context, held outside the surface's lock while the UI thread waits on its thread. +enum Target { + TwoD(TargetHandle), + WebGL(GlRef), + WebGPU(Arc), +} + +fn target(inner: &Inner) -> Option { + match &inner.binding { + Binding::TwoD(handle) => Some(Target::TwoD(handle.clone())), + Binding::WebGL(state) => { + crate::canvas_native_webgl_state_reference(state.0); + Some(Target::WebGL(GlRef(state.0))) + } + Binding::WebGPU(context) => Some(Target::WebGPU(Arc::clone(context))), + Binding::TwoDDirect | Binding::None => None, + } +} + +unsafe fn attach_panel(target: &Target, panel: *mut c_void) -> bool { + match target { + Target::TwoD(handle) => { + let swap_chain = handle + .sync(|real| real.get_context_mut().create_panel_swap_chain().map(Sendable)) + .flatten(); + let Some(Sendable(swap_chain)) = swap_chain else { return false }; + match unsafe { dxgi::bind_swap_chain(panel, swap_chain.as_raw()) } { + Ok(()) => true, + Err(error) => { + log::error!("canvas: could not show the OffscreenCanvas in its panel: {error}"); + false + } + } + } + Target::WebGL(state) => unsafe { crate::attach_swap_chain_panel_threaded(state.state(), panel) }, + // wgpu binds its own swapchain. + Target::WebGPU(_) => true, + } +} + +unsafe fn attach_xaml(target: &Target, source: *mut c_void) -> Option>> { + match target { + Target::TwoD(handle) => { + let device = handle.sync(|real| real.get_context().xaml_device().map(Sendable)).flatten(); + let Sendable((device, width, height)) = device?; + let handoff = match unsafe { XamlHandoff::new(source, &device, width, height) } { + Ok(handoff) => handoff, + Err(error) => { + log::error!("canvas: the XAML surface cannot be drawn from the render thread: {error}"); + return None; + } + }; + let shared = Arc::clone(&handoff); + handle + .sync(move |real| real.get_context_mut().attach_xaml_handoff(shared)) + .unwrap_or(false) + .then_some(Some(handoff)) + } + Target::WebGL(state) => { + unsafe { crate::attach_xaml_surface_threaded(state.state(), source) }.then_some(None) + } + Target::WebGPU(_) => None, + } +} + +fn apply_transform(target: &Target, t: SurfaceTransform) { + let SurfaceTransform { scale_x, scale_y, offset_x, offset_y } = t; + match target { + Target::TwoD(handle) => handle.post(move |real| { + real.get_context().set_swap_chain_transform(scale_x, scale_y, offset_x, offset_y); + }), + Target::WebGL(state) => { + crate::canvas_native_webgl_set_swap_chain_transform(state.0, scale_x, scale_y, offset_x, offset_y); + } + Target::WebGPU(context) => unsafe { + crate::webgpu::gpu_canvas_context::canvas_native_webgpu_context_set_swap_chain_transform( + Arc::as_ptr(context), + scale_x, + scale_y, + offset_x, + offset_y, + ); + }, + } +} + +#[allow(clippy::too_many_arguments)] +pub(super) fn threaded_d3d_2d( + width: f32, + height: f32, + density: f32, + alpha: bool, + font_color: i32, + ppi: f32, + direction: TextDirection, + color_space: ColorSpace, +) -> Option { + let context = CanvasRenderingContext2D::new_threaded_with( + width, height, density, alpha, font_color, ppi, direction, color_space, + move || { + let context = Context::new_d3d(width, height, density, alpha, font_color, ppi, direction, color_space)?; + crate::c2d::d3d::note_render_thread_device(&context); + Some(CanvasRenderingContext2D::new_d3d(context, alpha)) + }, + )?; + // The render thread may have failed to make it. + context.render_target()?.sync(|_| ())?; + Some(context) +} + +impl CanvasOffscreenSurface { + fn weak(&self) -> Weak { + // Every surface lives in an `Arc` (`canvas_native_offscreen_surface_create*`). + let this = unsafe { + Arc::increment_strong_count(self); + Arc::from_raw(self) + }; + Arc::downgrade(&this) + } + + /// Owner thread: the view hears it on the UI thread, once the context is shown there. + pub(super) fn tell_windows_view(&self, event: CanvasOffscreenEvent, a: u32, b: u32) { + let Some(ui) = self.inner().view.as_ref().map(|view| view.ui) else { return }; + let surface = self.weak(); + ui.run(move || { + let Some(surface) = surface.upgrade() else { return }; + match event { + CanvasOffscreenEvent::Engine => surface.show(b == 1), + // The view attaches a SurfaceImageSource of the new size. + CanvasOffscreenEvent::Resize => { + if let Some(view) = surface.inner().view.as_mut() { + if view.xaml.as_ref().is_some_and(|(_, size)| *size != (a, b)) { + view.xaml = None; + view.end_handoff(); + } + } + } + } + surface.place(); + surface.notify(event, a, b); + }); + } + + /// UI thread. A transparent canvas waits for its view's SurfaceImageSource. + fn show(&self, alpha: bool) { + let (target, panel, xaml) = { + let inner = self.inner(); + let Some(view) = inner.view.as_ref() else { return }; + (target(&inner), view.panel.0.clone(), view.xaml.as_ref().map(|(xaml, _)| xaml.0.clone())) + }; + let Some(target) = target else { return }; + match xaml { + Some(source) => { + self.attach_xaml_to(&target, &source); + } + None if !alpha => { + unsafe { attach_panel(&target, panel.as_raw()) }; + } + None => {} + } + } + + fn attach_xaml_to(&self, target: &Target, source: &IUnknown) -> bool { + let Some(handoff) = (unsafe { attach_xaml(target, source.as_raw()) }) else { + return false; + }; + let mut inner = self.inner(); + if let Some(view) = inner.view.as_mut() { + view.end_handoff(); + view.handoff = handoff; + } else if let Some(handoff) = handoff { + handoff.end_draw(); + } + true + } + + fn transform(&self, view: &View) -> SurfaceTransform { + let (width, height) = self.size(); + surface_transform(view.fit, (width as f32, height as f32), view.scale, view.view_size) + } + + /// UI thread. + fn place(&self) { + let (target, transform) = { + let inner = self.inner(); + let Some(view) = inner.view.as_ref() else { return }; + (target(&inner), self.transform(view)) + }; + if let Some(target) = target { + apply_transform(&target, transform); + } + } + + /// UI thread. + pub(super) fn detach_windows_view(&self) { + let (target, view) = { + let mut inner = self.inner(); + (target(&inner), inner.view.take()) + }; + let Some(mut view) = view else { return }; + unsafe { CompositionSwapChain::unbind_panel(view.panel.0.as_raw()) }; + view.end_handoff(); + match target { + Some(Target::TwoD(handle)) => handle.post(|real| real.get_context_mut().detach_d3d_view()), + Some(Target::WebGL(state)) => crate::detach_view_threaded(state.state()), + Some(Target::WebGPU(_)) | None => {} + } + } + + pub(super) fn panel(&self) -> Option<(IUnknown, UiThread)> { + let inner = self.inner(); + let view = inner.view.as_ref()?; + Some((view.panel.0.clone(), view.ui)) + } +} + +/// UI thread. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_create_windows( + width: u32, + height: u32, + density: f32, + ppi: f32, + direction: u32, + color_space: crate::CanvasColorSpace, + panel: *mut c_void, +) -> *const CanvasOffscreenSurface { + let Some(panel) = (unsafe { IUnknown::from_raw_borrowed(&panel) }).cloned() else { + return std::ptr::null(); + }; + let ui = match UiThread::current() { + Ok(ui) => ui, + Err(error) => { + log::error!("canvas: an OffscreenCanvas can't be shown from this thread: {error}"); + return std::ptr::null(); + } + }; + let surface = CanvasOffscreenSurface::new(width.max(1), height.max(1), density, ppi, direction, color_space); + surface.inner().view = Some(View { + ui, + panel: UiCom(panel), + xaml: None, + handoff: None, + fit: CanvasFit::default(), + scale: (density, density), + view_size: (0., 0.), + }); + Arc::into_raw(Arc::new(surface)) +} + +/// UI thread. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_set_layout( + surface: *const CanvasOffscreenSurface, + fit: i32, + scale_x: f32, + scale_y: f32, + view_width: f32, + view_height: f32, +) { + let Some(surface) = (unsafe { surface.as_ref() }) else { return }; + { + let mut inner = surface.inner(); + let Some(view) = inner.view.as_mut() else { return }; + view.fit = CanvasFit::from_i32(fit).unwrap_or(view.fit); + view.scale = (scale_x, scale_y); + view.view_size = (view_width, view_height); + } + surface.place(); +} + +/// UI thread. `false` for WebGPU: wgpu owns its swapchain. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_attach_xaml_surface( + surface: *const CanvasOffscreenSurface, + source: *mut c_void, +) -> bool { + let (Some(surface), Some(source)) = (unsafe { surface.as_ref() }, unsafe { IUnknown::from_raw_borrowed(&source) }.cloned()) + else { + return false; + }; + let target = { + let mut inner = surface.inner(); + let target = target(&inner); + let Some(view) = inner.view.as_mut() else { return false }; + if matches!(target, Some(Target::WebGPU(_))) { + return false; + } + view.xaml = Some((UiCom(source.clone()), surface.size())); + target + }; + // Without a context yet, it is shown in it once made. + target.is_none_or(|target| surface.attach_xaml_to(&target, &source)) +} + +/// UI thread. `out`: `[scaleX, scaleY, offsetX, offsetY]`. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_offscreen_surface_get_transform(surface: *const CanvasOffscreenSurface, out: *mut f32) { + let (Some(surface), false) = (unsafe { surface.as_ref() }, out.is_null()) else { return }; + let inner = surface.inner(); + let Some(view) = inner.view.as_ref() else { return }; + let t = surface.transform(view); + unsafe { std::ptr::copy_nonoverlapping([t.scale_x, t.scale_y, t.offset_x, t.offset_y].as_ptr(), out, 4) }; +} diff --git a/crates/canvas-c/src/raf/visionos.rs b/crates/canvas-c/src/raf/visionos.rs index 6390e7485..f4514b225 100644 --- a/crates/canvas-c/src/raf/visionos.rs +++ b/crates/canvas-c/src/raf/visionos.rs @@ -107,10 +107,10 @@ impl Raf { let display_link = unsafe { CADisplayLink::displayLinkWithTarget_selector(&*target, sel!(step:)) }; - // Start paused; `start()` unpauses. Schedule on the main run loop for common modes - // so it keeps firing during UI tracking (scrolling, etc.). + // Start paused; `start()` unpauses. Schedule on the creating thread's run loop for common + // modes so it keeps firing during UI tracking. display_link.setPaused(true); - let run_loop = NSRunLoop::mainRunLoop(); + let run_loop = NSRunLoop::currentRunLoop(); unsafe { display_link.addToRunLoop_forMode(&run_loop, NSRunLoopCommonModes) }; Self { diff --git a/crates/canvas-c/src/webgl/gl.rs b/crates/canvas-c/src/webgl/gl.rs index affb8d22f..763de07e1 100644 --- a/crates/canvas-c/src/webgl/gl.rs +++ b/crates/canvas-c/src/webgl/gl.rs @@ -274,6 +274,7 @@ pub struct WebGLState { detaching: AtomicBool, /// A present found the context lost (threaded only: an unthreaded one is asked directly). lost: AtomicBool, + behind: crate::webgl::thread::Behind, /// The XAML surface a threaded context presents into, as the UI thread holds it: that thread /// ends the last draw once the context moves on from it. #[cfg(target_os = "windows")] @@ -362,7 +363,7 @@ impl WebGLState { if self.in_flight.load(Ordering::Acquire) >= MAX_QUEUED_FRAMES { crate::webgl::thread::sync(|| ()); } - crate::webgl::thread::frame_queued(self.in_flight.fetch_add(1, Ordering::AcqRel)); + crate::webgl::thread::frame_queued(self.behind, self.in_flight.fetch_add(1, Ordering::AcqRel)); let state = StatePtr(self); crate::webgl::thread::post(move || { let state = state; @@ -370,7 +371,7 @@ impl WebGLState { if !this.detaching.load(Ordering::Acquire) { this.present_here(state); } - crate::webgl::thread::frame_presented(this.in_flight.fetch_sub(1, Ordering::AcqRel)); + crate::webgl::thread::frame_presented(this.behind, this.in_flight.fetch_sub(1, Ordering::AcqRel)); }); true } @@ -422,9 +423,11 @@ impl WebGLState { if !crate::webgl::thread::available() { return create().map_or(std::ptr::null_mut(), |state| Box::into_raw(Box::new(state))); } + let behind = crate::webgl::thread::behind_counter(); crate::webgl::thread::sync(|| { create().map_or(std::ptr::null_mut(), |mut state| { state.threaded = true; + state.behind = behind; Box::into_raw(Box::new(state)) }) }) @@ -470,6 +473,7 @@ impl WebGLState { in_flight: AtomicU32::new(0), detaching: AtomicBool::new(false), lost: AtomicBool::new(false), + behind: crate::webgl::thread::behind_counter(), #[cfg(target_os = "windows")] xaml: ShownXaml::default(), } @@ -2062,7 +2066,7 @@ pub extern "C" fn canvas_native_webgl_create_threaded( } /// `canvas_native_webgl_create_no_window`, for a context that lives on the WebGL thread. -#[cfg(target_os = "android")] +#[no_mangle] pub extern "C" fn canvas_native_webgl_create_no_window_threaded( width: i32, height: i32, @@ -2264,11 +2268,15 @@ pub extern "C" fn canvas_native_webgl_attach_swap_chain_panel(state: *mut WebGLS if state.is_null() || panel.is_null() { return false; } - let state = unsafe { &mut *state }; - if !state.is_threaded() { - return unsafe { state.get_inner_mut().attach_swap_chain_panel(panel) }; + if !unsafe { &*state }.is_threaded() { + return unsafe { (*state).get_inner_mut().attach_swap_chain_panel(panel) }; } - // Made where the context presents; only binding it has to happen here. + unsafe { attach_swap_chain_panel_threaded(&*state, panel) } +} + +/// UI thread. Made where the context presents; only binding it has to happen here. +#[cfg(target_os = "windows")] +pub(crate) unsafe fn attach_swap_chain_panel_threaded(state: &WebGLState, panel: *mut c_void) -> bool { let Some(swap_chain) = state.sync(|inner| inner.create_panel_swap_chain()) else { return false; }; @@ -2282,6 +2290,13 @@ pub extern "C" fn canvas_native_webgl_attach_swap_chain_panel(state: *mut WebGLS } } +/// UI thread. +#[cfg(target_os = "windows")] +pub(crate) fn detach_view_threaded(state: &WebGLState) { + state.post(|inner| inner.detach_view()); + state.show_xaml(None); +} + /// Windows: presents a `canvas_native_webgl_create_d3d` context into a XAML `SurfaceImageSource` /// (any COM pointer to it, made at the drawing buffer's size) instead of a swapchain, so it blends /// with the page. The rows are bottom-up: the host flips the image. UI thread. @@ -2291,10 +2306,15 @@ pub extern "C" fn canvas_native_webgl_attach_xaml_surface(state: *mut WebGLState if state.is_null() || source.is_null() { return false; } - let state = unsafe { &mut *state }; - if !state.is_threaded() { - return unsafe { state.get_inner_mut().attach_xaml_surface(source) }; + if !unsafe { &*state }.is_threaded() { + return unsafe { (*state).get_inner_mut().attach_xaml_surface(source) }; } + unsafe { attach_xaml_surface_threaded(&*state, source) } +} + +/// UI thread. +#[cfg(target_os = "windows")] +pub(crate) unsafe fn attach_xaml_surface_threaded(state: &WebGLState, source: *mut c_void) -> bool { let Some(device) = canvas_core::gpu::gl::angle_d3d11_device() else { return false; }; diff --git a/crates/canvas-c/src/webgl/thread.rs b/crates/canvas-c/src/webgl/thread.rs index de2aed571..85df9d0da 100644 --- a/crates/canvas-c/src/webgl/thread.rs +++ b/crates/canvas-c/src/webgl/thread.rs @@ -28,10 +28,12 @@ impl Worker { static WORKER: OnceLock>> = OnceLock::new(); /// Threaded canvases (WebGL here, 2D on its render thread) with a frame waiting behind another -/// that hasn't reached the screen yet: the GPU is behind them. -static CANVASES_BEHIND: AtomicU32 = AtomicU32::new(0); +/// that hasn't reached the screen yet: the GPU is behind them. One count per JS thread. +pub(crate) type Behind = &'static AtomicU32; thread_local! { + // Leaked: JS reads it as memory. + static BEHIND: Behind = Box::leak(Box::new(AtomicU32::new(0))); static ON_GL_THREAD: Cell = const { Cell::new(false) }; /// Work tried again until it's done (`retry`), by key. static RETRIES: RefCell bool>)>> = const { RefCell::new(Vec::new()) }; @@ -198,37 +200,42 @@ pub(crate) fn sync(f: impl FnOnce() -> R) -> R { /// Frames a context may have queued before it counts as behind: one being presented, one next. pub(crate) const BEHIND_AT: u32 = 2; +pub(crate) fn behind_counter() -> Behind { + BEHIND.with(|behind| *behind) +} + /// A context's queued presents went from `before` to `before + 1`. -pub(crate) fn frame_queued(before: u32) { +pub(crate) fn frame_queued(behind: Behind, before: u32) { if before + 1 == BEHIND_AT { - canvas_behind(); + canvas_behind(behind); } } /// A context's queued presents went from `before` to `before - 1`. -pub(crate) fn frame_presented(before: u32) { +pub(crate) fn frame_presented(behind: Behind, before: u32) { if before == BEHIND_AT { - canvas_caught_up(); + canvas_caught_up(behind); } } -pub(crate) fn canvas_behind() { - CANVASES_BEHIND.fetch_add(1, Ordering::AcqRel); +pub(crate) fn canvas_behind(behind: Behind) { + behind.fetch_add(1, Ordering::AcqRel); } -pub(crate) fn canvas_caught_up() { - CANVASES_BEHIND.fetch_sub(1, Ordering::AcqRel); +pub(crate) fn canvas_caught_up(behind: Behind) { + behind.fetch_sub(1, Ordering::AcqRel); } -/// How many threaded canvases are behind. The JS side holds requestAnimationFrame back while any -/// are, as a browser does when its compositor falls behind, rather than queue more work. +/// How many of the calling thread's threaded canvases are behind. The JS side holds +/// requestAnimationFrame back while any are, as a browser does when its compositor falls behind, +/// rather than queue more work. #[no_mangle] pub extern "C" fn canvas_native_canvases_behind() -> u32 { - CANVASES_BEHIND.load(Ordering::Acquire) + behind_counter().load(Ordering::Acquire) } /// Where that count lives, for the JS side to read as memory every frame instead of calling in. #[no_mangle] pub extern "C" fn canvas_native_canvases_behind_address() -> *const u32 { - CANVASES_BEHIND.as_ptr() + behind_counter().as_ptr() } diff --git a/crates/canvas-c/src/webgpu/gpu_canvas_context.rs b/crates/canvas-c/src/webgpu/gpu_canvas_context.rs index f6c9405dd..e92978127 100644 --- a/crates/canvas-c/src/webgpu/gpu_canvas_context.rs +++ b/crates/canvas-c/src/webgpu/gpu_canvas_context.rs @@ -559,6 +559,90 @@ fn to_data_url_with_texture( } } +/// No surface: getCurrentTexture hands out an offscreen texture. +#[no_mangle] +pub unsafe extern "C" fn canvas_native_webgpu_context_create_offscreen( + instance: *const CanvasWebGPUInstance, + width: u32, + height: u32, +) -> *const CanvasGPUCanvasContext { + if instance.is_null() { + return std::ptr::null(); + } + Arc::increment_strong_count(instance); + let instance = Arc::from_raw(instance); + Arc::into_raw(Arc::new(CanvasGPUCanvasContext { + instance, + surface: Mutex::new(None), + has_surface_presented: Arc::default(), + data: Mutex::default(), + view_data: Mutex::new(ViewData { width, height }), + read_back_texture: Mutex::default(), + current_texture: Mutex::default(), + offscreen_texture: Mutex::default(), + last_capabilities: Mutex::default(), + #[cfg(all(target_os = "windows", feature = "d3d"))] + panel: None, + })) +} + +#[no_mangle] +pub unsafe extern "C" fn canvas_native_webgpu_context_resize_offscreen( + context: *const CanvasGPUCanvasContext, + width: u32, + height: u32, +) { + if context.is_null() || width == 0 || height == 0 { + return; + } + let context = &*context; + discard_current_texture(context, "canvas_native_webgpu_context_resize_offscreen"); + { + let mut view_data = context.view_data.lock(); + view_data.width = width; + view_data.height = height; + } + let mut data = context.data.lock(); + let Some(data) = data.as_mut() else { + return; + }; + data.texture_data.size.width = width; + data.texture_data.size.height = height; + data.previous_configuration.width = width; + data.previous_configuration.height = height; + + #[cfg(not(target_os = "android"))] + let format = wgt::TextureFormat::Bgra8Unorm; + #[cfg(target_os = "android")] + let format = wgt::TextureFormat::Rgba8Unorm; + let desc = wgt::TextureDescriptor { + label: Some(Cow::Borrowed("ContextReadBack")), + size: wgt::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgt::TextureDimension::D2, + format, + usage: wgt::TextureUsages::COPY_SRC | wgt::TextureUsages::COPY_DST, + view_formats: vec![], + }; + let texture_data = TextureData { + usage: desc.usage, + dimension: desc.dimension, + size: desc.size, + format: desc.format, + mip_level_count: desc.mip_level_count, + sample_count: desc.sample_count, + }; + *context.read_back_texture.lock() = Some(ReadBackTexture { + texture: data.device.device.create_texture(&desc), + data: texture_data, + }); +} + #[cfg(any(target_os = "android"))] #[no_mangle] pub unsafe extern "C" fn canvas_native_webgpu_context_create( @@ -783,6 +867,18 @@ pub unsafe extern "C" fn canvas_native_webgpu_context_create_swap_chain_panel( panel: *mut c_void, width: u32, height: u32, +) -> *const CanvasGPUCanvasContext { + create_swap_chain_panel(instance, panel, None, width, height) +} + +/// `ui`: where the panel lives, when called from another thread. +#[cfg(all(target_os = "windows", feature = "d3d"))] +pub(crate) unsafe fn create_swap_chain_panel( + instance: *const CanvasWebGPUInstance, + panel: *mut c_void, + ui: Option, + width: u32, + height: u32, ) -> *const CanvasGPUCanvasContext { if instance.is_null() || panel.is_null() { return std::ptr::null(); @@ -790,7 +886,7 @@ pub unsafe extern "C" fn canvas_native_webgpu_context_create_swap_chain_panel( Arc::increment_strong_count(instance); let instance = Arc::from_raw(instance); - let target = match canvas_core::gpu::dxgi::PanelSurfaceTarget::new(panel) { + let target = match canvas_core::gpu::dxgi::PanelSurfaceTarget::with_ui(panel, ui) { Ok(target) => target, Err(error) => { log::error!("canvas_native_webgpu_context_create_swap_chain_panel: not a SwapChainPanel: {error}"); diff --git a/crates/canvas-c/tests/offscreen_surface.rs b/crates/canvas-c/tests/offscreen_surface.rs new file mode 100644 index 000000000..3dcdbba5c --- /dev/null +++ b/crates/canvas-c/tests/offscreen_surface.rs @@ -0,0 +1,111 @@ +use canvas_c::offscreen::*; +use canvas_c::{CanvasColorSpace, CanvasRenderingContext2D}; + +fn viewless(width: u32, height: u32) -> *const CanvasOffscreenSurface { + canvas_native_offscreen_surface_create(width, height, 1., 96., 0, CanvasColorSpace::Srgb) +} + +struct Sent(T); +unsafe impl Send for Sent {} + +#[test] +fn a_handle_is_adopted_once() { + let surface = viewless(30, 20); + let handle = unsafe { canvas_native_offscreen_surface_to_handle(surface) }; + assert_ne!(handle, 0); + unsafe { canvas_native_offscreen_surface_release(surface) }; + + let adopted = canvas_native_offscreen_surface_adopt(handle); + assert!(!adopted.is_null(), "the handle holds the surface"); + assert_eq!(unsafe { canvas_native_offscreen_surface_get_width(adopted) }, 30); + assert!(canvas_native_offscreen_surface_adopt(handle).is_null(), "a second adopt"); + assert!(!canvas_native_offscreen_surface_release_handle(handle)); + unsafe { canvas_native_offscreen_surface_release(adopted) }; +} + +#[test] +fn handles_are_never_reused() { + let surface = viewless(1, 1); + let first = unsafe { canvas_native_offscreen_surface_to_handle(surface) }; + assert!(canvas_native_offscreen_surface_release_handle(first)); + assert!(!canvas_native_offscreen_surface_release_handle(first), "released once"); + let second = unsafe { canvas_native_offscreen_surface_to_handle(surface) }; + assert!(second > first); + assert!(canvas_native_offscreen_surface_release_handle(second)); + unsafe { canvas_native_offscreen_surface_release(surface) }; +} + +#[test] +fn a_surface_with_a_context_has_no_handle() { + let surface = viewless(10, 10); + let context = unsafe { canvas_native_offscreen_surface_create_2d(surface, true, 0, false) }; + assert!(!context.is_null()); + assert_eq!(unsafe { canvas_native_offscreen_surface_to_handle(surface) }, 0); + assert!( + unsafe { canvas_native_offscreen_surface_create_2d(surface, true, 0, false) }.is_null(), + "one context per surface" + ); + canvas_c::canvas_native_context_release(context); + unsafe { canvas_native_offscreen_surface_release(surface) }; +} + +#[test] +fn an_adopted_surface_draws_on_its_new_thread() { + let surface = viewless(16, 16); + let handle = unsafe { canvas_native_offscreen_surface_to_handle(surface) }; + unsafe { canvas_native_offscreen_surface_release(surface) }; + + let (pixel, size, url) = std::thread::spawn(move || { + let surface = canvas_native_offscreen_surface_adopt(handle); + assert!(!surface.is_null()); + let context = unsafe { canvas_native_offscreen_surface_create_2d(surface, true, 0, true) }; + let ctx: &mut CanvasRenderingContext2D = unsafe { &mut *context }; + assert!(ctx.is_threaded()); + + canvas_c::resize(ctx, 20., 10.); + unsafe { canvas_native_offscreen_surface_resize(surface, 20, 10) }; + + let c = ctx.get_context_mut(); + c.set_fill_style_with_color("red"); + c.fill_rect_xywh(0., 0., 20., 10.); + ctx.render(); + + let data = ctx.image_data(0., 0., 20., 10.); + let pixel = data.data()[..4].to_vec(); + let size = unsafe { + (canvas_native_offscreen_surface_get_width(surface), canvas_native_offscreen_surface_get_height(surface)) + }; + // As the placeholder's toDataURL does. + let shared = Sent(surface); + let url = std::thread::spawn(move || { + let shared = shared; + let url = unsafe { canvas_native_offscreen_surface_to_data_url(shared.0, c"image/png".as_ptr(), 92) }; + assert!(!url.is_null()); + let string = unsafe { std::ffi::CStr::from_ptr(url) }.to_string_lossy().into_owned(); + canvas_c::canvas_native_string_destroy(url); + string + }) + .join() + .unwrap(); + + canvas_c::canvas_native_context_release(context); + unsafe { canvas_native_offscreen_surface_release(surface) }; + (pixel, size, url) + }) + .join() + .unwrap(); + + assert_eq!(pixel, vec![255, 0, 0, 255]); + assert_eq!(size, (20, 10)); + assert!(url.starts_with("data:image/png;base64,"), "{url}"); +} + +#[test] +fn a_viewless_surface_has_no_view() { + let surface = viewless(4, 4); + assert!(!unsafe { canvas_native_offscreen_surface_has_view(surface) }); + unsafe { canvas_native_offscreen_surface_resize(surface, 8, 2) }; + assert_eq!(unsafe { canvas_native_offscreen_surface_get_width(surface) }, 8); + assert_eq!(unsafe { canvas_native_offscreen_surface_get_height(surface) }, 2); + unsafe { canvas_native_offscreen_surface_release(surface) }; +} diff --git a/crates/canvas-core/src/gpu/dxgi.rs b/crates/canvas-core/src/gpu/dxgi.rs index cf9d0747e..64e7b49d4 100644 --- a/crates/canvas-core/src/gpu/dxgi.rs +++ b/crates/canvas-core/src/gpu/dxgi.rs @@ -279,6 +279,9 @@ unsafe impl Send for XamlHandoff {} unsafe impl Sync for XamlHandoff {} const WM_END_XAML_DRAWS: u32 = windows::Win32::UI::WindowsAndMessaging::WM_APP + 0x2d0; +const WM_RUN: u32 = windows::Win32::UI::WindowsAndMessaging::WM_APP + 0x2d1; + +type UiJob = Box; thread_local! { static WAKE_WINDOW: Cell = const { Cell::new(0) }; @@ -295,9 +298,54 @@ unsafe extern "system" fn wake_proc( end_xaml_draws(); return windows::Win32::Foundation::LRESULT(0); } + if message == WM_RUN { + let job = unsafe { Box::from_raw(lparam.0 as *mut UiJob) }; + job(); + return windows::Win32::Foundation::LRESULT(0); + } unsafe { windows::Win32::UI::WindowsAndMessaging::DefWindowProcW(window, message, wparam, lparam) } } +#[derive(Clone, Copy, Debug)] +pub struct UiThread { + window: isize, + thread: u32, +} + +impl UiThread { + /// The calling thread, which must pump messages. + pub fn current() -> Result { + Ok(Self { + window: wake_window()?, + thread: unsafe { windows::Win32::System::Threading::GetCurrentThreadId() }, + }) + } + + pub fn is_current(&self) -> bool { + self.thread == unsafe { windows::Win32::System::Threading::GetCurrentThreadId() } + } + + /// Dropped unrun if the thread is gone. + pub fn run(&self, f: impl FnOnce() + Send + 'static) { + if self.is_current() { + f(); + return; + } + let job = Box::into_raw(Box::new(Box::new(f) as UiJob)); + let posted = unsafe { + windows::Win32::UI::WindowsAndMessaging::PostMessageW( + Some(windows::Win32::Foundation::HWND(self.window as *mut c_void)), + WM_RUN, + windows::Win32::Foundation::WPARAM(0), + windows::Win32::Foundation::LPARAM(job as isize), + ) + }; + if posted.is_err() { + drop(unsafe { Box::from_raw(job) }); + } + } +} + /// This thread's message-only window, which ends the draws other threads hand it. fn wake_window() -> Result { use windows::Win32::UI::WindowsAndMessaging::{CreateWindowExW, RegisterClassW, HWND_MESSAGE, WINDOW_EX_STYLE, WINDOW_STYLE, WNDCLASSW}; @@ -524,9 +572,11 @@ pub struct PanelSurfaceTarget { proxy: ISwapChainPanelNative, panel: ISwapChainPanelNative, state: std::sync::Arc>, + /// Set when the library binds from another thread: the panel only takes a swapchain there. + ui: Option, } -// Only used on the UI thread; the wrapper travels with the context that owns it. +// The panel is only called on the UI thread; the wrapper travels with the context that owns it. unsafe impl Send for PanelSurfaceTarget {} unsafe impl Sync for PanelSurfaceTarget {} @@ -540,6 +590,27 @@ struct ProxyState { struct PanelProxy { panel: ISwapChainPanelNative, state: std::sync::Arc>, + ui: Option, +} + +struct SendCom(T); + +unsafe impl Send for SendCom {} + +fn set_panel_swap_chain(ui: Option, panel: &ISwapChainPanelNative, swap_chain: *mut c_void) -> HRESULT { + let Some(ui) = ui.filter(|ui| !ui.is_current()) else { + return unsafe { panel.SetSwapChain(swap_chain) }; + }; + let swap_chain = unsafe { windows::core::IUnknown::from_raw_borrowed(&swap_chain) }.cloned(); + let (panel, swap_chain) = (SendCom(panel.clone()), SendCom(swap_chain)); + ui.run(move || { + let (panel, swap_chain) = (panel, swap_chain); + let raw = swap_chain.0.as_ref().map_or(std::ptr::null_mut(), |swap_chain| swap_chain.as_raw()); + if let Err(error) = unsafe { panel.0.SetSwapChain(raw) }.ok() { + log::error!("canvas: could not show the swapchain in its panel: {error}"); + } + }); + HRESULT(0) } impl ISwapChainPanelNative_Impl for PanelProxy_Impl { @@ -550,22 +621,27 @@ impl ISwapChainPanelNative_Impl for PanelProxy_Impl { if let (Some(swap_chain), Some(transform)) = (state.swap_chain.as_ref(), state.transform.as_ref()) { let _ = unsafe { swap_chain.SetMatrixTransform(transform) }; } - unsafe { self.panel.SetSwapChain(swap_chain) } + set_panel_swap_chain(self.ui, &self.panel, swap_chain) } } impl PanelSurfaceTarget { /// `panel`: any COM pointer of the `SwapChainPanel`. UI thread. pub unsafe fn new(panel: *mut c_void) -> Result { + unsafe { Self::with_ui(panel, None) } + } + + pub unsafe fn with_ui(panel: *mut c_void, ui: Option) -> Result { let unknown = unsafe { windows::core::IUnknown::from_raw_borrowed(&panel) }.ok_or_else(windows::core::Error::empty)?; let panel: ISwapChainPanelNative = unknown.cast()?; let state = std::sync::Arc::new(parking_lot::Mutex::new(ProxyState::default())); let proxy: ISwapChainPanelNative = PanelProxy { panel: panel.clone(), state: state.clone(), + ui, } .into(); - Ok(Self { proxy, panel, state }) + Ok(Self { proxy, panel, state, ui }) } /// The `ISwapChainPanelNative` to hand to the library. @@ -595,7 +671,7 @@ impl PanelSurfaceTarget { impl Drop for PanelSurfaceTarget { fn drop(&mut self) { // Detach whatever the library bound, so the panel does not keep a dead swapchain. - let _ = unsafe { self.panel.SetSwapChain(std::ptr::null_mut()) }; + let _ = set_panel_swap_chain(self.ui, &self.panel, std::ptr::null_mut()); } } diff --git a/crates/canvas-core/src/gpu/gl/egl.rs b/crates/canvas-core/src/gpu/gl/egl.rs index 5a92cf6ec..063b4f17f 100644 --- a/crates/canvas-core/src/gpu/gl/egl.rs +++ b/crates/canvas-core/src/gpu/gl/egl.rs @@ -776,6 +776,12 @@ impl GLContext { } } + #[cfg(target_os = "windows")] + pub fn detach_view(&mut self) { + self.0.presenter = None; + self.0.xaml = None; + } + /// Presents into a XAML `SurfaceImageSource` through `handoff` (made on the UI thread with /// [`angle_d3d11_device`] at the context's size), from the thread that owns the context. #[cfg(target_os = "windows")] diff --git a/crates/canvas-core/src/gpu/metal.rs b/crates/canvas-core/src/gpu/metal.rs index b92402e71..b982e8f3d 100644 --- a/crates/canvas-core/src/gpu/metal.rs +++ b/crates/canvas-core/src/gpu/metal.rs @@ -68,6 +68,8 @@ pub struct MetalContext { view: Option>, current_drawable: Option>>, is_offscreen: bool, + /// Sets the layer's drawableSize itself, not the view's layout. + sizes_drawable: bool, } impl MetalContext { @@ -78,6 +80,10 @@ impl MetalContext { pub fn is_offscreen(&self) -> bool { self.is_offscreen } + + pub fn sizes_drawable(&self) -> bool { + self.sizes_drawable + } pub fn new(view: *mut c_void) -> Self { let _pool = unsafe { NSAutoreleasePool::new() }; let device = MTLCreateSystemDefaultDevice().expect("no Metal device"); @@ -121,6 +127,7 @@ impl MetalContext { view: Some(view), current_drawable, is_offscreen: false, + sizes_drawable: false, } } @@ -150,6 +157,7 @@ impl MetalContext { view: None, current_drawable, is_offscreen: true, + sizes_drawable: false, } } @@ -175,6 +183,7 @@ impl MetalContext { view: Some(view), current_drawable, is_offscreen: false, + sizes_drawable: false, } } @@ -198,6 +207,36 @@ impl MetalContext { view: None, current_drawable, is_offscreen: false, + sizes_drawable: false, + }) + } + + pub unsafe fn new_layer_device_queue_sized( + layer: *mut c_void, + device: *mut c_void, + queue: *mut c_void, + width: f64, + height: f64, + ) -> Option { + let _pool = NSAutoreleasePool::new(); + let device: Retained> = + Retained::retain((device as *mut AnyObject).cast())?; + let queue: Retained> = + Retained::retain((queue as *mut AnyObject).cast())?; + let layer: Retained = Retained::retain(layer.cast())?; + let mut size = layer.drawableSize(); + size.width = width.max(1.) as _; + size.height = height.max(1.) as _; + layer.setDrawableSize(size); + let current_drawable = layer.nextDrawable().map(|drawable| drawable.to_owned()); + Some(Self { + queue, + device, + layer, + view: None, + current_drawable, + is_offscreen: false, + sizes_drawable: true, }) } diff --git a/crates/canvas-napi/src/gpu/mod.rs b/crates/canvas-napi/src/gpu/mod.rs index 61be323a5..b55de2480 100644 --- a/crates/canvas-napi/src/gpu/mod.rs +++ b/crates/canvas-napi/src/gpu/mod.rs @@ -61,6 +61,12 @@ mod texture_view; // (empty) OnceLock and create a new wgpu Instance. static GPU_INSTANCE: std::sync::OnceLock> = std::sync::OnceLock::new(); +pub(crate) fn gpu_instance() -> Arc { + Arc::clone(GPU_INSTANCE.get_or_init(|| unsafe { + Arc::from_raw(canvas_c::webgpu::gpu::canvas_native_webgpu_instance_create()) + })) +} + #[napi(js_name = "GPU")] pub struct g_p_u { instance: Arc, diff --git a/crates/canvas-napi/src/host/windows.rs b/crates/canvas-napi/src/host/windows.rs index 730825c56..02a7cfda0 100644 --- a/crates/canvas-napi/src/host/windows.rs +++ b/crates/canvas-napi/src/host/windows.rs @@ -9,10 +9,12 @@ use std::ffi::c_void; +use canvas_c::offscreen::{CanvasOffscreenEvent, CanvasOffscreenSurface, CanvasOffscreenUiSink}; use canvas_c::webgpu::gpu_canvas_context::CanvasGPUCanvasContext; use canvas_c::{CanvasRenderingContext2D as CCanvasRenderingContext2D, WebGLState}; use canvas_core::fit::{surface_transform, CanvasFit}; -use napi::bindgen_prelude::ObjectFinalize; +use napi::bindgen_prelude::{FnArgs, Function, ObjectFinalize}; +use napi::threadsafe_function::{ThreadsafeFunctionCallMode, UnknownReturnValue}; use napi::{Env, Error, Result}; use napi_derive::napi; use windows_core::{IUnknown, Interface}; @@ -109,10 +111,29 @@ pub struct NSCCanvas { threaded_2d: bool, /// `initContext` makes a context that runs on the shared WebGL thread. threaded_webgl: bool, + offscreen: *const CanvasOffscreenSurface, +} + +fn transferred() -> Error { + Error::from_reason("The canvas was transferred to an OffscreenCanvas") +} + +type OffscreenListener<'a> = Function<'a, FnArgs<(u32, u32, u32)>, UnknownReturnValue>; + +struct OffscreenSink(Box); + +extern "C" fn offscreen_event(data: *mut c_void, event: CanvasOffscreenEvent, a: u32, b: u32) { + let sink = unsafe { &*(data as *const OffscreenSink) }; + (sink.0)(event as u32, a, b); +} + +extern "C" fn offscreen_release(data: *mut c_void) { + drop(unsafe { Box::from_raw(data as *mut OffscreenSink) }); } impl ObjectFinalize for NSCCanvas { - fn finalize(self, _: Env) -> Result<()> { + fn finalize(mut self, _: Env) -> Result<()> { + self.detach_offscreen_surface(); if let Some(panel) = self.panel.as_ref() { unsafe { canvas_core::gpu::dxgi::CompositionSwapChain::unbind_panel(panel.as_raw()) }; } @@ -136,6 +157,19 @@ impl NSCCanvas { } fn apply_transform(&self) { + if !self.offscreen.is_null() { + unsafe { + canvas_c::offscreen::canvas_native_offscreen_surface_set_layout( + self.offscreen, + self.fit as i32, + self.scale_x, + self.scale_y, + self.view_width, + self.view_height, + ) + }; + return; + } let t = surface_transform( self.fit, (self.surface_width as f32, self.surface_height as f32), @@ -188,11 +222,92 @@ impl NSCCanvas { xaml_source: None, threaded_2d: false, threaded_webgl: false, + offscreen: std::ptr::null(), }) } + /// `listener(event, a, b)`: a `CanvasOffscreenEvent`, on this thread. + #[napi( + js_name = "transferToOffscreenSurface", + ts_args_type = "listener: (event: number, a: number, b: number) => void" + )] + pub fn transfer_to_offscreen_surface(&mut self, listener: OffscreenListener) -> Result> { + if !matches!(self.context, Context::None) || !self.offscreen.is_null() || self.panel.is_none() { + return Ok(None); + } + let density = self.density(); + let surface = unsafe { + canvas_c::offscreen::canvas_native_offscreen_surface_create_windows( + self.surface_width, + self.surface_height, + density, + density * 96., + 0, + canvas_c::CanvasColorSpace::Srgb, + self.panel_ptr(), + ) + }; + if surface.is_null() { + return Ok(None); + } + // Weak: a view's events don't keep the env alive. + let tsfn = listener + .build_threadsafe_function::<(u32, u32, u32)>() + .weak::() + .build_callback(|ctx| Ok(FnArgs::from(ctx.value)))?; + let sink = Box::new(OffscreenSink(Box::new(move |event, a, b| { + tsfn.call((event, a, b), ThreadsafeFunctionCallMode::NonBlocking); + }))); + unsafe { + canvas_c::offscreen::canvas_native_offscreen_surface_set_ui_sink( + surface, + CanvasOffscreenUiSink { + data: Box::into_raw(sink) as *mut c_void, + call: Some(offscreen_event), + release: Some(offscreen_release), + }, + ); + } + self.offscreen = surface; + self.apply_transform(); + unsafe { canvas_c::offscreen::canvas_native_offscreen_surface_reference(surface) }; + Ok(crate::offscreen::OffscreenSurface::from_raw(surface)) + } + + /// `quality` is 0..1. + #[napi(js_name = "offscreenToDataURL")] + pub fn offscreen_to_data_url(&self, format: String, quality: f64) -> Option { + if self.offscreen.is_null() { + return None; + } + let format = std::ffi::CString::new(format).ok()?; + let url = unsafe { + canvas_c::offscreen::canvas_native_offscreen_surface_to_data_url(self.offscreen, format.as_ptr(), (quality * 100.) as u32) + }; + if url.is_null() { + return None; + } + let string = unsafe { std::ffi::CStr::from_ptr(url) }.to_string_lossy().into_owned(); + canvas_c::canvas_native_string_destroy(url); + Some(string) + } + + #[napi] + pub fn detach_offscreen_surface(&mut self) { + let surface = std::mem::replace(&mut self.offscreen, std::ptr::null()); + if !surface.is_null() { + unsafe { + canvas_c::offscreen::canvas_native_offscreen_surface_detach_view(surface); + canvas_c::offscreen::canvas_native_offscreen_surface_release(surface); + } + } + } + #[napi(getter)] pub fn surface_width(&self) -> u32 { + if !self.offscreen.is_null() { + return unsafe { canvas_c::offscreen::canvas_native_offscreen_surface_get_width(self.offscreen) }; + } self.surface_width } @@ -203,6 +318,9 @@ impl NSCCanvas { #[napi(getter)] pub fn surface_height(&self) -> u32 { + if !self.offscreen.is_null() { + return unsafe { canvas_c::offscreen::canvas_native_offscreen_surface_get_height(self.offscreen) }; + } self.surface_height } @@ -249,12 +367,12 @@ impl NSCCanvas { #[napi(getter)] pub fn drawing_buffer_width(&self) -> u32 { - self.surface_width + self.surface_width() } #[napi(getter)] pub fn drawing_buffer_height(&self) -> u32 { - self.surface_height + self.surface_height() } /// The context's pointer as a decimal string (0 without one), as the iOS view's @@ -269,7 +387,8 @@ impl NSCCanvas { pub fn set_surface_size(&mut self, width: f64, height: f64) { // `max` also maps NaN to 1. let (width, height) = (width.max(1.) as u32, height.max(1.) as u32); - if (width, height) == (self.surface_width, self.surface_height) { + // A transferred canvas's bitmap is its OffscreenCanvas's. + if !self.offscreen.is_null() || (width, height) == (self.surface_width, self.surface_height) { return; } self.surface_width = width; @@ -330,6 +449,7 @@ impl NSCCanvas { Context::WebGL(_) | Context::WebGPU(_) => { return Err(Error::from_reason("The canvas already has a WebGL or WebGPU context")) } + Context::None if !self.offscreen.is_null() => return Err(transferred()), Context::None => {} } let color_space = match color_space.unwrap_or(0) { @@ -387,6 +507,7 @@ impl NSCCanvas { Context::TwoD(_) | Context::WebGPU(_) => { return Err(Error::from_reason("The canvas already has a 2D or WebGPU context")) } + Context::None if !self.offscreen.is_null() => return Err(transferred()), Context::None => {} } let version = if context_type.contains("webgl2") { 2 } else { 1 }; @@ -434,6 +555,7 @@ impl NSCCanvas { Context::TwoD(_) | Context::WebGL(_) => { return Err(Error::from_reason("The canvas already has a 2D or WebGL context")) } + Context::None if !self.offscreen.is_null() => return Err(transferred()), Context::None => {} } let (instance, _) = instance.get_i64(); @@ -472,6 +594,9 @@ impl NSCCanvas { let source = unsafe { IUnknown::from_raw_borrowed(&raw) } .cloned() .ok_or_else(|| Error::from_reason("Invalid SurfaceImageSource pointer"))?; + if !self.offscreen.is_null() { + return Ok(unsafe { canvas_c::offscreen::canvas_native_offscreen_surface_attach_xaml_surface(self.offscreen, source.as_raw()) }); + } let attached = match self.context { Context::None => true, Context::TwoD(context) => canvas_c::canvas_native_context_attach_xaml_surface(context, source.as_raw()), @@ -489,6 +614,11 @@ impl NSCCanvas { /// view places a XAML surface's image with it (a swapchain gets it natively). #[napi(getter)] pub fn surface_transform(&self) -> Vec { + if !self.offscreen.is_null() { + let mut t = [0f32; 4]; + unsafe { canvas_c::offscreen::canvas_native_offscreen_surface_get_transform(self.offscreen, t.as_mut_ptr()) }; + return t.iter().map(|value| *value as f64).collect(); + } let t = surface_transform( self.fit, (self.surface_width as f32, self.surface_height as f32), diff --git a/crates/canvas-napi/src/lib.rs b/crates/canvas-napi/src/lib.rs index 1cb41a4c4..51d8ccefa 100644 --- a/crates/canvas-napi/src/lib.rs +++ b/crates/canvas-napi/src/lib.rs @@ -23,6 +23,7 @@ mod frame; mod logger; mod fast; mod image_bitmap; +mod offscreen; mod module; /// Like the V8 bindings' `install()`: `globalThis.CanvasModule = exports` unless one is already /// installed. diff --git a/crates/canvas-napi/src/logger.rs b/crates/canvas-napi/src/logger.rs index 7b3281e31..12dc9dfc8 100644 --- a/crates/canvas-napi/src/logger.rs +++ b/crates/canvas-napi/src/logger.rs @@ -13,7 +13,7 @@ use std::sync::{Mutex, OnceLock}; use log::{Level, LevelFilter, Log, Metadata, Record}; use napi::sys; -struct Console(sys::napi_threadsafe_function); +struct Console(sys::napi_threadsafe_function, std::thread::ThreadId); // Only called through napi's threadsafe-function API, which is thread-safe. unsafe impl Send for Console {} @@ -49,7 +49,7 @@ impl Log for Logger { debugger_output(&message); let console = CONSOLE.lock().unwrap_or_else(|e| e.into_inner()); - if let Some(Console(function)) = console.as_ref() { + if let Some(Console(function, _)) = console.as_ref() { let data = Box::into_raw(Box::new((record.level(), message))); let status = unsafe { sys::napi_call_threadsafe_function(*function, data as *mut c_void, sys::ThreadsafeFunctionCallMode::nonblocking) @@ -111,6 +111,16 @@ pub fn install(env: sys::napi_env) -> napi::Result<()> { } }); + let thread = std::thread::current().id(); + // A Worker's env keeps the main thread's console. + if CONSOLE + .lock() + .unwrap_or_else(|e| e.into_inner()) + .as_ref() + .is_some_and(|console| console.1 != thread) + { + return Ok(()); + } let mut function = ptr::null_mut(); unsafe { let mut name = ptr::null_mut(); @@ -131,8 +141,8 @@ pub fn install(env: sys::napi_env) -> napi::Result<()> { // Logging never keeps the host alive. napi::check_status!(sys::napi_unref_threadsafe_function(env, function))?; } - let previous = CONSOLE.lock().unwrap_or_else(|e| e.into_inner()).replace(Console(function)); - if let Some(Console(previous)) = previous { + let previous = CONSOLE.lock().unwrap_or_else(|e| e.into_inner()).replace(Console(function, thread)); + if let Some(Console(previous, _)) = previous { unsafe { sys::napi_release_threadsafe_function(previous, sys::ThreadsafeFunctionReleaseMode::release) }; } Ok(()) diff --git a/crates/canvas-napi/src/offscreen.rs b/crates/canvas-napi/src/offscreen.rs new file mode 100644 index 000000000..e4634b60b --- /dev/null +++ b/crates/canvas-napi/src/offscreen.rs @@ -0,0 +1,173 @@ +//! `CanvasModule.OffscreenSurface`: viewless, or a transferred canvas's panel +//! (`NSCCanvas.transferToOffscreenSurface`). + +use std::cell::Cell; +use std::ffi::{CStr, CString}; + +use canvas_c::offscreen::*; +use canvas_c::CanvasColorSpace; +use napi::bindgen_prelude::{Either, ObjectFinalize, Unknown}; +use napi::{Env, Result}; +use napi_derive::napi; + +use crate::c2d::CanvasRenderingContext2D; +use crate::gl::web_g_l_rendering_context; +use crate::gl2::web_g_l_2_rendering_context; +use crate::gpu::context::g_p_u_canvas_context; + +#[napi(js_name = "OffscreenSurface", custom_finalize)] +pub struct OffscreenSurface { + /// Null after `dispose()`. + surface: Cell<*const CanvasOffscreenSurface>, +} + +impl ObjectFinalize for OffscreenSurface { + fn finalize(self, _: Env) -> Result<()> { + unsafe { canvas_native_offscreen_surface_release(self.surface.get()) }; + Ok(()) + } +} + +impl OffscreenSurface { + pub(crate) fn from_raw(surface: *const CanvasOffscreenSurface) -> Option { + (!surface.is_null()).then(|| Self { surface: Cell::new(surface) }) + } + + fn ptr(&self) -> *const CanvasOffscreenSurface { + self.surface.get() + } +} + +#[napi] +impl OffscreenSurface { + #[napi] + pub fn create(width: u32, height: u32, density: f64, ppi: f64, direction: u32, color_space: u32) -> Option { + let color_space = if color_space == 1 { CanvasColorSpace::P3 } else { CanvasColorSpace::Srgb }; + Self::from_raw(canvas_native_offscreen_surface_create(width, height, density as f32, ppi as f32, direction, color_space)) + } + + #[napi] + pub fn adopt(handle: u32) -> Option { + Self::from_raw(canvas_native_offscreen_surface_adopt(handle)) + } + + #[napi(js_name = "releaseHandle")] + pub fn release_handle(handle: u32) -> bool { + canvas_native_offscreen_surface_release_handle(handle) + } + + #[napi(getter)] + pub fn width(&self) -> u32 { + unsafe { canvas_native_offscreen_surface_get_width(self.ptr()) } + } + + #[napi(getter)] + pub fn height(&self) -> u32 { + unsafe { canvas_native_offscreen_surface_get_height(self.ptr()) } + } + + #[napi(getter)] + pub fn density(&self) -> f64 { + unsafe { canvas_native_offscreen_surface_get_density(self.ptr()) as f64 } + } + + #[napi(getter)] + pub fn ppi(&self) -> f64 { + unsafe { canvas_native_offscreen_surface_get_ppi(self.ptr()) as f64 } + } + + #[napi(getter)] + pub fn direction(&self) -> u32 { + unsafe { canvas_native_offscreen_surface_get_direction(self.ptr()) } + } + + #[napi(getter, js_name = "hasView")] + pub fn has_view(&self) -> bool { + unsafe { canvas_native_offscreen_surface_has_view(self.ptr()) } + } + + #[napi] + pub fn resize(&self, width: u32, height: u32) { + unsafe { canvas_native_offscreen_surface_resize(self.ptr(), width, height) }; + } + + #[napi(js_name = "toHandle")] + pub fn to_handle(&self) -> u32 { + unsafe { canvas_native_offscreen_surface_to_handle(self.ptr()) } + } + + #[napi] + pub fn dispose(&self) { + let surface = self.surface.replace(std::ptr::null()); + unsafe { canvas_native_offscreen_surface_release(surface) }; + } + + #[napi(js_name = "create2D")] + pub fn create_2d(&self, alpha: bool, font_color: i32, threaded: bool) -> Option { + let context = unsafe { canvas_native_offscreen_surface_create_2d(self.ptr(), alpha, font_color, threaded) }; + (!context.is_null()).then(|| CanvasRenderingContext2D::from_raw(context)) + } + + #[allow(clippy::too_many_arguments)] + #[napi(js_name = "createWebGL")] + pub fn create_webgl( + &self, + version: i32, + alpha: bool, + antialias: bool, + depth: bool, + fail_if_major_performance_caveat: bool, + power_preference: i32, + premultiplied_alpha: bool, + preserve_drawing_buffer: bool, + stencil: bool, + desynchronized: bool, + xr_compatible: bool, + threaded: bool, + ) -> Option> { + let attributes = WebGLAttributes { + version, + alpha, + antialias, + depth, + fail_if_major_performance_caveat, + power_preference, + premultiplied_alpha, + preserve_drawing_buffer, + stencil, + desynchronized, + xr_compatible, + }; + let state = unsafe { canvas_native_offscreen_surface_create_webgl(self.ptr(), &attributes, threaded) }; + if state.is_null() { + return None; + } + Some(if version == 2 { + Either::B(web_g_l_2_rendering_context::from_raw(state)) + } else { + Either::A(web_g_l_rendering_context::from_raw(state)) + }) + } + + /// One wgpu instance per process here, so `gpu` is unused. + #[napi(js_name = "createWebGPU")] + pub fn create_webgpu(&self, _gpu: Unknown) -> Option { + let instance = crate::gpu::gpu_instance(); + let context = unsafe { canvas_native_offscreen_surface_create_webgpu(self.ptr(), std::sync::Arc::as_ptr(&instance)) }; + (!context.is_null()).then(|| g_p_u_canvas_context::from_raw(context)) + } + + /// `quality` is 0..1. + #[napi(js_name = "toDataURL")] + pub fn to_data_url(&self, format: Option, quality: Option) -> Option { + let format = CString::new(format.unwrap_or_else(|| "image/png".into())).ok()?; + let quality = quality.map_or(92, |q| (q * 100.) as u32); + let url = unsafe { canvas_native_offscreen_surface_to_data_url(self.ptr(), format.as_ptr(), quality) }; + if url.is_null() { + return None; + } + let string = unsafe { CStr::from_ptr(url) }.to_string_lossy().into_owned(); + canvas_c::canvas_native_string_destroy(url); + Some(string) + } +} diff --git a/crates/canvas-webgl/src/prelude.rs b/crates/canvas-webgl/src/prelude.rs index 7a634f6de..b89ecd403 100644 --- a/crates/canvas-webgl/src/prelude.rs +++ b/crates/canvas-webgl/src/prelude.rs @@ -648,6 +648,11 @@ impl WebGLState { self.context.attach_xaml_handoff(handoff) } + #[cfg(target_os = "windows")] + pub fn detach_view(&mut self) { + self.context.detach_view() + } + /// `present` for a context on a thread of its own (`GLContext::present_or_hold`). #[cfg(target_os = "windows")] pub fn present_or_hold(&self) -> bool { diff --git a/packages/canvas/Canvas/common.ts b/packages/canvas/Canvas/common.ts index a0d1cb56a..f74c4f9a4 100644 --- a/packages/canvas/Canvas/common.ts +++ b/packages/canvas/Canvas/common.ts @@ -1,6 +1,7 @@ import { CSSType, View, Property, booleanConverter, Screen, ContainerView } from '@nativescript/core'; import type { CanvasRenderingContext } from '../common'; -import { removeItemFromArray } from './utils'; +import { CANVAS_VIEW, removeItemFromArray } from './utils'; +import { OffscreenCanvas } from '../OffscreenCanvas'; export interface ICanvasBase { /** @deprecated The canvas is usable as soon as it exists; call getContext() directly. */ @@ -1317,9 +1318,6 @@ export abstract class CanvasBase extends ContainerView implements ICanvasBase { /** Set by `transferControlToOffscreen()`: the canvas then only shows its OffscreenCanvas's frames. */ protected _transferredToOffscreen = false; - /** Builds the OffscreenCanvas a transferred canvas hands out; set by the OffscreenCanvas module. */ - static _offscreenFromPlaceholder: ((canvas: CanvasBase) => any) | null = null; - /** getContext() without the transfer check, for this canvas's OffscreenCanvas. */ public abstract _getContext(type: string, contextAttributes?: any): CanvasRenderingContext | null; @@ -1347,11 +1345,7 @@ export abstract class CanvasBase extends ContainerView implements ICanvasBase { if (this._hasContext()) { throw domError('InvalidStateError', "Failed to execute 'transferControlToOffscreen' on 'HTMLCanvasElement': Cannot transfer control from a canvas that has a rendering context."); } - const create = CanvasBase._offscreenFromPlaceholder; - if (!create) { - throw new Error('OffscreenCanvas is unavailable: import it from @nativescript/canvas.'); - } - const offscreen = create(this); + const offscreen = OffscreenCanvas._fromPlaceholder(this as any); this._transferredToOffscreen = true; return offscreen; } @@ -1382,3 +1376,5 @@ export abstract class CanvasBase extends ContainerView implements ICanvasBase { return this; } } + +(CanvasBase.prototype as any)[CANVAS_VIEW] = true; diff --git a/packages/canvas/Canvas/index.android.ts b/packages/canvas/Canvas/index.android.ts index f1e58257f..47cabd3e9 100644 --- a/packages/canvas/Canvas/index.android.ts +++ b/packages/canvas/Canvas/index.android.ts @@ -538,6 +538,15 @@ export class Canvas extends CanvasBase { return this._contextType !== ContextType.None; } + /** @internal */ + _createOffscreenSurface() { + const pointer = (this._canvas as any)?.transferToOffscreenSurface?.(); + if (pointer == null || String(pointer) === '0') { + return null; + } + return global.CanvasModule.OffscreenSurface?.fromPointer(BigInt(String(pointer))) ?? null; + } + _resizeBitmap(width: number, height: number) { this.__setSurfaceWidth(width); this.__setSurfaceHeight(height); diff --git a/packages/canvas/Canvas/index.ios.ts b/packages/canvas/Canvas/index.ios.ts index 2f870b33b..cc88518d2 100644 --- a/packages/canvas/Canvas/index.ios.ts +++ b/packages/canvas/Canvas/index.ios.ts @@ -541,6 +541,15 @@ export class Canvas extends CanvasBase { return this._contextType !== ContextType.None; } + /** @internal */ + _createOffscreenSurface() { + const pointer = (this._canvas as any)?.transferToOffscreenSurface?.(); + if (pointer == null || String(pointer) === '0') { + return null; + } + return global.CanvasModule.OffscreenSurface?.fromPointer(BigInt(String(pointer))) ?? null; + } + _resizeBitmap(width: number, height: number) { this.__setSurfaceWidth(width); this.__setSurfaceHeight(height); diff --git a/packages/canvas/Canvas/index.windows.ts b/packages/canvas/Canvas/index.windows.ts index 2fd62a41d..5772a4b4e 100644 --- a/packages/canvas/Canvas/index.windows.ts +++ b/packages/canvas/Canvas/index.windows.ts @@ -208,14 +208,22 @@ export class Canvas extends NapiCanvas { this._imageSize = size; } } + // The panel clips a swapchain to its bounds, but not its children. + const panel = this._panel; + if (panel) { + const clip = new Microsoft.UI.Xaml.Media.RectangleGeometry(); + clip.Rect = { X: 0, Y: 0, Width: panel.ActualWidth || 0, Height: panel.ActualHeight || 0 }; + panel.Clip = clip; + } const [scaleX, scaleY, offsetX, offsetY] = host.surfaceTransform; const shownWidth = width * scaleX; const shownHeight = height * scaleY; image.Width = shownWidth; image.Height = shownHeight; + // A margin, not a translate: an image larger than the panel is clipped to its layout slot + // before its render transform moves it. + image.Margin = { Left: offsetX, Top: offsetY, Right: 0, Bottom: 0 }; const transform = new Microsoft.UI.Xaml.Media.CompositeTransform(); - transform.TranslateX = offsetX; - transform.TranslateY = offsetY; if (this._imageFlipped) { transform.ScaleY = -1; transform.CenterY = shownHeight / 2; diff --git a/packages/canvas/Canvas/napi-host.ts b/packages/canvas/Canvas/napi-host.ts index 3519d676c..71821eff4 100644 --- a/packages/canvas/Canvas/napi-host.ts +++ b/packages/canvas/Canvas/napi-host.ts @@ -54,6 +54,17 @@ export const enum CanvasFit { ScaleDown = 4, } +/** `CanvasOffscreenEvent` and `CanvasOffscreenEngine` (crates/canvas-c/src/offscreen). */ +const enum OffscreenEvent { + Resize = 1, + Engine = 2, +} + +const enum OffscreenEngine { + GL = 2, + GPU = 3, +} + function isFixedLength(value: any) { return value?.unit === 'px' || value?.unit === 'dip'; } @@ -499,6 +510,8 @@ export abstract class NapiCanvas extends CanvasBase { if (this._liveRef) { liveCanvases.delete(this._liveRef); } + // A transferred canvas's OffscreenCanvas draws on without it. + this._canvas?.detachOffscreenSurface?.(); this._canvas = undefined; super.disposeNativeView(); } @@ -529,6 +542,22 @@ export abstract class NapiCanvas extends CanvasBase { return this._contextType !== ContextType.None; } + /** @internal */ + _createOffscreenSurface() { + const ref = new WeakRef(this); + return this._canvas?.transferToOffscreenSurface?.((event: number, a: number, b: number) => ref.deref()?._onOffscreenEvent(event, a, b)) ?? null; + } + + private _onOffscreenEvent(event: number, a: number, b: number) { + if (!this._canvas) { + return; + } + if (event === OffscreenEvent.Engine && a !== OffscreenEngine.GPU) { + this._prepareSurface(b === 1, a === OffscreenEngine.GL); + } + this._syncFit(); + } + _resizeBitmap(width: number, height: number) { this.__setSurfaceWidth(width); this.__setSurfaceHeight(height); @@ -638,6 +667,10 @@ export abstract class NapiCanvas extends CanvasBase { if (this._contextType === ContextType.WebGPU) { return this._gpuContext.__toDataURL(type, encoderOptions); } + const transferred = this._transferredToOffscreen ? this._canvas?.offscreenToDataURL?.(type, encoderOptions) : null; + if (transferred) { + return transferred; + } if (!this.native) { // No context yet: the (transparent) bitmap is still this.width x this.height. const blank = global.CanvasModule.CanvasRenderingContext2D.withCpu(this.width, this.height, 1, true, 0, 96, 0); diff --git a/packages/canvas/Canvas/utils.ts b/packages/canvas/Canvas/utils.ts index 5689602e4..02ea88fd4 100644 --- a/packages/canvas/Canvas/utils.ts +++ b/packages/canvas/Canvas/utils.ts @@ -194,6 +194,13 @@ export function holdBackAnimationFramesWhileBehind() { * copyExternalImageToTexture) take like a Canvas: both hand their context's native object as `native`. * Checked by tag so the consumers need not import the OffscreenCanvas module. */ +/** Lets code a Worker loads recognize a `Canvas` view without importing it. */ +export const CANVAS_VIEW = Symbol.for('@nativescript/canvas:Canvas'); + +export function isCanvasView(value: any): boolean { + return !!value && typeof value === 'object' && value[CANVAS_VIEW] === true; +} + export function isOffscreenCanvas(value: any): boolean { return !!value && typeof value === 'object' && value[Symbol.toStringTag] === 'OffscreenCanvas'; } diff --git a/packages/canvas/Canvas2D/CanvasRenderingContext2D/index.ts b/packages/canvas/Canvas2D/CanvasRenderingContext2D/index.ts index c534ca5ec..4a2d0f512 100644 --- a/packages/canvas/Canvas2D/CanvasRenderingContext2D/index.ts +++ b/packages/canvas/Canvas2D/CanvasRenderingContext2D/index.ts @@ -5,14 +5,14 @@ import { TextMetrics } from '../TextMetrics'; import { ImageSource, Screen, Color, ImageAsset as NSImageAsset } from '@nativescript/core'; import { ImageAsset, fromSvgSource, getSvgNaturalSize, resolveSvgSource, svgToImageAsset } from '../../ImageAsset'; import { CanvasPattern } from '../CanvasPattern'; -import { Canvas } from '../../Canvas'; +import type { Canvas } from '../../Canvas'; import { Helpers } from '../../helpers'; import { DOMMatrix } from '../DOMMatrix'; import type { CanvasRenderingContext } from '../../common'; import { NAPI_HOST } from '../../platform'; import { imageAssetFor } from '../../platform/index'; -import { isOffscreenCanvas } from '../../Canvas/utils'; +import { isCanvasView, isOffscreenCanvas } from '../../Canvas/utils'; declare const NSCCanvasRenderingContext2D; const REPETITIONS = ['repeat', 'repeat-x', 'repeat-y', 'no-repeat']; @@ -368,10 +368,9 @@ export class CanvasRenderingContext2D implements CanvasRenderingContext { private _fillStyleObject: CanvasGradient | CanvasPattern | null = null; private _strokeStyleObject: CanvasGradient | CanvasPattern | null = null; - constructor(context: any, contextOptions) { + constructor(context: any, contextOptions, native?: any) { this.contextPtr = context; - const ctxPtr = BigInt(context.toString()); - this.context = global.CanvasModule.create2DContextWithPointer(ctxPtr); + this.context = native ?? global.CanvasModule.create2DContextWithPointer(BigInt(context.toString())); this._type = '2d'; this._contextAttributes = { alpha: contextOptions?.alpha ?? true, @@ -790,7 +789,7 @@ export class CanvasRenderingContext2D implements CanvasRenderingContext { return null; } else if (image instanceof ImageAsset) { img = image.native; - } else if ((image instanceof Canvas || isOffscreenCanvas(image))) { + } else if ((isCanvasView(image) || isOffscreenCanvas(image))) { img = (image as any).native; } else if (image && typeof image.tagName === 'string' && (image.tagName === 'IMG' || image.tagName === 'IMAGE')) { if (__ANDROID__ && image._image instanceof android.graphics.Bitmap) { @@ -815,7 +814,7 @@ export class CanvasRenderingContext2D implements CanvasRenderingContext { return null; } - } else if (image && typeof image.tagName === 'string' && image.tagName === 'CANVAS' && image._canvas instanceof Canvas) { + } else if (image && typeof image.tagName === 'string' && image.tagName === 'CANVAS' && isCanvasView(image._canvas)) { img = image._canvas.native; } else if (image instanceof ImageBitmap) { img = (image as any).native; @@ -908,7 +907,7 @@ export class CanvasRenderingContext2D implements CanvasRenderingContext { } else if (__APPLE__ && image instanceof UIImage) { drawNativeImage(args, image, this); return; - } else if ((image instanceof Canvas || isOffscreenCanvas(image))) { + } else if ((isCanvasView(image) || isOffscreenCanvas(image))) { image = (image as any).native; } else if (image && typeof image.tagName === 'string' && (image.tagName === 'IMG' || image.tagName === 'IMAGE')) { if (image._imageSource instanceof ImageSource && !NAPI_HOST) { @@ -936,7 +935,7 @@ export class CanvasRenderingContext2D implements CanvasRenderingContext { image = new ImageAsset(); image.fromFileSync(image.src); } - } else if (image && typeof image.tagName === 'string' && image.tagName === 'CANVAS' && image._canvas instanceof Canvas) { + } else if (image && typeof image.tagName === 'string' && image.tagName === 'CANVAS' && isCanvasView(image._canvas)) { image = image._canvas.native; } else if (image && typeof image.tagName === 'string' && (image.tagName === 'VIDEO' || image.tagName === 'VID' || image.tagName.toLowerCase() === 'video') && image._video && typeof image._video.drawImageFrame === 'function') { image._video.drawImageFrame(this, args); @@ -1033,8 +1032,8 @@ export class CanvasRenderingContext2D implements CanvasRenderingContext { isNativeSource = true; } else if (__APPLE__ && image instanceof UIImage) { isNativeSource = true; - } else if ((image instanceof Canvas || isOffscreenCanvas(image))) { - if (__APPLE__) { + } else if ((isCanvasView(image) || isOffscreenCanvas(image))) { + if (__APPLE__ && isCanvasView(image)) { image = _canvasToImageAssetIOS(image as Canvas); } else { image = (image as any).native; @@ -1063,7 +1062,7 @@ export class CanvasRenderingContext2D implements CanvasRenderingContext { image = new ImageAsset(); image.fromFileSync(image.src); } - } else if (image && typeof image.tagName === 'string' && image.tagName === 'CANVAS' && image._canvas instanceof Canvas) { + } else if (image && typeof image.tagName === 'string' && image.tagName === 'CANVAS' && isCanvasView(image._canvas)) { if (__APPLE__) { image = _canvasToImageAssetIOS(image._canvas as Canvas); } else { diff --git a/packages/canvas/ImageBitmap/index.ts b/packages/canvas/ImageBitmap/index.ts index bc157dc82..eef9c008e 100644 --- a/packages/canvas/ImageBitmap/index.ts +++ b/packages/canvas/ImageBitmap/index.ts @@ -1,12 +1,11 @@ import { ImageAsset, fromSvgSource } from '../ImageAsset'; import { ImageData } from '../Canvas2D'; -import { Canvas } from '../Canvas'; import { ImageSource } from '@nativescript/core'; import { Helpers } from '../helpers'; import { NAPI_HOST } from '../platform'; import { imageAssetFor } from '../platform/index'; -import { isOffscreenCanvas } from '../Canvas/utils'; +import { isCanvasView, isOffscreenCanvas } from '../Canvas/utils'; function parseOptions(options) { if (__ANDROID__) { @@ -62,7 +61,7 @@ function invalidSource(source: any) { /** `undefined` for an unrecognised source, so the caller rejects instead of hanging. */ function resolveSource(source: any): any { source = fromSvgSource(source); - if ((source instanceof Canvas || isOffscreenCanvas(source))) { + if ((isCanvasView(source) || isOffscreenCanvas(source))) { return (source as any).native; } if (source instanceof ImageBitmap) { @@ -86,7 +85,7 @@ function resolveSource(source: any): any { if (source.tagName === 'IMG' || source.tagName === 'IMAGE') { return source._asset?.native; } - if (source.tagName === 'CANVAS' && source._canvas instanceof Canvas) { + if (source.tagName === 'CANVAS' && isCanvasView(source._canvas)) { return source._canvas.native; } return undefined; diff --git a/packages/canvas/OffscreenCanvas/index.ts b/packages/canvas/OffscreenCanvas/index.ts index 2acaa349d..c647531f4 100644 --- a/packages/canvas/OffscreenCanvas/index.ts +++ b/packages/canvas/OffscreenCanvas/index.ts @@ -1,18 +1,15 @@ -/** - * `OffscreenCanvas`: a canvas with no view. - * - * It draws through the same native contexts as `Canvas`, on a detached `Canvas` made at the - * first getContext(), or, after `canvas.transferControlToOffscreen()`, on that canvas's own - * surface so the frames show in its view. The contexts belong to the UI thread, so it cannot - * move to a Worker yet. - */ -import { Canvas } from '../Canvas'; -import { CanvasBase } from '../Canvas/common'; +// Imports no view, so a Worker can load it. +import type { Canvas } from '../Canvas'; import { CanvasRenderingContext2D } from '../Canvas2D/CanvasRenderingContext2D'; import { ImageBitmap } from '../ImageBitmap'; import { ImageBitmapRenderingContext } from '../ImageBitmapRenderingContext'; -import { handleContextOptions } from '../Canvas/utils'; +import { handleContextOptions, holdBackAnimationFramesWhileBehind, parsePowerPreference } from '../Canvas/utils'; import { Helpers } from '../helpers'; +import { WebGLRenderingContext } from '../WebGL/WebGLRenderingContext'; +import { WebGL2RenderingContext } from '../WebGL2/WebGL2RenderingContext'; +import { GPUCanvasContext } from '../WebGPU/GPUCanvasContext'; + +declare const navigator: any; export type OffscreenRenderingContextId = '2d' | 'bitmaprenderer' | 'webgl' | 'webgl2' | 'webgpu'; @@ -184,10 +181,15 @@ function clearWebGL(gl: any) { } export class OffscreenCanvas { + /** A canvas shown in a view is always threaded. */ + static threaded2D = true; + static threadedWebGL = true; + private _width: number; private _height: number; - /** Detached, or the placeholder canvas. Made at the first getContext(). */ + private _surface: any = null; private _host: Canvas | null = null; + private _detached = false; private _contextId: OffscreenRenderingContextId | null = null; private _context: any = null; private _listeners: Map | null = null; @@ -203,31 +205,83 @@ export class OffscreenCanvas { this._height = enforceRange(height, "Failed to construct 'OffscreenCanvas'"); } - /** @internal `canvas.transferControlToOffscreen()`: draws into the canvas's own surface. */ + /** @internal */ static _fromPlaceholder(canvas: Canvas): OffscreenCanvas { + const surface = surfaces() ? (canvas as any)._createOffscreenSurface?.() : null; + if (surface) { + return OffscreenCanvas._fromSurface(surface); + } const offscreen = new OffscreenCanvas(canvas.width, canvas.height); offscreen._attachHost(canvas); return offscreen; } + /** @internal */ + static _fromSurface(surface: any): OffscreenCanvas { + const offscreen = new OffscreenCanvas(surface.width, surface.height); + offscreen._surface = surface; + return offscreen; + } + + /** @internal Detaches `offscreen`, as a transfer does. */ + static _toHandle(offscreen: OffscreenCanvas): number { + if (!(offscreen instanceof OffscreenCanvas)) { + throw new TypeError("Failed to transfer: the value is not an 'OffscreenCanvas'."); + } + if (offscreen._detached) { + throw domError('DataCloneError', 'An OffscreenCanvas could not be transferred because it was detached.'); + } + if (offscreen._context) { + throw domError('InvalidStateError', 'An OffscreenCanvas could not be transferred because it had a rendering context.'); + } + const surface = offscreen._ensureSurface(); + if (!surface) { + throw domError('NotSupportedError', 'An OffscreenCanvas can not be transferred on this platform yet.'); + } + const handle = surface.toHandle(); + if (!handle) { + throw domError('DataCloneError', 'An OffscreenCanvas could not be transferred.'); + } + surface.dispose(); + offscreen._surface = null; + offscreen._detached = true; + return handle; + } + + /** @internal */ + static _fromHandle(handle: number): OffscreenCanvas { + const surface = surfaces()?.adopt(handle) ?? null; + if (!surface) { + throw domError('DataCloneError', 'The OffscreenCanvas was already received, or released.'); + } + return OffscreenCanvas._fromSurface(surface); + } + + /** @internal */ + static _releaseHandle(handle: number): boolean { + return !!surfaces()?.releaseHandle(handle); + } + get [Symbol.toStringTag]() { return 'OffscreenCanvas'; } get width(): number { - return this._width; + return this._detached ? 0 : this._width; } set width(value: number) { + this._assertNotDetached('width'); this._width = enforceRange(value, "Failed to set the 'width' property on 'OffscreenCanvas'"); this._resize(); } get height(): number { - return this._height; + return this._detached ? 0 : this._height; } set height(value: number) { + this._assertNotDetached('height'); this._height = enforceRange(value, "Failed to set the 'height' property on 'OffscreenCanvas'"); this._resize(); } @@ -250,25 +304,15 @@ export class OffscreenCanvas { if (CONTEXT_IDS.indexOf(id) === -1) { throw new TypeError(`Failed to execute 'getContext' on 'OffscreenCanvas': The provided value '${id}' is not a valid enum value of type OffscreenRenderingContextType.`); } + if (this._detached) { + throw domError('InvalidStateError', "Failed to execute 'getContext' on 'OffscreenCanvas': OffscreenCanvas object is detached."); + } if (this._context) { return this._contextId === id ? this._context : null; } - const host = this._ensureHost(); - let context: any = null; - if (id === 'bitmaprenderer') { - // Backed by a 2D surface that is never handed out, as Canvas does it. - const backing = host._getContext('2d', options); - if (backing) { - context = new ImageBitmapRenderingContext(this as any, backing, handleContextOptions('bitmaprenderer', options)); - } - } else { - context = host._getContext(id, options); - if (context) { - Object.defineProperty(context, 'canvas', { value: this, configurable: true }); - } - } - + const surface = this._ensureSurface(); + const context = surface ? this._surfaceContext(surface, id, options) : this._hostContext(id, options); if (context) { this._context = context; this._contextId = id; @@ -277,6 +321,9 @@ export class OffscreenCanvas { } transferToImageBitmap(): ImageBitmap { + if (this._detached) { + throw domError('InvalidStateError', "Failed to execute 'transferToImageBitmap' on 'OffscreenCanvas': Cannot transfer an ImageBitmap from a detached OffscreenCanvas"); + } const context = this._context; if (!context) { throw domError('InvalidStateError', "Failed to execute 'transferToImageBitmap' on 'OffscreenCanvas': Cannot transfer an ImageBitmap from an OffscreenCanvas with no context"); @@ -314,6 +361,10 @@ export class OffscreenCanvas { convertToBlob(options?: ImageEncodeOptions): Promise { return new Promise((resolve, reject) => { + if (this._detached) { + reject(domError('InvalidStateError', "Failed to execute 'convertToBlob' on 'OffscreenCanvas': OffscreenCanvas object is detached.")); + return; + } if (this._width === 0 || this._height === 0) { reject(domError('IndexSizeError', "Failed to execute 'convertToBlob' on 'OffscreenCanvas': The size of the OffscreenCanvas is zero.")); return; @@ -405,13 +456,101 @@ export class OffscreenCanvas { return !event.defaultPrevented; } - private _ensureHost(): Canvas { - if (!this._host) { - const host = Canvas.createCustomView(); - host._resizeBitmap(this._width, this._height); - this._attachHost(host); + private _assertNotDetached(name: 'width' | 'height') { + if (this._detached) { + throw domError('InvalidStateError', `Failed to set the '${name}' property on 'OffscreenCanvas': OffscreenCanvas object is detached.`); } - return this._host; + } + + private _ensureSurface(): any { + if (!this._surface && !this._host) { + this._surface = surfaces()?.create(this._width, this._height, 1, 160, 0, 0) ?? null; + } + return this._surface; + } + + private _surfaceContext(surface: any, id: OffscreenRenderingContextId, options?: any): any { + const shown = surface.hasView; + let context: any = null; + switch (id) { + case '2d': + context = this._surface2D(surface, '2d', options, shown); + break; + case 'bitmaprenderer': { + const backing = this._surface2D(surface, 'bitmaprenderer', options, shown); + if (backing) { + context = new ImageBitmapRenderingContext(this as any, backing, handleContextOptions('bitmaprenderer', options)); + } + return context; + } + case 'webgl': + case 'webgl2': { + const opts = handleContextOptions(id, options); + const threaded = shown || !!(options?.threaded ?? OffscreenCanvas.threadedWebGL); + const powerPreference = typeof opts.powerPreference === 'number' ? opts.powerPreference : parsePowerPreference(opts.powerPreference); + const native = surface.createWebGL(id === 'webgl2' ? 2 : 1, opts.alpha, opts.antialias, opts.depth, opts.failIfMajorPerformanceCaveat, powerPreference < 0 ? 0 : powerPreference, opts.premultipliedAlpha, opts.preserveDrawingBuffer, opts.stencil, opts.desynchronized, opts.xrCompatible, threaded); + if (!native) { + return null; + } + if (threaded) { + holdBackAnimationFramesWhileBehind(); + } + context = id === 'webgl2' ? new (WebGL2RenderingContext as any)(null, null, native) : new (WebGLRenderingContext as any)(native); + context._type = id; + break; + } + case 'webgpu': { + const gpu = typeof navigator !== 'undefined' ? navigator?.gpu?.native : null; + const native = gpu ? surface.createWebGPU(gpu) : null; + if (!native) { + return null; + } + context = new (GPUCanvasContext as any)(null, {}, native); + break; + } + } + if (context) { + context._canvas = this; + Object.defineProperty(context, 'canvas', { value: this, configurable: true }); + } + return context; + } + + private _surface2D(surface: any, type: '2d' | 'bitmaprenderer', options: any, shown: boolean): CanvasRenderingContext2D | null { + const opts = handleContextOptions(type, options); + const threaded = shown || !!(options?.threaded ?? OffscreenCanvas.threaded2D); + const native = surface.create2D(opts.alpha, -16777216, threaded); + if (!native) { + return null; + } + if (threaded) { + holdBackAnimationFramesWhileBehind(); + } + const context = new (CanvasRenderingContext2D as any)(null, opts, native); + context._type = '2d'; + context.__threaded = threaded; + context._canvas = this; + return context; + } + + private _hostContext(id: OffscreenRenderingContextId, options?: any): any { + const host = this._host; + if (!host) { + return null; + } + let context: any = null; + if (id === 'bitmaprenderer') { + const backing = host._getContext('2d', options); + if (backing) { + context = new ImageBitmapRenderingContext(this as any, backing, handleContextOptions('bitmaprenderer', options)); + } + } else { + context = host._getContext(id, options); + if (context) { + Object.defineProperty(context, 'canvas', { value: this, configurable: true }); + } + } + return context; } private _attachHost(host: Canvas) { @@ -443,10 +582,15 @@ export class OffscreenCanvas { /** Per spec a 2D context resets even when the size is unchanged. */ private _resize() { - if (!this._host) { + if (this._surface) { + const backing = this._contextId === 'bitmaprenderer' ? (this._context as any)?._context : this._contextId === '2d' ? this._context : null; + backing?.native?.__resize(this._width, this._height); + this._surface.resize(this._width, this._height); + } else if (this._host) { + this._host._resizeBitmap(this._width, this._height); + } else { return; } - this._host._resizeBitmap(this._width, this._height); if (this._contextId === '2d') { this._context.reset(); } @@ -461,19 +605,15 @@ export class OffscreenCanvas { return native.__toDataURL(type, quality); } // No context: the bitmap is transparent black. - const scratch = Canvas.createCustomView(); - try { - scratch._resizeBitmap(this._width, this._height); - return (scratch._getContext('2d', { threaded: false }) as any)?.native?.__toDataURL(type, quality) ?? 'data:,'; - } finally { - try { - scratch.disposeNativeView(); - } catch (e) {} - } + const blank = global.CanvasModule.CanvasRenderingContext2D.withCpu(this._width || 1, this._height || 1, 1, true, 0, 160, 0); + return blank?.__toDataURL(type, quality) ?? 'data:,'; } } -CanvasBase._offscreenFromPlaceholder = (canvas) => OffscreenCanvas._fromPlaceholder(canvas as Canvas); +function surfaces(): any { + Helpers.initialize(); + return global.CanvasModule?.OffscreenSurface ?? null; +} /** Only a 2D context whose canvas is an OffscreenCanvas matches; there is no separate class. */ export class OffscreenCanvasRenderingContext2D { diff --git a/packages/canvas/README.md b/packages/canvas/README.md index f9ac304d3..8981be119 100644 --- a/packages/canvas/README.md +++ b/packages/canvas/README.md @@ -103,8 +103,50 @@ const blob = await sprite.convertToBlob({ type: 'image/png' }); ``` `canvas.transferControlToOffscreen()` returns an OffscreenCanvas that draws into that canvas's -surface, so its frames show in the view. It runs on the UI thread like any canvas; it can't move to -a Worker yet. With the polyfill, `OffscreenCanvas` is also a global. +surface, so its frames show in the view. With the polyfill, `OffscreenCanvas` is also a global. + +#### In a Worker + +An OffscreenCanvas can move to a Worker, which then draws into it on its own thread: a transferred +canvas keeps showing the Worker's frames in its view. Where the runtime can transfer it (NativeScript +Windows, with transfer support in `postMessage`), it moves as on the web: + +```typescript +// Main thread +const offscreen = canvas.transferControlToOffscreen(); +worker.postMessage({ canvas: offscreen }, [offscreen]); +``` + +```typescript +// The Worker +import '@nativescript/canvas/worker'; + +self.onmessage = ({ data }) => { + const canvas: OffscreenCanvas = data.canvas; + const ctx = canvas.getContext('2d'); + const draw = () => { + ctx.fillRect(0, 0, canvas.width, canvas.height); + requestAnimationFrame(draw); + }; + requestAnimationFrame(draw); +}; +``` + +Elsewhere (the Android and iOS runtimes, for now), send a handle instead: + +```typescript +// Main thread +worker.postMessage({ canvas: OffscreenCanvas._toHandle(offscreen) }); + +// The Worker +import { OffscreenCanvas } from '@nativescript/canvas/worker'; +const canvas = OffscreenCanvas._fromHandle(data.canvas); +``` + +- `@nativescript/canvas/worker` is the canvas API without the `Canvas` view: import it first in the Worker. +- A transfer (or `_toHandle`) detaches the OffscreenCanvas it is given, and it can't have a context yet. A handle is received once; `OffscreenCanvas._releaseHandle(handle)` drops one nothing will receive. +- `2d`, `bitmaprenderer`, `webgl`, `webgl2` and `webgpu` all work there, as does `new OffscreenCanvas(w, h)` made in the Worker, which can be transferred back. +- Resizing it in the Worker resizes the view on the UI thread a moment later. ## WebGPU diff --git a/packages/canvas/WebGL/WebGLRenderingContext/index.ts b/packages/canvas/WebGL/WebGLRenderingContext/index.ts index d1b257f24..5651554fc 100644 --- a/packages/canvas/WebGL/WebGLRenderingContext/index.ts +++ b/packages/canvas/WebGL/WebGLRenderingContext/index.ts @@ -38,13 +38,12 @@ import { WEBGL_lose_context, } from '../WebGLExtensions'; import { ImageAsset, fromSvgSource } from '../../ImageAsset'; -import { Canvas } from '../../Canvas'; import { ImageBitmap } from '../../ImageBitmap'; import { Helpers } from '../../helpers'; import { NAPI_HOST, POINTER_CONTEXT_HOST } from '../../platform'; import { imageAssetFor } from '../../platform/index'; -import { isOffscreenCanvas } from '../../Canvas/utils'; +import { isCanvasView, isOffscreenCanvas } from '../../Canvas/utils'; enum ContextType { None, @@ -131,14 +130,14 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { get drawingBufferHeight() { if (__ANDROID__) { - return this._canvas?.drawingBufferHeight ?? 0; + return this._canvas?.drawingBufferHeight ?? this.native.drawingBufferHeight; } return this.native.drawingBufferHeight; } get drawingBufferWidth() { if (__ANDROID__) { - return this._canvas?.drawingBufferWidth ?? 0; + return this._canvas?.drawingBufferWidth ?? this.native.drawingBufferWidth; } return this.native.drawingBufferWidth; } @@ -901,7 +900,7 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { this.native.texImage2D(target, level, internalformat, width, height, border, format, type ?? internalformat, pixels.native); } else if (pixels instanceof ImageBitmap) { this.native.texImage2D(target, level, internalformat, width, height, border, format, type ?? internalformat, pixels.native); - } else if ((pixels instanceof Canvas || isOffscreenCanvas(pixels))) { + } else if ((isCanvasView(pixels) || isOffscreenCanvas(pixels))) { this.native.texImage2D(target, level, internalformat, width, height, border, format, type ?? internalformat, pixels.native); } else if (__ANDROID__ && pixels instanceof android.graphics.Bitmap) { (org).nativescript.canvas.NSCWebGLRenderingContext.texImage2D(java.lang.Long.valueOf(this._contextPtr), target, level, internalformat, width, height, pixels, this.native.__flipY); @@ -938,7 +937,7 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { (org).nativescript.canvas.NSCWebGLRenderingContext.texImage2D(java.lang.Long.valueOf(this._contextPtr), target, level, internalformat, width, height, ImageSource.fromFileSync(pixels.src).android, this.native.__flipY); } } - } else if (pixels && typeof pixels.tagName === 'string' && pixels.tagName === 'CANVAS' && pixels._canvas instanceof Canvas) { + } else if (pixels && typeof pixels.tagName === 'string' && pixels.tagName === 'CANVAS' && isCanvasView(pixels._canvas)) { this.native.texImage2D(target, level, internalformat, width, height, border, format, type, pixels._canvas.native); } else if (pixels instanceof ImageData) { this.native.texImage2D(target, level, internalformat, width, height, border, format, type, (pixels).native); @@ -957,7 +956,7 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { this.native.texImage2D(target, level, internalformat, width, height, border.native); } else if (border instanceof ImageBitmap) { this.native.texImage2D(target, level, internalformat, width, height, border.native); - } else if ((border instanceof Canvas || isOffscreenCanvas(border))) { + } else if ((isCanvasView(border) || isOffscreenCanvas(border))) { this.native.texImage2D(target, level, internalformat, width, height, border.native); } else if (__ANDROID__ && border instanceof android.graphics.Bitmap) { (org).nativescript.canvas.NSCWebGLRenderingContext.texImage2D(java.lang.Long.valueOf(this._contextPtr), target, level, internalformat, width, height, border, this.native.__flipY); @@ -984,7 +983,7 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { (org).nativescript.canvas.NSCWebGLRenderingContext.texImage2D(java.lang.Long.valueOf(this._contextPtr), target, level, internalformat, width, height, ImageSource.fromFileSync(border.src).android, this.native.__flipY); } } - } else if (border && typeof border.tagName === 'string' && border.tagName === 'CANVAS' && border._canvas instanceof Canvas) { + } else if (border && typeof border.tagName === 'string' && border.tagName === 'CANVAS' && isCanvasView(border._canvas)) { this.native.texImage2D(target, level, internalformat, width, height, border._canvas.native); } else if (border instanceof ImageData) { this.native.texImage2D(target, level, internalformat, width, height, (border).native); @@ -1037,7 +1036,7 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { } } else if (format instanceof ImageAsset) { this.native.texSubImage2D(target, level, xoffset, yoffset, width, height, format.native); - } else if ((format instanceof Canvas || isOffscreenCanvas(format))) { + } else if ((isCanvasView(format) || isOffscreenCanvas(format))) { this.native.texSubImage2D(target, level, xoffset, yoffset, width, height, format.native); } else if (format instanceof ImageBitmap) { this.native.texSubImage2D(target, level, xoffset, yoffset, width, height, format.native); @@ -1064,7 +1063,7 @@ export class WebGLRenderingContextBase extends WebGLRenderingCommon { this.native.texSubImage2D(target, level, xoffset, yoffset, width, height, result.ios); } } - } else if (format && typeof format.tagName === 'string' && format.tagName === 'CANVAS' && format._canvas instanceof Canvas) { + } else if (format && typeof format.tagName === 'string' && format.tagName === 'CANVAS' && isCanvasView(format._canvas)) { this.native.texSubImage2D(target, level, xoffset, yoffset, width, height, format._canvas.native); } else if (format instanceof ImageData) { this.native.texSubImage2D(target, level, xoffset, yoffset, width, height, (format).native); diff --git a/packages/canvas/WebGL2/WebGL2RenderingContext/index.ts b/packages/canvas/WebGL2/WebGL2RenderingContext/index.ts index 6c038121b..b47475ea1 100644 --- a/packages/canvas/WebGL2/WebGL2RenderingContext/index.ts +++ b/packages/canvas/WebGL2/WebGL2RenderingContext/index.ts @@ -16,13 +16,12 @@ import { WebGLBuffer } from '../../WebGL/WebGLBuffer'; import { ImageAsset, fromSvgSource } from '../../ImageAsset'; import { ImageSource, Screen } from '@nativescript/core'; import { WebGL2RenderingContextBase } from './common'; -import { Canvas } from '../../Canvas'; import { ImageBitmap } from '../../ImageBitmap'; import { Helpers } from '../../helpers'; import { NAPI_HOST, POINTER_CONTEXT_HOST } from '../../platform'; import { imageAssetFor } from '../../platform/index'; -import { isOffscreenCanvas } from '../../Canvas/utils'; +import { isCanvasView, isOffscreenCanvas } from '../../Canvas/utils'; export class WebGL2RenderingContext extends WebGL2RenderingContextBase { static { @@ -31,8 +30,12 @@ export class WebGL2RenderingContext extends WebGL2RenderingContextBase { _context; _contextPtr: string; - constructor(context, contextOptions) { + constructor(context, contextOptions, existing?: any) { super(null); + if (existing) { + this._context = existing; + return; + } let nativeContext = '0'; if (__ANDROID__) { nativeContext = context.getNativeContextString(); @@ -419,7 +422,7 @@ export class WebGL2RenderingContext extends WebGL2RenderingContextBase { this.native.texImage3D(target, level, internalformat, width, height, depth, border, format, type, NAPI_HOST ? imageAssetFor(source)?.native : source.android); } else if (source instanceof ImageAsset) { this.native.texImage3D(target, level, internalformat, width, height, depth, border, format, type, source.native); - } else if ((source instanceof Canvas || isOffscreenCanvas(source))) { + } else if ((isCanvasView(source) || isOffscreenCanvas(source))) { this.native.texImage3D(target, level, internalformat, width, height, depth, border, format, type, source.native); } else if (source instanceof ImageBitmap) { this.native.texImage3D(target, level, internalformat, width, height, depth, border, format, type, source.native); @@ -434,7 +437,7 @@ export class WebGL2RenderingContext extends WebGL2RenderingContextBase { const result = ImageSource.fromFileSync(source.src); this.native.texImage3D(target, level, internalformat, width, height, depth, border, format, type, result ? (NAPI_HOST ? imageAssetFor(result)?.native : result.android) : null); } - } else if (source && typeof source.tagName === 'string' && source.tagName === 'CANVAS' && source._canvas instanceof Canvas) { + } else if (source && typeof source.tagName === 'string' && source.tagName === 'CANVAS' && isCanvasView(source._canvas)) { this.native.texImage3D(target, level, internalformat, width, height, depth, border, format, type, source._canvas.native); } } @@ -472,7 +475,7 @@ export class WebGL2RenderingContext extends WebGL2RenderingContextBase { this.native.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, NAPI_HOST ? imageAssetFor(srcData)?.native : srcData.android); } else if (srcData instanceof ImageAsset) { this.native.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, srcData.native); - } else if ((srcData instanceof Canvas || isOffscreenCanvas(srcData))) { + } else if ((isCanvasView(srcData) || isOffscreenCanvas(srcData))) { this.native.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, srcData.native); } else if (srcData instanceof ImageBitmap) { this.native.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, srcData.native); @@ -487,7 +490,7 @@ export class WebGL2RenderingContext extends WebGL2RenderingContextBase { const result = ImageSource.fromFileSync(srcData.src); this.native.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, result ? (NAPI_HOST ? imageAssetFor(result)?.native : result.android) : null); } - } else if (srcData && typeof srcData.tagName === 'string' && srcData.tagName === 'CANVAS' && srcData._canvas instanceof Canvas) { + } else if (srcData && typeof srcData.tagName === 'string' && srcData.tagName === 'CANVAS' && isCanvasView(srcData._canvas)) { this.native.texSubImage3D(target, level, xoffset, yoffset, zoffset, width, height, depth, format, type, srcData._canvas.native); } } diff --git a/packages/canvas/WebGPU/GPUCanvasContext.ts b/packages/canvas/WebGPU/GPUCanvasContext.ts index ff672d864..ca6a42b1f 100644 --- a/packages/canvas/WebGPU/GPUCanvasContext.ts +++ b/packages/canvas/WebGPU/GPUCanvasContext.ts @@ -29,7 +29,12 @@ export class GPUCanvasContext implements CanvasRenderingContext { this._swapchainViews.push(view); } - constructor(context: any, contextOptions: any = {}) { + constructor(context: any, contextOptions: any = {}, native?: any) { + this._type = 'webgpu'; + if (native) { + this[native_] = native; + return; + } let nativeContext = '0'; if (__ANDROID__) { nativeContext = context.getNativeContext().toString(); @@ -244,8 +249,13 @@ export class GPUCanvasContext implements CanvasRenderingContext { __toDataURL(type: string, quality: number) { if (this[device_]) { return this.native.__toDataURL(type, quality); - } else { - return (this.canvas)._canvas.toDataURL(type, quality); } + // Unconfigured: a blank bitmap. + const canvas: any = this.canvas; + if (canvas?._canvas?.toDataURL) { + return canvas._canvas.toDataURL(type, quality); + } + const blank = global.CanvasModule.CanvasRenderingContext2D.withCpu(canvas?.width || 300, canvas?.height || 150, 1, true, 0, 160, 0); + return blank.__toDataURL(type, quality); } } diff --git a/packages/canvas/WebGPU/GPUQueue.ts b/packages/canvas/WebGPU/GPUQueue.ts index 718c92347..2fc09e79c 100644 --- a/packages/canvas/WebGPU/GPUQueue.ts +++ b/packages/canvas/WebGPU/GPUQueue.ts @@ -1,11 +1,10 @@ -import { Canvas } from '../Canvas'; import { ImageAsset, fromSvgSource } from '../ImageAsset'; import { GPUTextureUsage, native_ } from './Constants'; import { GPUBuffer } from './GPUBuffer'; import { GPUCommandBuffer } from './GPUCommandBuffer'; import { GPUImageCopyExternalImage, GPUImageCopyTexture, GPUImageCopyTextureTagged, GPUImageDataLayout } from './Interfaces'; import { GPUExtent3D } from './Types'; -import { isOffscreenCanvas } from '../Canvas/utils'; +import { isCanvasView, isOffscreenCanvas } from '../Canvas/utils'; export class GPUQueue { [native_]: any; @@ -97,11 +96,11 @@ export class GPUQueue { } else if (source.source?._type === '2d' || source.source?._type?.indexOf('webgl') > -1 || source.source?._type === 'webgpu') { _keepAlive = source.source; src.source = (source.source as any).native; - } else if ((source.source instanceof Canvas || isOffscreenCanvas(source.source))) { + } else if ((isCanvasView(source.source) || isOffscreenCanvas(source.source))) { _keepAlive = source.source; src.source = source.source.native; } else if (typeof source.source.tagName === 'string' && source.source.tagName === 'CANVAS') { - if (source.source._canvas instanceof Canvas) { + if (isCanvasView(source.source._canvas)) { _keepAlive = source.source._canvas; src.source = source.source._canvas.native; } diff --git a/packages/canvas/globals.ts b/packages/canvas/globals.ts new file mode 100644 index 000000000..d847f69f9 --- /dev/null +++ b/packages/canvas/globals.ts @@ -0,0 +1,50 @@ +// None of these is, or loads, a view. +import { TextEncoder } from './TextEncoder'; +import { TextDecoder } from './TextDecoder'; +import { ImageBitmap } from './ImageBitmap'; +import { ImageBitmapRenderingContext } from './ImageBitmapRenderingContext'; +import { OffscreenCanvas, OffscreenCanvasRenderingContext2D } from './OffscreenCanvas'; +import { ImageAsset } from './ImageAsset'; +import { CanvasPattern, CanvasGradient, Path2D, ImageData, DOMMatrix } from './Canvas2D'; +import { CanvasRenderingContext2D } from './Canvas2D/CanvasRenderingContext2D'; +import { WebGLRenderingContext } from './WebGL/WebGLRenderingContext'; +import { WebGL2RenderingContext } from './WebGL2/WebGL2RenderingContext'; +import { GPUBufferUsage, GPUMapMode, GPUShaderStage, GPUTextureUsage } from './WebGPU/Constants'; + +export function installCanvasGlobals() { + const values = { + OffscreenCanvas, + OffscreenCanvasRenderingContext2D, + CanvasRenderingContext2D, + WebGLRenderingContext, + WebGL2RenderingContext, + CanvasPattern, + CanvasGradient, + TextEncoder, + TextDecoder, + Path2D, + ImageData, + DOMMatrix, + ImageBitmap, + ImageBitmapRenderingContext, + ImageAsset, + GPUBufferUsage, + GPUTextureUsage, + GPUMapMode, + GPUShaderStage, + }; + for (const name of Object.keys(values)) { + Object.defineProperty(global, name, { + value: values[name], + configurable: true, + writable: true, + }); + } + // Runtimes with transfer hooks (NativeScript Windows) then move it with postMessage(…, [canvas]). + (global as any).__nsRegisterTransferable?.('OffscreenCanvas', { + test: (value: any) => value instanceof OffscreenCanvas, + detach: (canvas: OffscreenCanvas) => OffscreenCanvas._toHandle(canvas), + attach: (handle: number) => OffscreenCanvas._fromHandle(handle), + release: (handle: number) => OffscreenCanvas._releaseHandle(handle), + }); +} diff --git a/packages/canvas/index.ts b/packages/canvas/index.ts index 4fdc85c09..94c536bb7 100644 --- a/packages/canvas/index.ts +++ b/packages/canvas/index.ts @@ -1,18 +1,6 @@ import { TouchEvent, PointerEvent, CustomEvent } from './Canvas/common'; -import { TextEncoder } from './TextEncoder'; -import { TextDecoder } from './TextDecoder'; -import { ImageBitmap } from './ImageBitmap'; -import { ImageBitmapRenderingContext } from './ImageBitmapRenderingContext'; -import { OffscreenCanvas, OffscreenCanvasRenderingContext2D } from './OffscreenCanvas'; -import { ImageAsset } from './ImageAsset'; - -import { CanvasPattern, CanvasGradient, Path2D, ImageData, DOMMatrix } from './Canvas2D'; - -import { CanvasRenderingContext2D } from './Canvas2D/CanvasRenderingContext2D'; -import { WebGLRenderingContext } from './WebGL/WebGLRenderingContext'; -import { WebGL2RenderingContext } from './WebGL2/WebGL2RenderingContext'; -import { GPUBufferUsage, GPUMapMode, GPUShaderStage, GPUTextureUsage } from './WebGPU/Constants'; +import { installCanvasGlobals } from './globals'; export * from './Canvas2D'; export * from './ImageBitmap'; @@ -87,6 +75,8 @@ if (__APPLE__) { }); } +installCanvasGlobals(); + Object.defineProperty(global, 'fonts', { value: new FontFaceSet(), configurable: true, @@ -99,90 +89,6 @@ Object.defineProperty(global, 'FontFace', { writable: true, }); -Object.defineProperty(global, 'OffscreenCanvas', { - value: OffscreenCanvas, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'OffscreenCanvasRenderingContext2D', { - value: OffscreenCanvasRenderingContext2D, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'CanvasRenderingContext2D', { - value: CanvasRenderingContext2D, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'WebGLRenderingContext', { - value: WebGLRenderingContext, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'WebGL2RenderingContext', { - value: WebGL2RenderingContext, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'CanvasPattern', { - value: CanvasPattern, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'CanvasGradient', { - value: CanvasGradient, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'TextEncoder', { - value: TextEncoder, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'TextDecoder', { - value: TextDecoder, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'Path2D', { - value: Path2D, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'ImageData', { - value: ImageData, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'DOMMatrix', { - value: DOMMatrix, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'ImageBitmap', { - value: ImageBitmap, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'ImageBitmapRenderingContext', { - value: ImageBitmapRenderingContext, - configurable: true, - writable: true, -}); - Object.defineProperty(global, 'TouchEvent', { value: TouchEvent, configurable: true, @@ -195,36 +101,6 @@ Object.defineProperty(global, 'PointerEvent', { writable: true, }); -Object.defineProperty(global, 'ImageAsset', { - value: ImageAsset, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'GPUBufferUsage', { - value: GPUBufferUsage, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'GPUTextureUsage', { - value: GPUTextureUsage, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'GPUMapMode', { - value: GPUMapMode, - configurable: true, - writable: true, -}); - -Object.defineProperty(global, 'GPUShaderStage', { - value: GPUShaderStage, - configurable: true, - writable: true, -}); - Object.defineProperty(global, 'CustomEvent', { value: CustomEvent, configurable: true, diff --git a/packages/canvas/platforms/ios/src/NSOperationQueueWrapper.mm b/packages/canvas/platforms/ios/src/NSOperationQueueWrapper.mm index 308f4c3d7..20dbdea5d 100644 --- a/packages/canvas/platforms/ios/src/NSOperationQueueWrapper.mm +++ b/packages/canvas/platforms/ios/src/NSOperationQueueWrapper.mm @@ -25,7 +25,8 @@ - (instancetype)init { -(instancetype)initWithCurrentQueue { self = [super init]; if (self) { - current = CFRunLoopGetCurrent(); + // A callback can outlive a Worker's thread. + current = (__CFRunLoop*)CFRetain(CFRunLoopGetCurrent()); } return self; } @@ -38,7 +39,12 @@ -(instancetype)initWithSerial:(BOOL)serial { return self; } -- (void)dealloc {} +- (void)dealloc { + if (current != NULL) { + CFRelease(current); + current = NULL; + } +} - (void)addOperation:(void (^)())task { diff --git a/packages/canvas/platforms/ios/src/cpp/Caches.h b/packages/canvas/platforms/ios/src/cpp/Caches.h index 4bee1d2b2..e8b270a08 100644 --- a/packages/canvas/platforms/ios/src/cpp/Caches.h +++ b/packages/canvas/platforms/ios/src/cpp/Caches.h @@ -63,6 +63,9 @@ class Caches { std::unique_ptr> ImageBitmapTmpl = std::unique_ptr>( nullptr); + std::unique_ptr> OffscreenSurfaceTmpl = std::unique_ptr>( + nullptr); + std::unique_ptr> ANGLE_instanced_arraysTmpl = std::unique_ptr>( nullptr); diff --git a/packages/canvas/platforms/ios/src/cpp/CanvasJSIModule.cpp b/packages/canvas/platforms/ios/src/cpp/CanvasJSIModule.cpp index 6e62c4562..1583e7459 100644 --- a/packages/canvas/platforms/ios/src/cpp/CanvasJSIModule.cpp +++ b/packages/canvas/platforms/ios/src/cpp/CanvasJSIModule.cpp @@ -121,9 +121,12 @@ void CanvasJSIModule::install(v8::Isolate *isolate) { auto context = isolate->GetCurrentContext(); auto global = context->Global(); - if (!global-> - HasOwnProperty(context, ConvertToV8String(isolate, "CanvasJSIModule")).FromMaybe( - false)) { + // Not the CanvasModule global: on iOS that is the ObjC class first. + auto installed = v8::Private::ForApi(isolate, ConvertToV8String(isolate, "nsc:CanvasModule")); + if (!global->HasPrivate(context, installed).FromMaybe(false)) { + global->SetPrivate(context, installed, v8::True(isolate)).FromJust(); + // A new isolate may reuse a dead Worker's address. + Caches::Remove(isolate); v8::Locker locker(isolate); @@ -139,6 +142,7 @@ void CanvasJSIModule::install(v8::Isolate *isolate) { ImageDataImpl::Init(canvasMod, isolate); ImageAssetImpl::Init(canvasMod, isolate); ImageBitmapImpl::Init(canvasMod, isolate); + OffscreenSurfaceImpl::Init(canvasMod, isolate); CanvasGradient::Init(canvasMod, isolate); CanvasPattern::Init(canvasMod, isolate); MatrixImpl::Init(canvasMod, isolate); diff --git a/packages/canvas/platforms/ios/src/cpp/CanvasJSIModule.h b/packages/canvas/platforms/ios/src/cpp/CanvasJSIModule.h index 22aa3b3bc..3a9b96ccf 100644 --- a/packages/canvas/platforms/ios/src/cpp/CanvasJSIModule.h +++ b/packages/canvas/platforms/ios/src/cpp/CanvasJSIModule.h @@ -12,6 +12,7 @@ #include "ImageAssetImpl.h" #include "canvas2d/ImageDataImpl.h" #include "ImageBitmapImpl.h" +#include "OffscreenSurfaceImpl.h" #include "TextDecoderImpl.h" #include "TextEncoderImpl.h" #include "canvas2d/MatrixImpl.h" diff --git a/packages/canvas/platforms/ios/src/cpp/NativeType.h b/packages/canvas/platforms/ios/src/cpp/NativeType.h index e3c492cd6..c5b37cc43 100644 --- a/packages/canvas/platforms/ios/src/cpp/NativeType.h +++ b/packages/canvas/platforms/ios/src/cpp/NativeType.h @@ -84,7 +84,8 @@ enum class NativeType { GPURenderBundle, GPUCompilationInfo, GPUCompilationMessage, - GPUExternalTexture + GPUExternalTexture, + OffscreenSurface }; #endif //CANVAS_ANDROID_NATIVETYPE_H diff --git a/packages/canvas/platforms/ios/src/cpp/OffscreenSurfaceImpl.cpp b/packages/canvas/platforms/ios/src/cpp/OffscreenSurfaceImpl.cpp new file mode 100644 index 000000000..5b45cd6f0 --- /dev/null +++ b/packages/canvas/platforms/ios/src/cpp/OffscreenSurfaceImpl.cpp @@ -0,0 +1,249 @@ +#include "OffscreenSurfaceImpl.h" +#include "Caches.h" +#include "OneByteStringResource.h" +#include "canvas2d/CanvasRenderingContext2DImpl.h" +#include "webgl/WebGLRenderingContext.h" +#include "webgl2/WebGL2RenderingContext.h" +#include "webgpu/GPUImpl.h" +#include "webgpu/GPUCanvasContextImpl.h" + +OffscreenSurfaceImpl::OffscreenSurfaceImpl(const CanvasOffscreenSurface *surface) : surface_(surface) {} + +OffscreenSurfaceImpl::~OffscreenSurfaceImpl() { + canvas_native_offscreen_surface_release(surface_); + surface_ = nullptr; +} + +void OffscreenSurfaceImpl::Init(v8::Local canvasModule, v8::Isolate *isolate) { + v8::Locker locker(isolate); + v8::Isolate::Scope isolate_scope(isolate); + v8::HandleScope handle_scope(isolate); + + auto context = isolate->GetCurrentContext(); + auto func = GetCtor(isolate)->GetFunction(context).ToLocalChecked(); + canvasModule->Set(context, ConvertToV8String(isolate, "OffscreenSurface"), func).FromJust(); +} + +OffscreenSurfaceImpl *OffscreenSurfaceImpl::GetPointer(const v8::Local &object) { + auto ptr = object->GetAlignedPointerFromInternalField(0, ObjectWrapperImpl::kInternalFieldTag); + if (ptr == nullptr) { + return nullptr; + } + return static_cast(ptr); +} + +v8::Local OffscreenSurfaceImpl::NewInstance(v8::Isolate *isolate, OffscreenSurfaceImpl *impl) { + auto context = isolate->GetCurrentContext(); + v8::EscapableHandleScope scope(isolate); + auto object = GetCtor(isolate)->GetFunction(context).ToLocalChecked()->NewInstance(context).ToLocalChecked(); + SetNativeType(impl, NativeType::OffscreenSurface); + object->SetAlignedPointerInInternalField(0, impl, ObjectWrapperImpl::kInternalFieldTag); + impl->BindFinalizer(isolate, object); + return scope.Escape(object); +} + +/// Null after `dispose()`. +static const CanvasOffscreenSurface *SurfaceOf(const v8::Local &object) { + auto ptr = OffscreenSurfaceImpl::GetPointer(object); + return ptr == nullptr ? nullptr : ptr->GetSurface(); +} + +v8::Local OffscreenSurfaceImpl::GetCtor(v8::Isolate *isolate) { + auto cache = Caches::Get(isolate); + auto ctor = cache->OffscreenSurfaceTmpl.get(); + if (ctor != nullptr) { + return ctor->Get(isolate); + } + + v8::Local ctorTmpl = v8::FunctionTemplate::New(isolate); + ctorTmpl->SetClassName(ConvertToV8String(isolate, "OffscreenSurface")); + ctorTmpl->Set(ConvertToV8String(isolate, "create"), v8::FunctionTemplate::New(isolate, Create)); + ctorTmpl->Set(ConvertToV8String(isolate, "fromPointer"), v8::FunctionTemplate::New(isolate, FromPointer)); + ctorTmpl->Set(ConvertToV8String(isolate, "adopt"), v8::FunctionTemplate::New(isolate, Adopt)); + ctorTmpl->Set(ConvertToV8String(isolate, "releaseHandle"), v8::FunctionTemplate::New(isolate, ReleaseHandle)); + + auto tmpl = ctorTmpl->InstanceTemplate(); + tmpl->SetInternalFieldCount(2); + tmpl->SetNativeDataProperty(ConvertToV8String(isolate, "width"), GetWidth); + tmpl->SetNativeDataProperty(ConvertToV8String(isolate, "height"), GetHeight); + tmpl->SetNativeDataProperty(ConvertToV8String(isolate, "density"), GetDensity); + tmpl->SetNativeDataProperty(ConvertToV8String(isolate, "ppi"), GetPpi); + tmpl->SetNativeDataProperty(ConvertToV8String(isolate, "direction"), GetDirection); + tmpl->SetNativeDataProperty(ConvertToV8String(isolate, "hasView"), GetHasView); + tmpl->Set(ConvertToV8String(isolate, "resize"), v8::FunctionTemplate::New(isolate, Resize)); + tmpl->Set(ConvertToV8String(isolate, "toHandle"), v8::FunctionTemplate::New(isolate, ToHandle)); + tmpl->Set(ConvertToV8String(isolate, "dispose"), v8::FunctionTemplate::New(isolate, Dispose)); + tmpl->Set(ConvertToV8String(isolate, "create2D"), v8::FunctionTemplate::New(isolate, Create2D)); + tmpl->Set(ConvertToV8String(isolate, "createWebGL"), v8::FunctionTemplate::New(isolate, CreateWebGL)); + tmpl->Set(ConvertToV8String(isolate, "createWebGPU"), v8::FunctionTemplate::New(isolate, CreateWebGPU)); + tmpl->Set(ConvertToV8String(isolate, "toDataURL"), v8::FunctionTemplate::New(isolate, ToDataURL)); + + cache->OffscreenSurfaceTmpl = std::make_unique>(isolate, ctorTmpl); + return ctorTmpl; +} + +static void ReturnSurface(const v8::FunctionCallbackInfo &args, const CanvasOffscreenSurface *surface) { + if (surface == nullptr) { + args.GetReturnValue().SetNull(); + return; + } + auto isolate = args.GetIsolate(); + args.GetReturnValue().Set(OffscreenSurfaceImpl::NewInstance(isolate, new OffscreenSurfaceImpl(surface))); +} + +void OffscreenSurfaceImpl::Create(const v8::FunctionCallbackInfo &args) { + auto isolate = args.GetIsolate(); + auto context = isolate->GetCurrentContext(); + auto width = args[0]->Uint32Value(context).FromMaybe(300); + auto height = args[1]->Uint32Value(context).FromMaybe(150); + auto density = (float) args[2]->NumberValue(context).FromMaybe(1); + auto ppi = (float) args[3]->NumberValue(context).FromMaybe(160); + auto direction = args[4]->Uint32Value(context).FromMaybe(0); + auto colorSpace = args[5]->Uint32Value(context).FromMaybe(0) == 1 ? CanvasColorSpaceP3 : CanvasColorSpaceSrgb; + ReturnSurface(args, canvas_native_offscreen_surface_create(width, height, density, ppi, direction, colorSpace)); +} + +/// Takes the reference the canvas view handed out. +void OffscreenSurfaceImpl::FromPointer(const v8::FunctionCallbackInfo &args) { + auto context = args.GetIsolate()->GetCurrentContext(); + v8::Local value; + if (!args[0]->ToBigInt(context).ToLocal(&value)) { + args.GetReturnValue().SetNull(); + return; + } + ReturnSurface(args, (const CanvasOffscreenSurface *) value->Int64Value()); +} + +void OffscreenSurfaceImpl::Adopt(const v8::FunctionCallbackInfo &args) { + auto context = args.GetIsolate()->GetCurrentContext(); + auto handle = args[0]->Uint32Value(context).FromMaybe(0); + ReturnSurface(args, handle == 0 ? nullptr : canvas_native_offscreen_surface_adopt(handle)); +} + +void OffscreenSurfaceImpl::ReleaseHandle(const v8::FunctionCallbackInfo &args) { + auto context = args.GetIsolate()->GetCurrentContext(); + auto handle = args[0]->Uint32Value(context).FromMaybe(0); + args.GetReturnValue().Set(handle != 0 && canvas_native_offscreen_surface_release_handle(handle)); +} + +void OffscreenSurfaceImpl::GetWidth(v8::Local, const v8::PropertyCallbackInfo &info) { + info.GetReturnValue().Set(canvas_native_offscreen_surface_get_width(SurfaceOf(info.Holder()))); +} + +void OffscreenSurfaceImpl::GetHeight(v8::Local, const v8::PropertyCallbackInfo &info) { + info.GetReturnValue().Set(canvas_native_offscreen_surface_get_height(SurfaceOf(info.Holder()))); +} + +void OffscreenSurfaceImpl::GetDensity(v8::Local, const v8::PropertyCallbackInfo &info) { + info.GetReturnValue().Set((double) canvas_native_offscreen_surface_get_density(SurfaceOf(info.Holder()))); +} + +void OffscreenSurfaceImpl::GetPpi(v8::Local, const v8::PropertyCallbackInfo &info) { + info.GetReturnValue().Set((double) canvas_native_offscreen_surface_get_ppi(SurfaceOf(info.Holder()))); +} + +void OffscreenSurfaceImpl::GetDirection(v8::Local, const v8::PropertyCallbackInfo &info) { + info.GetReturnValue().Set(canvas_native_offscreen_surface_get_direction(SurfaceOf(info.Holder()))); +} + +void OffscreenSurfaceImpl::GetHasView(v8::Local, const v8::PropertyCallbackInfo &info) { + info.GetReturnValue().Set(canvas_native_offscreen_surface_has_view(SurfaceOf(info.Holder()))); +} + +void OffscreenSurfaceImpl::Resize(const v8::FunctionCallbackInfo &args) { + auto context = args.GetIsolate()->GetCurrentContext(); + auto width = args[0]->Uint32Value(context).FromMaybe(0); + auto height = args[1]->Uint32Value(context).FromMaybe(0); + canvas_native_offscreen_surface_resize(SurfaceOf(args.This()), width, height); +} + +void OffscreenSurfaceImpl::ToHandle(const v8::FunctionCallbackInfo &args) { + args.GetReturnValue().Set(canvas_native_offscreen_surface_to_handle(SurfaceOf(args.This()))); +} + +void OffscreenSurfaceImpl::Dispose(const v8::FunctionCallbackInfo &args) { + auto ptr = GetPointer(args.This()); + if (ptr != nullptr && ptr->surface_ != nullptr) { + canvas_native_offscreen_surface_release(ptr->surface_); + ptr->surface_ = nullptr; + } +} + +void OffscreenSurfaceImpl::Create2D(const v8::FunctionCallbackInfo &args) { + auto isolate = args.GetIsolate(); + auto context = isolate->GetCurrentContext(); + auto alpha = args[0]->BooleanValue(isolate); + auto fontColor = args[1]->Int32Value(context).FromMaybe(-16777216); + auto threaded = args[2]->BooleanValue(isolate); + auto ctx = canvas_native_offscreen_surface_create_2d(SurfaceOf(args.This()), alpha, fontColor, threaded); + if (ctx == nullptr) { + args.GetReturnValue().SetNull(); + return; + } + args.GetReturnValue().Set(CanvasRenderingContext2DImpl::NewInstance(isolate, new CanvasRenderingContext2DImpl(ctx, true))); +} + +void OffscreenSurfaceImpl::CreateWebGL(const v8::FunctionCallbackInfo &args) { + auto isolate = args.GetIsolate(); + auto context = isolate->GetCurrentContext(); + WebGLAttributes attributes{}; + attributes.version = args[0]->Int32Value(context).FromMaybe(1); + attributes.alpha = args[1]->BooleanValue(isolate); + attributes.antialias = args[2]->BooleanValue(isolate); + attributes.depth = args[3]->BooleanValue(isolate); + attributes.fail_if_major_performance_caveat = args[4]->BooleanValue(isolate); + attributes.power_preference = args[5]->Int32Value(context).FromMaybe(0); + attributes.premultiplied_alpha = args[6]->BooleanValue(isolate); + attributes.preserve_drawing_buffer = args[7]->BooleanValue(isolate); + attributes.stencil = args[8]->BooleanValue(isolate); + attributes.desynchronized = args[9]->BooleanValue(isolate); + attributes.xr_compatible = args[10]->BooleanValue(isolate); + auto threaded = args[11]->BooleanValue(isolate); + + auto state = canvas_native_offscreen_surface_create_webgl(SurfaceOf(args.This()), &attributes, threaded); + if (state == nullptr) { + args.GetReturnValue().SetNull(); + return; + } + if (attributes.version == 2) { + args.GetReturnValue().Set(WebGL2RenderingContext::NewInstance(isolate, new WebGL2RenderingContext(state, WebGLRenderingVersion::V2))); + } else { + args.GetReturnValue().Set(WebGLRenderingContext::NewInstance(isolate, new WebGLRenderingContext(state))); + } +} + +/// `gpu`: the calling isolate's `navigator.gpu.native`. +void OffscreenSurfaceImpl::CreateWebGPU(const v8::FunctionCallbackInfo &args) { + auto isolate = args.GetIsolate(); + if (!args[0]->IsObject()) { + args.GetReturnValue().SetNull(); + return; + } + auto gpu = GPUImpl::GetPointer(args[0].As()); + auto ctx = gpu == nullptr ? nullptr : canvas_native_offscreen_surface_create_webgpu(SurfaceOf(args.This()), gpu->GetGPUInstance()); + if (ctx == nullptr) { + args.GetReturnValue().SetNull(); + return; + } + args.GetReturnValue().Set(GPUCanvasContextImpl::NewInstance(isolate, new GPUCanvasContextImpl(ctx))); +} + +void OffscreenSurfaceImpl::ToDataURL(const v8::FunctionCallbackInfo &args) { + auto isolate = args.GetIsolate(); + auto context = isolate->GetCurrentContext(); + std::string type("image/png"); + int quality = 92; + if (args[0]->IsString()) { + type = ConvertFromV8String(isolate, args[0]); + } + if (args[1]->IsNumber()) { + quality = (int) (args[1]->NumberValue(context).ToChecked() * 100); + } + auto data = canvas_native_offscreen_surface_to_data_url(SurfaceOf(args.This()), type.c_str(), quality); + if (data == nullptr) { + args.GetReturnValue().SetNull(); + return; + } + auto value = new OneByteStringResource(data); + args.GetReturnValue().Set(v8::String::NewExternalOneByte(isolate, value).ToLocalChecked()); +} diff --git a/packages/canvas/platforms/ios/src/cpp/OffscreenSurfaceImpl.h b/packages/canvas/platforms/ios/src/cpp/OffscreenSurfaceImpl.h new file mode 100644 index 000000000..00dd00413 --- /dev/null +++ b/packages/canvas/platforms/ios/src/cpp/OffscreenSurfaceImpl.h @@ -0,0 +1,60 @@ +#pragma once + +#include "Common.h" +#include "Helpers.h" +#include "ObjectWrapperImpl.h" + +class OffscreenSurfaceImpl : public ObjectWrapperImpl { +public: + /// Takes `surface`'s reference. + explicit OffscreenSurfaceImpl(const CanvasOffscreenSurface *surface); + + ~OffscreenSurfaceImpl() override; + + static void Init(v8::Local canvasModule, v8::Isolate *isolate); + + static v8::Local GetCtor(v8::Isolate *isolate); + + static OffscreenSurfaceImpl *GetPointer(const v8::Local &object); + + static v8::Local NewInstance(v8::Isolate *isolate, OffscreenSurfaceImpl *impl); + + const CanvasOffscreenSurface *GetSurface() { return surface_; } + +private: + const CanvasOffscreenSurface *surface_; + + static void Create(const v8::FunctionCallbackInfo &args); + + static void FromPointer(const v8::FunctionCallbackInfo &args); + + static void Adopt(const v8::FunctionCallbackInfo &args); + + static void ReleaseHandle(const v8::FunctionCallbackInfo &args); + + static void GetWidth(v8::Local name, const v8::PropertyCallbackInfo &info); + + static void GetHeight(v8::Local name, const v8::PropertyCallbackInfo &info); + + static void GetDensity(v8::Local name, const v8::PropertyCallbackInfo &info); + + static void GetPpi(v8::Local name, const v8::PropertyCallbackInfo &info); + + static void GetDirection(v8::Local name, const v8::PropertyCallbackInfo &info); + + static void GetHasView(v8::Local name, const v8::PropertyCallbackInfo &info); + + static void Resize(const v8::FunctionCallbackInfo &args); + + static void ToHandle(const v8::FunctionCallbackInfo &args); + + static void Dispose(const v8::FunctionCallbackInfo &args); + + static void Create2D(const v8::FunctionCallbackInfo &args); + + static void CreateWebGL(const v8::FunctionCallbackInfo &args); + + static void CreateWebGPU(const v8::FunctionCallbackInfo &args); + + static void ToDataURL(const v8::FunctionCallbackInfo &args); +}; diff --git a/packages/canvas/platforms/ios/src/cpp/canvas2d/CanvasRenderingContext2DImpl.cpp b/packages/canvas/platforms/ios/src/cpp/canvas2d/CanvasRenderingContext2DImpl.cpp index 01abbf930..c9cff0588 100644 --- a/packages/canvas/platforms/ios/src/cpp/canvas2d/CanvasRenderingContext2DImpl.cpp +++ b/packages/canvas/platforms/ios/src/cpp/canvas2d/CanvasRenderingContext2DImpl.cpp @@ -643,6 +643,23 @@ CanvasRenderingContext2DImpl::GetPointer(const v8::Local &object) { } +void CanvasRenderingContext2DImpl::WithCpu(const v8::FunctionCallbackInfo &args) { + auto isolate = args.GetIsolate(); + auto context = isolate->GetCurrentContext(); + auto width = (float) args[0]->NumberValue(context).FromMaybe(300); + auto height = (float) args[1]->NumberValue(context).FromMaybe(150); + auto density = (float) args[2]->NumberValue(context).FromMaybe(1); + auto alpha = args[3]->BooleanValue(isolate); + auto fontColor = args[4]->Int32Value(context).FromMaybe(-16777216); + auto ppi = (float) args[5]->NumberValue(context).FromMaybe(160); + auto direction = args[6]->Uint32Value(context).FromMaybe(0); + auto ctx = canvas_native_context_create(width, height, density, alpha, fontColor, ppi, + direction, CanvasColorSpaceSrgb); + auto ret = CanvasRenderingContext2DImpl::NewInstance(isolate, new CanvasRenderingContext2DImpl( + ctx, true)); + args.GetReturnValue().Set(ret); +} + void CanvasRenderingContext2DImpl::StartRaf() { auto raf = this->GetRaf(); if (raf != nullptr) { @@ -672,6 +689,7 @@ v8::Local CanvasRenderingContext2DImpl::GetCtor(v8::Isolat v8::Local ctorTmpl = v8::FunctionTemplate::New(isolate); ctorTmpl->SetClassName(ConvertToV8String(isolate, "CanvasRenderingContext2D")); + ctorTmpl->Set(ConvertToV8String(isolate, "withCpu"), v8::FunctionTemplate::New(isolate, WithCpu)); auto tmpl = ctorTmpl->InstanceTemplate(); tmpl->SetInternalFieldCount(2); diff --git a/packages/canvas/platforms/ios/src/cpp/canvas2d/CanvasRenderingContext2DImpl.h b/packages/canvas/platforms/ios/src/cpp/canvas2d/CanvasRenderingContext2DImpl.h index 88a84f7b7..fe5caec4f 100644 --- a/packages/canvas/platforms/ios/src/cpp/canvas2d/CanvasRenderingContext2DImpl.h +++ b/packages/canvas/platforms/ios/src/cpp/canvas2d/CanvasRenderingContext2DImpl.h @@ -141,6 +141,8 @@ class CanvasRenderingContext2DImpl : ObjectWrapperImpl { static void __StartRaf(const v8::FunctionCallbackInfo &args); + static void WithCpu(const v8::FunctionCallbackInfo &args); + static void __FastStartRaf(v8::Local receiver_obj) { CanvasRenderingContext2DImpl *ptr = GetPointer(receiver_obj); if (ptr == nullptr) { diff --git a/packages/canvas/platforms/windows/arm64/canvasnative.node b/packages/canvas/platforms/windows/arm64/canvasnative.node index ad5711dfa..bb7013e8a 100644 Binary files a/packages/canvas/platforms/windows/arm64/canvasnative.node and b/packages/canvas/platforms/windows/arm64/canvasnative.node differ diff --git a/packages/canvas/platforms/windows/x64/canvasnative.node b/packages/canvas/platforms/windows/x64/canvasnative.node index c680d26b2..8d0d10333 100644 Binary files a/packages/canvas/platforms/windows/x64/canvasnative.node and b/packages/canvas/platforms/windows/x64/canvasnative.node differ diff --git a/packages/canvas/src-native/canvas-android/canvas/CMakeLists.txt b/packages/canvas/src-native/canvas-android/canvas/CMakeLists.txt index cb383c0ad..a39ae36c1 100644 --- a/packages/canvas/src-native/canvas-android/canvas/CMakeLists.txt +++ b/packages/canvas/src-native/canvas-android/canvas/CMakeLists.txt @@ -129,6 +129,7 @@ add_library( ${CPP_DIR}/ImageAssetImpl.cpp ${CPP_DIR}/ImageBitmapImpl.cpp + ${CPP_DIR}/OffscreenSurfaceImpl.cpp ${CPP_DIR}/TextDecoderImpl.cpp ${CPP_DIR}/TextEncoderImpl.cpp ${CPP_DIR}/WorkerPool.cpp diff --git a/packages/canvas/src-native/canvas-android/canvas/src/main/cpp/include/canvas_native.h b/packages/canvas/src-native/canvas-android/canvas/src/main/cpp/include/canvas_native.h index 6f801742f..479082a4c 100644 --- a/packages/canvas/src-native/canvas-android/canvas/src/main/cpp/include/canvas_native.h +++ b/packages/canvas/src-native/canvas-android/canvas/src/main/cpp/include/canvas_native.h @@ -357,6 +357,20 @@ typedef enum CanvasLoadOp { CanvasLoadOpLoad = 1, } CanvasLoadOp; +/** + * What the surface tells its view, on the UI thread. + */ +typedef enum CanvasOffscreenEvent { + /** + * The bitmap is now `a` x `b`: lay the view out to show it. + */ + CanvasOffscreenEventResize = 1, + /** + * A context was made: `a` is its `CanvasOffscreenEngine`, `b` is 1 if it has alpha. + */ + CanvasOffscreenEventEngine = 2, +} CanvasOffscreenEvent; + typedef enum CanvasOptionalTextureViewDimension { CanvasOptionalTextureViewDimensionNone, CanvasOptionalTextureViewDimensionD1, @@ -760,6 +774,8 @@ typedef struct CanvasGPUTexture CanvasGPUTexture; typedef struct CanvasGPUTextureView CanvasGPUTextureView; +typedef struct CanvasOffscreenSurface CanvasOffscreenSurface; + typedef struct CanvasRenderingContext2D CanvasRenderingContext2D; typedef struct CanvasWebGPUInstance CanvasWebGPUInstance; @@ -2075,6 +2091,34 @@ typedef struct FileHelperMime { const char *extension; } FileHelperMime; +/** + * The view's side of a surface. `call` may come from any thread; it must hop to the UI thread, + * running at once when already there (so a main-thread OffscreenCanvas resizes its view + * synchronously). `release` frees `data` once the surface is done with the view. + */ +typedef struct CanvasOffscreenUiSink { + void *data; + void (*call)(void *data, enum CanvasOffscreenEvent event, uint32_t a, uint32_t b); + void (*release)(void *data); +} CanvasOffscreenUiSink; + +/** + * `getContext('webgl' | 'webgl2')`'s attributes. + */ +typedef struct WebGLAttributes { + int32_t version; + bool alpha; + bool antialias; + bool depth; + bool fail_if_major_performance_caveat; + int32_t power_preference; + bool premultiplied_alpha; + bool preserve_drawing_buffer; + bool stencil; + bool desynchronized; + bool xr_compatible; +} WebGLAttributes; + void canvas_native_font_clear(void); void canvas_native_font_add_family(const char *alias, @@ -3431,6 +3475,21 @@ char *canvas_native_webgpu_to_data_url_with_texture(const struct CanvasGPUCanvas const char *format, uint32_t quality); +/** + * A context with no surface (an OffscreenCanvas without a view): getCurrentTexture hands out an + * offscreen texture, read back like a presented one. + */ +const struct CanvasGPUCanvasContext *canvas_native_webgpu_context_create_offscreen(const struct CanvasWebGPUInstance *instance, + uint32_t width, + uint32_t height); + +/** + * Resizes a context's textures, without touching its surface (none, for one made offscreen). + */ +void canvas_native_webgpu_context_resize_offscreen(const struct CanvasGPUCanvasContext *context, + uint32_t width, + uint32_t height); + #if defined(TARGET_OS_ANDROID) const struct CanvasGPUCanvasContext *canvas_native_webgpu_context_create(struct CanvasWebGPUInstance *instance, void *window, @@ -4586,6 +4645,24 @@ struct WebGLState *canvas_native_webgl_create_no_window(int32_t width, bool xr_compatible, bool is_canvas); +/** + * `canvas_native_webgl_create_no_window`, for a context that lives on the WebGL thread. + */ +struct WebGLState *canvas_native_webgl_create_no_window_threaded(int32_t width, + int32_t height, + int32_t version, + bool alpha, + bool antialias, + bool depth, + bool fail_if_major_performance_caveat, + int32_t power_preference, + bool premultiplied_alpha, + bool preserve_drawing_buffer, + bool stencil, + bool desynchronized, + bool xr_compatible, + bool is_canvas); + /** * Windows: a WebGL context whose drawing buffer can be shown in a `SwapChainPanel` * (`canvas_native_webgl_attach_swap_chain_panel`). Null when ANGLE is unavailable. @@ -4604,6 +4681,23 @@ struct WebGLState *canvas_native_webgl_create_d3d(int32_t width, bool desynchronized, bool xr_compatible); +/** + * `canvas_native_webgl_create_d3d`, for a context that lives on the WebGL thread. + */ +struct WebGLState *canvas_native_webgl_create_d3d_threaded(int32_t width, + int32_t height, + int32_t version, + bool alpha, + bool antialias, + bool depth, + bool fail_if_major_performance_caveat, + int32_t power_preference, + bool premultiplied_alpha, + bool preserve_drawing_buffer, + bool stencil, + bool desynchronized, + bool xr_compatible); + /** * Windows: shows a `canvas_native_webgl_create_d3d` context in a `SwapChainPanel` (any COM * pointer to it). UI thread. @@ -4627,7 +4721,8 @@ bool canvas_native_webgl_set_swap_chain_transform(struct WebGLState *state, float offset_y); /** - * Windows: resizes (and clears) a `canvas_native_webgl_create_d3d` drawing buffer. + * Windows: resizes (and clears) a `canvas_native_webgl_create_d3d` drawing buffer. A threaded + * context queues it, so it reports success. */ bool canvas_native_webgl_resize_d3d(struct WebGLState *state, int32_t width, int32_t height); @@ -6057,14 +6152,156 @@ void canvas_native_webgl2_tex_image2d_image_data(int32_t target, struct WebGLState *state); /** - * How many threaded canvases are behind. The JS side holds requestAnimationFrame back while any - * are, as a browser does when its compositor falls behind, rather than queue more work. + * How many of the calling thread's threaded canvases are behind. The JS side holds + * requestAnimationFrame back while any are, as a browser does when its compositor falls behind, + * rather than queue more work. */ uint32_t canvas_native_canvases_behind(void); /** - * Where that count lives, for the JS side to read as memory every frame instead of calling in. + * Where the calling thread's count lives, for its JS to read as memory every frame instead of + * calling in. */ const uint32_t *canvas_native_canvases_behind_address(void); +/** + * A surface with no view: `new OffscreenCanvas(width, height)`. + */ +const struct CanvasOffscreenSurface *canvas_native_offscreen_surface_create(uint32_t width, + uint32_t height, + float density, + float ppi, + uint32_t direction, + enum CanvasColorSpace color_space); + +#if (defined(TARGET_OS_IOS) || defined(TARGET_OS_VISION)) +/** + * A canvas view's surface, which its OffscreenCanvas draws into (`transferControlToOffscreen`). + * UI thread. Retains `metal_layer`, `device`, `queue` (the view's Metal layer, null without one) + * and `gl_layer` (its CAEAGLLayer, null without one). + */ +const struct CanvasOffscreenSurface *canvas_native_offscreen_surface_create_ios(uint32_t width, + uint32_t height, + float density, + float ppi, + uint32_t direction, + enum CanvasColorSpace color_space, + void *metal_layer, + void *device, + void *queue, + uintptr_t samples, + void *gl_layer); +#endif + +#if defined(TARGET_OS_ANDROID) +/** + * A canvas view's surface on Android. Its window comes and goes with the view's + * (`canvas_native_offscreen_surface_set_window`). + */ +const struct CanvasOffscreenSurface *canvas_native_offscreen_surface_create_android(uint32_t width, + uint32_t height, + float density, + float ppi, + uint32_t direction, + enum CanvasColorSpace color_space); +#endif + +void canvas_native_offscreen_surface_reference(const struct CanvasOffscreenSurface *surface); + +void canvas_native_offscreen_surface_release(const struct CanvasOffscreenSurface *surface); + +/** + * UI thread. Replaces the previous sink, releasing it. + */ +void canvas_native_offscreen_surface_set_ui_sink(const struct CanvasOffscreenSurface *surface, + struct CanvasOffscreenUiSink sink); + +/** + * UI thread, synchronously: the view is going away (its OffscreenCanvas draws on, offscreen). + */ +void canvas_native_offscreen_surface_detach_view(const struct CanvasOffscreenSurface *surface); + +/** + * UI thread, after the view laid out a resize: a WebGL drawing buffer follows its layer. + */ +void canvas_native_offscreen_surface_view_resized(const struct CanvasOffscreenSurface *surface); + +/** + * Owner thread: the OffscreenCanvas's width and height. + */ +void canvas_native_offscreen_surface_resize(const struct CanvasOffscreenSurface *surface, + uint32_t width, + uint32_t height); + +uint32_t canvas_native_offscreen_surface_get_width(const struct CanvasOffscreenSurface *surface); + +uint32_t canvas_native_offscreen_surface_get_height(const struct CanvasOffscreenSurface *surface); + +float canvas_native_offscreen_surface_get_density(const struct CanvasOffscreenSurface *surface); + +float canvas_native_offscreen_surface_get_ppi(const struct CanvasOffscreenSurface *surface); + +uint32_t canvas_native_offscreen_surface_get_direction(const struct CanvasOffscreenSurface *surface); + +bool canvas_native_offscreen_surface_has_view(const struct CanvasOffscreenSurface *surface); + +/** + * Owner thread. Null if the surface already has a context. + */ +struct CanvasRenderingContext2D *canvas_native_offscreen_surface_create_2d(const struct CanvasOffscreenSurface *surface, + bool alpha, + int32_t font_color, + bool threaded); + +/** + * Owner thread. Null if the surface already has a context, or the context could not be made. + */ +struct WebGLState *canvas_native_offscreen_surface_create_webgl(const struct CanvasOffscreenSurface *surface, + const struct WebGLAttributes *attributes, + bool threaded); + +/** + * Owner thread. Null if the surface already has a context. + */ +const struct CanvasGPUCanvasContext *canvas_native_offscreen_surface_create_webgpu(const struct CanvasOffscreenSurface *surface, + const struct CanvasWebGPUInstance *instance); + +/** + * Any thread. Null without a context; free with `canvas_native_string_destroy`. + */ +char *canvas_native_offscreen_surface_to_data_url(const struct CanvasOffscreenSurface *surface, + const char *format, + uint32_t quality); + +/** + * A handle another thread adopts the surface by, once: 0 if it already has a context. The + * handle holds a reference until adopted or released. + */ +uint32_t canvas_native_offscreen_surface_to_handle(const struct CanvasOffscreenSurface *surface); + +/** + * Takes the surface a handle names, with its reference: null if it was adopted or released. + */ +const struct CanvasOffscreenSurface *canvas_native_offscreen_surface_adopt(uint32_t handle); + +/** + * Drops a handle nothing adopted. False if it was adopted or released already. + */ +bool canvas_native_offscreen_surface_release_handle(uint32_t handle); + +#if defined(TARGET_OS_ANDROID) +/** + * UI thread: the view's window (`ANativeWindow*`), new or resized. Takes its own reference. + */ +void canvas_native_offscreen_surface_set_window(const struct CanvasOffscreenSurface *surface, + void *window); +#endif + +#if defined(TARGET_OS_ANDROID) +/** + * UI thread, synchronously: the view's window is going away. + */ +void canvas_native_offscreen_surface_window_destroyed(const struct CanvasOffscreenSurface *surface); +#endif + #endif /* CANVAS_C_H */ diff --git a/packages/canvas/src-native/canvas-android/canvas/src/main/java/org/nativescript/canvas/NSCCanvas.kt b/packages/canvas/src-native/canvas-android/canvas/src/main/java/org/nativescript/canvas/NSCCanvas.kt index a2191eb23..d40be4b88 100644 --- a/packages/canvas/src-native/canvas-android/canvas/src/main/java/org/nativescript/canvas/NSCCanvas.kt +++ b/packages/canvas/src-native/canvas-android/canvas/src/main/java/org/nativescript/canvas/NSCCanvas.kt @@ -193,6 +193,9 @@ class NSCCanvas : FrameLayout { internal fun surfaceDestroyed() { listener?.surfaceDestroyed() + if (offscreenSurface != 0L) { + nativeOffscreenSurfaceWindowDestroyed(offscreenSurface) + } // CPU contexts draw into their own view. if (engine != Engine.CPU) { detachSurface(nativeContext, engine, is2D) @@ -248,6 +251,16 @@ class NSCCanvas : FrameLayout { fun releaseNativeContext() { val ctx = nativeContext val texture = textureView.surfaceTexture + val offscreen = offscreenSurface + if (offscreen != 0L) { + offscreenSurface = 0 + mainHandler.post { + nativeOffscreenSurfaceDetachView(offscreen) + nativeOffscreenSurfaceRelease(offscreen) + texture?.release() + } + return + } if (ctx == 0L) { texture?.release() return @@ -286,6 +299,43 @@ class NSCCanvas : FrameLayout { releaseNativeContext() } + private var offscreenSurface: Long = 0 + + /** A string: a tagged pointer does not fit a JS number. */ + fun transferToOffscreenSurface(): String { + if (nativeContext != 0L || offscreenSurface != 0L) { + return "0" + } + val metrics = resources.displayMetrics + val ptr = nativeOffscreenSurfaceCreate(surfaceWidth, surfaceHeight, metrics.density, metrics.densityDpi.toFloat(), direction, 0) + if (ptr == 0L) { + return "0" + } + offscreenSurface = ptr + nativeOffscreenSurfaceSetSink(ptr, OffscreenSink(this)) + surface?.let { nativeOffscreenSurfaceSetWindow(ptr, it) } + return nativeOffscreenSurfaceReference(ptr).toString() + } + + class OffscreenSink internal constructor(canvas: NSCCanvas) { + private val canvas = java.lang.ref.WeakReference(canvas) + + fun onEvent(event: Int, a: Int, b: Int) { + val run = Runnable { + val view = canvas.get() ?: return@Runnable + when (event) { + 1 -> view.setSurfaceSize(a, b) + 2 -> view.textureView.isOpaque = b == 0 + } + } + if (Looper.myLooper() == Looper.getMainLooper()) { + run.run() + } else { + mainHandler.post(run) + } + } + } + fun initWebGPUContext(instance: Long) { if (engine != Engine.None) { return @@ -906,6 +956,12 @@ class NSCCanvas : FrameLayout { internal fun resize() { scaleSurface() + if (offscreenSurface != 0L) { + isSurfaceDestroyed = false + surface?.let { nativeOffscreenSurfaceSetWindow(offscreenSurface, it) } + listener?.surfaceResize(surfaceWidth, surfaceHeight) + return + } if (nativeContext != 0L) { isSurfaceDestroyed = false when (engine) { @@ -1029,6 +1085,9 @@ class NSCCanvas : FrameLayout { } fun toDataURL(type: String, quality: Float): String { + if (offscreenSurface != 0L) { + nativeOffscreenSurfaceToDataURL(offscreenSurface, type, (quality * 100).toInt())?.let { return it } + } var bitmap: Bitmap? = null var format = Bitmap.CompressFormat.PNG var retType = "image/png" @@ -1259,6 +1318,30 @@ class NSCCanvas : FrameLayout { @FastNative external fun nativeDetach2DSurface(context: Long) + @JvmStatic + external fun nativeOffscreenSurfaceCreate(width: Int, height: Int, density: Float, ppi: Float, direction: Int, colorSpace: Int): Long + + @JvmStatic + external fun nativeOffscreenSurfaceSetWindow(surface: Long, window: Surface) + + @JvmStatic + external fun nativeOffscreenSurfaceWindowDestroyed(surface: Long) + + @JvmStatic + external fun nativeOffscreenSurfaceDetachView(surface: Long) + + @JvmStatic + external fun nativeOffscreenSurfaceSetSink(surface: Long, sink: Any) + + @JvmStatic + external fun nativeOffscreenSurfaceReference(surface: Long): Long + + @JvmStatic + external fun nativeOffscreenSurfaceRelease(surface: Long) + + @JvmStatic + external fun nativeOffscreenSurfaceToDataURL(surface: Long, format: String, quality: Int): String? + // Not @FastNative: a threaded context waits for the WebGL thread to build it. @JvmStatic external fun nativeInitWebGL( diff --git a/packages/canvas/src-native/canvas-ios/CanvasNative/Source/CanvasHelpers.swift b/packages/canvas/src-native/canvas-ios/CanvasNative/Source/CanvasHelpers.swift index 0e731d46a..c0326889e 100644 --- a/packages/canvas/src-native/canvas-ios/CanvasNative/Source/CanvasHelpers.swift +++ b/packages/canvas/src-native/canvas-ios/CanvasNative/Source/CanvasHelpers.swift @@ -339,8 +339,18 @@ public class CanvasHelpers: NSObject { return _ReadQueue! } + /// A Worker thread has no OperationQueue.current, and its callbacks must not run on main. + private struct CallerRunLoop { + let runLoop = CFRunLoopGetCurrent() + + func addOperation(_ block: @escaping () -> Void) { + CFRunLoopPerformBlock(runLoop, CFRunLoopMode.commonModes.rawValue, block) + CFRunLoopWakeUp(runLoop) + } + } + public static func writeFile(_ data: NSData, _ path: String, _ callback:@escaping (String?, String?) -> Void){ - let queue = OperationQueue.current ?? OperationQueue.main + let queue = CallerRunLoop() WriteQueue.addOperation { let done = data.write(toFile: path, atomically: true) if(!done){ @@ -358,7 +368,7 @@ public class CanvasHelpers: NSObject { public static func readFile(_ path: String, _ callback:@escaping (String?, NSData?) -> Void){ - let queue = OperationQueue.current ?? OperationQueue.main + let queue = CallerRunLoop() ReadQueue.addOperation { let data = NSData(contentsOfFile: path) if(data == nil){ @@ -375,7 +385,7 @@ public class CanvasHelpers: NSObject { public static func deleteFile(_ path: String, _ callback:@escaping (NSError?, Bool) -> Void){ - let queue = OperationQueue.current ?? OperationQueue.main + let queue = CallerRunLoop() ReadQueue.addOperation { do { try FileManager.default.removeItem(atPath: path) @@ -392,7 +402,7 @@ public class CanvasHelpers: NSObject { public static func handleBase64Image(_ mime: String, _ dir: String, _ base64: String, _ callback:@escaping (String?, String?) -> Void){ - let queue = OperationQueue.current ?? OperationQueue.main + let queue = CallerRunLoop() WriteQueue.addOperation { let id = UUID().uuidString diff --git a/packages/canvas/src-native/canvas-ios/CanvasNative/Source/NSCCanvas.swift b/packages/canvas/src-native/canvas-ios/CanvasNative/Source/NSCCanvas.swift index fa8cc0cfc..fdf7f4de1 100644 --- a/packages/canvas/src-native/canvas-ios/CanvasNative/Source/NSCCanvas.swift +++ b/packages/canvas/src-native/canvas-ios/CanvasNative/Source/NSCCanvas.swift @@ -237,6 +237,10 @@ public class NSCCanvas: UIView { @objc public func toDataURL(_ format: String, _ quality: Float) -> String { + if let surface = offscreenSurface, let url = canvas_native_offscreen_surface_to_data_url(surface, format, UInt32(max(quality, 0) * 100)) { + defer { canvas_native_string_destroy(url) } + return String(cString: url) + } if(engine == .None){ let rect = CGRect(x: 0, y: 0, width: surfaceWidth, height: surfaceHeight) let renderer = UIGraphicsImageRenderer(bounds: rect) @@ -308,6 +312,48 @@ public class NSCCanvas: UIView { colorSpace ) } + private var offscreenSurface: OpaquePointer? = nil + + @objc public func transferToOffscreenSurface() -> Int64 { + if nativeContext != 0 || offscreenSurface != nil { + return 0 + } + let density = autoScale ? Float(nscNativeScale()) : 1 + let direction: UInt32 = UIView.userInterfaceLayoutDirection(for: semanticContentAttribute) == .rightToLeft ? 1 : 0 + #if os(visionOS) + let glLayer: UnsafeMutableRawPointer? = nil + #else + let glLayer: UnsafeMutableRawPointer? = Unmanaged.passUnretained(glView.layer).toOpaque() + #endif + let metalLayer = Unmanaged.passUnretained(mtlView.layer).toOpaque() + guard let surface = canvas_native_offscreen_surface_create_ios(UInt32(surfaceWidth), UInt32(surfaceHeight), density, density * 160, direction, CanvasColorSpaceSrgb, metalLayer, mtlView.getDevicePtr(), mtlView.getQueuePtr(), mtlView.sampleCount, glLayer) else { + return 0 + } + offscreenSurface = surface + let sink = Unmanaged.passRetained(NSCOffscreenSink(self)).toOpaque() + canvas_native_offscreen_surface_set_ui_sink(surface, CanvasOffscreenUiSink(data: sink, call: nscOffscreenSinkCall, release: nscOffscreenSinkRelease)) + canvas_native_offscreen_surface_reference(surface) + return Int64(Int(bitPattern: surface)) + } + + fileprivate func offscreenEvent(_ event: CanvasOffscreenEvent, _ a: Int, _ b: Int) { + if event == CanvasOffscreenEventResize { + setSurfaceSize(a, b) + } else if event == CanvasOffscreenEventEngine { + let alpha = b == 1 + isOpaque = !alpha + // 2: CanvasOffscreenEngine::GL. + if a == 2 { + glView.isOpaque = !alpha + glView.layer.isOpaque = !alpha + glView.isHidden = false + } else { + mtlView.isOpaque = !alpha + mtlView.isHidden = false + } + } + } + @objc public func initWebGPUContext(_ instance: Int64){ if (nativeContext != 0) { return @@ -739,6 +785,11 @@ public class NSCCanvas: UIView { } private func resize(){ + if let surface = offscreenSurface { + scaleSurface() + canvas_native_offscreen_surface_view_resized(surface) + return + } if(nativeContext == 0){ scaleSurface() return @@ -797,7 +848,10 @@ public class NSCCanvas: UIView { glView.frame = CGRect(x: 0, y: 0, width: unscaledWidth, height: unscaledHeight) mtlView.frame = CGRect(x: 0, y: 0, width: unscaledWidth, height: unscaledHeight) - mtlView.drawableSize = CGSize(width: width.rounded(.down), height: height.rounded(.down)) + // A transferred view's drawable is sized by its render thread. + if offscreenSurface == nil { + mtlView.drawableSize = CGSize(width: width.rounded(.down), height: height.rounded(.down)) + } cpuView.frame = CGRect(x: 0, y: 0, width: unscaledWidth, height: unscaledHeight) @@ -965,6 +1019,11 @@ public class NSCCanvas: UIView { } deinit { + if let surface = offscreenSurface { + canvas_native_offscreen_surface_detach_view(surface) + canvas_native_offscreen_surface_release(surface) + offscreenSurface = nil + } if(nativeContext != 0){ if(is2D){ CanvasHelpers.release2DContext(nativeContext) @@ -1045,3 +1104,32 @@ extension MTLTexture { return UIImage(cgImage: cgImage) } } + +private final class NSCOffscreenSink { + weak var canvas: NSCCanvas? + + init(_ canvas: NSCCanvas) { + self.canvas = canvas + } + + func send(_ event: CanvasOffscreenEvent, _ a: UInt32, _ b: UInt32) { + let run: () -> Void = { [weak self] in + self?.canvas?.offscreenEvent(event, Int(a), Int(b)) + } + if Thread.isMainThread { + run() + } else { + DispatchQueue.main.async(execute: run) + } + } +} + +private let nscOffscreenSinkCall: @convention(c) (UnsafeMutableRawPointer?, CanvasOffscreenEvent, UInt32, UInt32) -> Void = { data, event, a, b in + guard let data = data else { return } + Unmanaged.fromOpaque(data).takeUnretainedValue().send(event, a, b) +} + +private let nscOffscreenSinkRelease: @convention(c) (UnsafeMutableRawPointer?) -> Void = { data in + guard let data = data else { return } + Unmanaged.fromOpaque(data).release() +} diff --git a/packages/canvas/src-native/canvas-ios/CanvasNative/include/canvas_native.h b/packages/canvas/src-native/canvas-ios/CanvasNative/include/canvas_native.h index aff24cc30..479082a4c 100644 --- a/packages/canvas/src-native/canvas-ios/CanvasNative/include/canvas_native.h +++ b/packages/canvas/src-native/canvas-ios/CanvasNative/include/canvas_native.h @@ -357,6 +357,20 @@ typedef enum CanvasLoadOp { CanvasLoadOpLoad = 1, } CanvasLoadOp; +/** + * What the surface tells its view, on the UI thread. + */ +typedef enum CanvasOffscreenEvent { + /** + * The bitmap is now `a` x `b`: lay the view out to show it. + */ + CanvasOffscreenEventResize = 1, + /** + * A context was made: `a` is its `CanvasOffscreenEngine`, `b` is 1 if it has alpha. + */ + CanvasOffscreenEventEngine = 2, +} CanvasOffscreenEvent; + typedef enum CanvasOptionalTextureViewDimension { CanvasOptionalTextureViewDimensionNone, CanvasOptionalTextureViewDimensionD1, @@ -760,6 +774,8 @@ typedef struct CanvasGPUTexture CanvasGPUTexture; typedef struct CanvasGPUTextureView CanvasGPUTextureView; +typedef struct CanvasOffscreenSurface CanvasOffscreenSurface; + typedef struct CanvasRenderingContext2D CanvasRenderingContext2D; typedef struct CanvasWebGPUInstance CanvasWebGPUInstance; @@ -2075,6 +2091,34 @@ typedef struct FileHelperMime { const char *extension; } FileHelperMime; +/** + * The view's side of a surface. `call` may come from any thread; it must hop to the UI thread, + * running at once when already there (so a main-thread OffscreenCanvas resizes its view + * synchronously). `release` frees `data` once the surface is done with the view. + */ +typedef struct CanvasOffscreenUiSink { + void *data; + void (*call)(void *data, enum CanvasOffscreenEvent event, uint32_t a, uint32_t b); + void (*release)(void *data); +} CanvasOffscreenUiSink; + +/** + * `getContext('webgl' | 'webgl2')`'s attributes. + */ +typedef struct WebGLAttributes { + int32_t version; + bool alpha; + bool antialias; + bool depth; + bool fail_if_major_performance_caveat; + int32_t power_preference; + bool premultiplied_alpha; + bool preserve_drawing_buffer; + bool stencil; + bool desynchronized; + bool xr_compatible; +} WebGLAttributes; + void canvas_native_font_clear(void); void canvas_native_font_add_family(const char *alias, @@ -3431,6 +3475,21 @@ char *canvas_native_webgpu_to_data_url_with_texture(const struct CanvasGPUCanvas const char *format, uint32_t quality); +/** + * A context with no surface (an OffscreenCanvas without a view): getCurrentTexture hands out an + * offscreen texture, read back like a presented one. + */ +const struct CanvasGPUCanvasContext *canvas_native_webgpu_context_create_offscreen(const struct CanvasWebGPUInstance *instance, + uint32_t width, + uint32_t height); + +/** + * Resizes a context's textures, without touching its surface (none, for one made offscreen). + */ +void canvas_native_webgpu_context_resize_offscreen(const struct CanvasGPUCanvasContext *context, + uint32_t width, + uint32_t height); + #if defined(TARGET_OS_ANDROID) const struct CanvasGPUCanvasContext *canvas_native_webgpu_context_create(struct CanvasWebGPUInstance *instance, void *window, @@ -4586,6 +4645,24 @@ struct WebGLState *canvas_native_webgl_create_no_window(int32_t width, bool xr_compatible, bool is_canvas); +/** + * `canvas_native_webgl_create_no_window`, for a context that lives on the WebGL thread. + */ +struct WebGLState *canvas_native_webgl_create_no_window_threaded(int32_t width, + int32_t height, + int32_t version, + bool alpha, + bool antialias, + bool depth, + bool fail_if_major_performance_caveat, + int32_t power_preference, + bool premultiplied_alpha, + bool preserve_drawing_buffer, + bool stencil, + bool desynchronized, + bool xr_compatible, + bool is_canvas); + /** * Windows: a WebGL context whose drawing buffer can be shown in a `SwapChainPanel` * (`canvas_native_webgl_attach_swap_chain_panel`). Null when ANGLE is unavailable. @@ -6075,14 +6152,156 @@ void canvas_native_webgl2_tex_image2d_image_data(int32_t target, struct WebGLState *state); /** - * How many threaded canvases are behind. The JS side holds requestAnimationFrame back while any - * are, as a browser does when its compositor falls behind, rather than queue more work. + * How many of the calling thread's threaded canvases are behind. The JS side holds + * requestAnimationFrame back while any are, as a browser does when its compositor falls behind, + * rather than queue more work. */ uint32_t canvas_native_canvases_behind(void); /** - * Where that count lives, for the JS side to read as memory every frame instead of calling in. + * Where the calling thread's count lives, for its JS to read as memory every frame instead of + * calling in. */ const uint32_t *canvas_native_canvases_behind_address(void); +/** + * A surface with no view: `new OffscreenCanvas(width, height)`. + */ +const struct CanvasOffscreenSurface *canvas_native_offscreen_surface_create(uint32_t width, + uint32_t height, + float density, + float ppi, + uint32_t direction, + enum CanvasColorSpace color_space); + +#if (defined(TARGET_OS_IOS) || defined(TARGET_OS_VISION)) +/** + * A canvas view's surface, which its OffscreenCanvas draws into (`transferControlToOffscreen`). + * UI thread. Retains `metal_layer`, `device`, `queue` (the view's Metal layer, null without one) + * and `gl_layer` (its CAEAGLLayer, null without one). + */ +const struct CanvasOffscreenSurface *canvas_native_offscreen_surface_create_ios(uint32_t width, + uint32_t height, + float density, + float ppi, + uint32_t direction, + enum CanvasColorSpace color_space, + void *metal_layer, + void *device, + void *queue, + uintptr_t samples, + void *gl_layer); +#endif + +#if defined(TARGET_OS_ANDROID) +/** + * A canvas view's surface on Android. Its window comes and goes with the view's + * (`canvas_native_offscreen_surface_set_window`). + */ +const struct CanvasOffscreenSurface *canvas_native_offscreen_surface_create_android(uint32_t width, + uint32_t height, + float density, + float ppi, + uint32_t direction, + enum CanvasColorSpace color_space); +#endif + +void canvas_native_offscreen_surface_reference(const struct CanvasOffscreenSurface *surface); + +void canvas_native_offscreen_surface_release(const struct CanvasOffscreenSurface *surface); + +/** + * UI thread. Replaces the previous sink, releasing it. + */ +void canvas_native_offscreen_surface_set_ui_sink(const struct CanvasOffscreenSurface *surface, + struct CanvasOffscreenUiSink sink); + +/** + * UI thread, synchronously: the view is going away (its OffscreenCanvas draws on, offscreen). + */ +void canvas_native_offscreen_surface_detach_view(const struct CanvasOffscreenSurface *surface); + +/** + * UI thread, after the view laid out a resize: a WebGL drawing buffer follows its layer. + */ +void canvas_native_offscreen_surface_view_resized(const struct CanvasOffscreenSurface *surface); + +/** + * Owner thread: the OffscreenCanvas's width and height. + */ +void canvas_native_offscreen_surface_resize(const struct CanvasOffscreenSurface *surface, + uint32_t width, + uint32_t height); + +uint32_t canvas_native_offscreen_surface_get_width(const struct CanvasOffscreenSurface *surface); + +uint32_t canvas_native_offscreen_surface_get_height(const struct CanvasOffscreenSurface *surface); + +float canvas_native_offscreen_surface_get_density(const struct CanvasOffscreenSurface *surface); + +float canvas_native_offscreen_surface_get_ppi(const struct CanvasOffscreenSurface *surface); + +uint32_t canvas_native_offscreen_surface_get_direction(const struct CanvasOffscreenSurface *surface); + +bool canvas_native_offscreen_surface_has_view(const struct CanvasOffscreenSurface *surface); + +/** + * Owner thread. Null if the surface already has a context. + */ +struct CanvasRenderingContext2D *canvas_native_offscreen_surface_create_2d(const struct CanvasOffscreenSurface *surface, + bool alpha, + int32_t font_color, + bool threaded); + +/** + * Owner thread. Null if the surface already has a context, or the context could not be made. + */ +struct WebGLState *canvas_native_offscreen_surface_create_webgl(const struct CanvasOffscreenSurface *surface, + const struct WebGLAttributes *attributes, + bool threaded); + +/** + * Owner thread. Null if the surface already has a context. + */ +const struct CanvasGPUCanvasContext *canvas_native_offscreen_surface_create_webgpu(const struct CanvasOffscreenSurface *surface, + const struct CanvasWebGPUInstance *instance); + +/** + * Any thread. Null without a context; free with `canvas_native_string_destroy`. + */ +char *canvas_native_offscreen_surface_to_data_url(const struct CanvasOffscreenSurface *surface, + const char *format, + uint32_t quality); + +/** + * A handle another thread adopts the surface by, once: 0 if it already has a context. The + * handle holds a reference until adopted or released. + */ +uint32_t canvas_native_offscreen_surface_to_handle(const struct CanvasOffscreenSurface *surface); + +/** + * Takes the surface a handle names, with its reference: null if it was adopted or released. + */ +const struct CanvasOffscreenSurface *canvas_native_offscreen_surface_adopt(uint32_t handle); + +/** + * Drops a handle nothing adopted. False if it was adopted or released already. + */ +bool canvas_native_offscreen_surface_release_handle(uint32_t handle); + +#if defined(TARGET_OS_ANDROID) +/** + * UI thread: the view's window (`ANativeWindow*`), new or resized. Takes its own reference. + */ +void canvas_native_offscreen_surface_set_window(const struct CanvasOffscreenSurface *surface, + void *window); +#endif + +#if defined(TARGET_OS_ANDROID) +/** + * UI thread, synchronously: the view's window is going away. + */ +void canvas_native_offscreen_surface_window_destroyed(const struct CanvasOffscreenSurface *surface); +#endif + #endif /* CANVAS_C_H */ diff --git a/packages/canvas/worker.ts b/packages/canvas/worker.ts new file mode 100644 index 000000000..87bec29a2 --- /dev/null +++ b/packages/canvas/worker.ts @@ -0,0 +1,32 @@ +// The canvas API without the `Canvas` view, for Workers. +import '@nativescript/core/globals'; +import { Helpers } from './helpers'; +import { installCanvasGlobals } from './globals'; +import { GPU } from './WebGPU/GPU'; + +export * from './Canvas2D'; +export * from './ImageBitmap'; +export * from './ImageBitmapRenderingContext'; +export * from './OffscreenCanvas'; +export * from './ImageAsset'; +export * from './TextEncoder'; +export * from './TextDecoder'; +export * from './WebGL'; +export * from './WebGL2'; +export * from './WebGPU'; + +declare const org; + +if (__ANDROID__) { + // JNI_OnLoad otherwise runs only when the view's class loads. + org.nativescript.canvas.NSCCanvas.loadLib(); +} +Helpers.initialize(); +installCanvasGlobals(); + +const scope: any = global; +scope.navigator ??= {}; +if (!('gpu' in scope.navigator)) { + const gpu = new GPU(); + Object.defineProperty(scope.navigator, 'gpu', { get: () => gpu, configurable: true }); +} diff --git a/tools/demo/canvas/spec/index.ts b/tools/demo/canvas/spec/index.ts index f998f84da..beb3918c0 100644 --- a/tools/demo/canvas/spec/index.ts +++ b/tools/demo/canvas/spec/index.ts @@ -6,7 +6,7 @@ * adb logcat -d | grep 'SPEC|' * * `--es suite ` narrows the run: a group (`2d`, `webgl`, `webgpu`, - * `imagebitmap`, `bitmaprenderer`, `lifecycle`, `offscreen`, `audio` and `media` on Windows) or a single suite (`2d.path2d`). + * `imagebitmap`, `bitmaprenderer`, `lifecycle`, `offscreen` (with `offscreen.worker`), `audio` and `media` on Windows) or a single suite (`2d.path2d`). * `contextlost` (Windows) runs on its own: it removes the GPU device. */ @@ -22,6 +22,7 @@ import { registerAudioSpec } from './audio'; import { registerMediaSpec } from './media'; import { registerLifecycleSpec } from './lifecycle'; import { registerOffscreenCanvasSpec } from './offscreen'; +import { registerOffscreenWorkerSpec } from './offscreen-worker'; declare const __WINDOWS__: boolean; @@ -40,6 +41,7 @@ export async function runSpecTests(group: SpecGroup = 'all') { registerScalingSpec(); registerLifecycleSpec(); registerOffscreenCanvasSpec(); + registerOffscreenWorkerSpec(); if (typeof __WINDOWS__ !== 'undefined' && __WINDOWS__) { registerAudioSpec(); registerMediaSpec(); diff --git a/tools/demo/canvas/spec/offscreen-worker.ts b/tools/demo/canvas/spec/offscreen-worker.ts new file mode 100644 index 000000000..863306a8c --- /dev/null +++ b/tools/demo/canvas/spec/offscreen-worker.ts @@ -0,0 +1,361 @@ +// `offscreen.worker.ts` is the Worker half. + +import { ImageAsset, OffscreenCanvas } from '@nativescript/canvas'; +import { suite, test, skip, ok, equal, throws, rejects, make2D, makeCanvas, pixelAt } from './harness'; + +declare const Worker: any; + +const RED = [255, 0, 0, 255]; +const GREEN = [0, 255, 0, 255]; +const BLUE = [0, 0, 255, 255]; + +function near(actual: ArrayLike, expected: ArrayLike, tolerance = 8) { + for (let i = 0; i < 4; i++) { + if (Math.abs(actual[i] - expected[i]) > tolerance) { + return false; + } + } + return true; +} + +function expectNear(actual: ArrayLike, expected: ArrayLike, what: string) { + ok(actual && near(actual, expected), `${what}: expected [${Array.from(expected).join(', ')}], got [${actual ? Array.from(actual).join(', ') : actual}]`); +} + +class WorkerClient { + private worker = new Worker('./offscreen.worker.ts'); + private next = 1; + private calls = new Map void; reject: (error: any) => void }>(); + + constructor() { + this.worker.onmessage = (event: any) => { + const { id, ok, result, name, message } = event.data ?? {}; + const call = this.calls.get(id); + if (!call) { + return; + } + this.calls.delete(id); + if (ok) { + call.resolve(result); + } else { + const error: any = new Error(message); + error.name = name; + call.reject(error); + } + }; + this.worker.onerror = (error: any) => { + for (const call of this.calls.values()) { + call.reject(new Error(`worker error: ${error?.message ?? error}`)); + } + this.calls.clear(); + }; + } + + call(op: string, args?: any, timeout = 10000, transfer?: any[]): Promise { + const id = this.next++; + return new Promise((resolve, reject) => { + const timer = setTimeout(() => { + this.calls.delete(id); + reject(new Error(`worker op ${op} timed out`)); + }, timeout); + this.calls.set(id, { + resolve: (value) => { + clearTimeout(timer); + resolve(value); + }, + reject: (error) => { + clearTimeout(timer); + reject(error); + }, + }); + this.worker.postMessage({ id, op, args }, transfer); + }); + } + + terminate() { + this.worker.terminate(); + } +} + +let client: WorkerClient | null = null; + +function worker() { + client ??= new WorkerClient(); + return client; +} + +function handleOf(width: number, height: number) { + return OffscreenCanvas._toHandle(new OffscreenCanvas(width, height)); +} + +function pixelOfDataURL(url: string, width: number, height: number, x: number, y: number) { + const comma = url.indexOf(','); + ok(comma > 0 && comma < url.length - 1, `not an image: ${url.substring(0, 32)}`); + const [, buffer] = (global as any).CanvasModule.__base64Decode(url.substring(comma + 1)); + const asset = new ImageAsset(); + ok(asset.loadFromEncodedBytesSync(new Uint8Array(buffer)), 'the data URL did not decode'); + const { ctx } = make2D(width, height); + ctx.drawImage(asset, 0, 0); + return pixelAt(ctx, x, y); +} + +/** A Worker's frames reach the view after it answers. */ +async function eventually(check: () => void, timeout = 2000) { + const started = Date.now(); + for (;;) { + try { + check(); + return; + } catch (e) { + if (Date.now() - started > timeout) { + throw e; + } + } + await new Promise((resolve) => setTimeout(resolve, 50)); + } +} + +function transferred(width: number, height: number) { + const canvas = makeCanvas(width, height) as any; + const offscreen = canvas.transferControlToOffscreen(); + return { canvas, offscreen }; +} + +export function registerOffscreenWorkerSpec() { + suite('offscreen.worker.handle', () => { + test('a handle is received once', () => { + const handle = handleOf(4, 4); + const received = OffscreenCanvas._fromHandle(handle); + equal(received.width, 4); + throws(() => OffscreenCanvas._fromHandle(handle), 'DataCloneError'); + }); + + test('the sent canvas is detached', async () => { + const canvas = new OffscreenCanvas(4, 4); + OffscreenCanvas._toHandle(canvas); + equal(canvas.width, 0); + equal(canvas.height, 0); + throws(() => canvas.getContext('2d'), 'InvalidStateError'); + throws(() => (canvas.width = 2), 'InvalidStateError'); + throws(() => OffscreenCanvas._toHandle(canvas), 'DataCloneError'); + const error = await rejects(canvas.convertToBlob()); + equal(error?.name, 'InvalidStateError'); + }); + + test('a canvas with a context cannot be sent', () => { + const canvas = new OffscreenCanvas(4, 4); + canvas.getContext('2d'); + throws(() => OffscreenCanvas._toHandle(canvas), 'InvalidStateError'); + }); + + test('a released handle is gone', () => { + const handle = handleOf(4, 4); + equal(OffscreenCanvas._releaseHandle(handle), true); + equal(OffscreenCanvas._releaseHandle(handle), false); + throws(() => OffscreenCanvas._fromHandle(handle), 'DataCloneError'); + }); + + test('a received canvas draws here too', () => { + const canvas = OffscreenCanvas._fromHandle(handleOf(8, 8)); + const ctx = canvas.getContext('2d') as any; + ctx.fillStyle = 'red'; + ctx.fillRect(0, 0, 8, 8); + expectNear(pixelAt(ctx, 4, 4), RED, 'pixel'); + }); + }); + + suite('offscreen.worker.transfer', () => { + if (!(global as any).CanvasModule?.OffscreenSurface) { + skip('a transferred canvas draws through its view', 'no OffscreenSurface on this host yet'); + return; + } + + test('a transferred canvas draws through its view', async () => { + const { canvas, offscreen } = transferred(8, 8); + ok((offscreen as any)._surface?.hasView, 'not backed by the view'); + const ctx = offscreen.getContext('2d'); + ctx.fillStyle = 'red'; + ctx.fillRect(0, 0, 8, 8); + // Shown once committed, at the next flush. + await eventually(() => expectNear(pixelOfDataURL(canvas.toDataURL(), 8, 8, 4, 4), RED, 'the view')); + }); + + test('2d from the Worker shows in the view', async () => { + const { canvas, offscreen } = transferred(10, 10); + const result = await worker().call('2d', { handle: OffscreenCanvas._toHandle(offscreen) }); + expectNear(result.rest, RED, 'in the Worker'); + await eventually(() => { + const url = canvas.toDataURL(); + expectNear(pixelOfDataURL(url, 10, 10, 1, 1), BLUE, 'the view, corner'); + expectNear(pixelOfDataURL(url, 10, 10, 8, 8), RED, 'the view, rest'); + }); + }); + + for (const version of ['webgl', 'webgl2']) { + test(`${version} from the Worker shows in the view`, async () => { + const { canvas, offscreen } = transferred(8, 8); + const result = await worker().call('webgl', { version, preserve: true, handle: OffscreenCanvas._toHandle(offscreen) }); + ok(result.context, `no ${version} context`); + expectNear(result.pixel, GREEN, 'in the Worker'); + await eventually(() => expectNear(pixelOfDataURL(canvas.toDataURL(), 8, 8, 4, 4), GREEN, 'the view')); + }); + } + + test('resizing in the Worker resizes the view', async () => { + const { canvas, offscreen } = transferred(8, 8); + await worker().call('resize', { handle: OffscreenCanvas._toHandle(offscreen) }); + await eventually(() => { + equal(canvas.width, 20, 'width'); + equal(canvas.height, 10, 'height'); + }); + }); + + test('the Worker draws on after the view is gone', async () => { + const { canvas, offscreen } = transferred(8, 8); + await worker().call('hold', { handle: OffscreenCanvas._toHandle(offscreen) }); + canvas.disposeNativeView(); + await new Promise((resolve) => setTimeout(resolve, 100)); + const result = await worker().call('drawHeld', { color: 'blue' }); + expectNear(result.pixel, BLUE, 'in the Worker'); + }); + }); + + suite('offscreen.worker.postMessage', () => { + if (typeof (global as any).__nsRegisterTransferable !== 'function') { + skip('postMessage transfers an OffscreenCanvas', 'no transfer hooks in this runtime'); + return; + } + + test('postMessage transfers an OffscreenCanvas', async () => { + const canvas = new OffscreenCanvas(10, 10); + const result = await worker().call('2d', { canvas }, 10000, [canvas]); + equal(canvas.width, 0, 'the sent canvas is detached'); + equal(result.width, 10); + expectNear(result.corner, BLUE, 'corner'); + expectNear(result.rest, RED, 'rest'); + }); + + test("postMessage transfers a view's OffscreenCanvas", async () => { + const { canvas, offscreen } = transferred(10, 10); + await worker().call('2d', { canvas: offscreen }, 10000, [offscreen]); + await eventually(() => { + const url = canvas.toDataURL(); + expectNear(pixelOfDataURL(url, 10, 10, 1, 1), BLUE, 'the view, corner'); + expectNear(pixelOfDataURL(url, 10, 10, 8, 8), RED, 'the view, rest'); + }); + }); + + test('the Worker transfers an OffscreenCanvas back', async () => { + const result = await worker().call('sendBack', { width: 6, height: 4 }); + ok(result.canvas instanceof OffscreenCanvas, 'not an OffscreenCanvas'); + equal(result.canvas.width, 6); + const ctx = result.canvas.getContext('2d') as any; + ctx.fillStyle = 'red'; + ctx.fillRect(0, 0, 6, 4); + expectNear(pixelAt(ctx, 3, 2), RED, 'pixel'); + }); + + test('a canvas with a context cannot be transferred', async () => { + const canvas = new OffscreenCanvas(4, 4); + canvas.getContext('2d'); + const error = await rejects(worker().call('2d', { canvas }, 1000, [canvas])); + equal(error?.name, 'InvalidStateError'); + equal(canvas.width, 4, 'still attached'); + }); + + test('a value that is not transferable throws', async () => { + const error = await rejects(worker().call('2d', {}, 1000, [{}])); + equal(error?.name, 'DataCloneError'); + }); + }); + + suite('offscreen.worker', () => { + test('the Worker has requestAnimationFrame', async () => { + const { frames } = await worker().call('frames'); + ok(frames > 3, `expected frames, got ${frames}`); + }); + + for (const threaded of [true, false]) { + test(`2d draws in the Worker (${threaded ? 'threaded' : 'direct'}, made there)`, async () => { + const result = await worker().call('2d', { width: 8, height: 6, threaded }); + equal(result.width, 8); + equal(result.height, 6); + expectNear(result.corner, BLUE, 'corner'); + expectNear(result.rest, RED, 'rest'); + }); + } + + test('2d draws in the Worker (sent)', async () => { + const result = await worker().call('2d', { handle: handleOf(10, 10) }); + equal(result.width, 10); + expectNear(result.corner, BLUE, 'corner'); + expectNear(result.rest, RED, 'rest'); + }); + + test('a sent canvas is received once, in the Worker too', async () => { + const result = await worker().call('adoptTwice', { handle: handleOf(4, 4) }); + equal(result.threw, true); + equal(result.name, 'DataCloneError'); + }); + + test('resizing in the Worker resizes the bitmap', async () => { + const result = await worker().call('resize', { handle: handleOf(8, 8) }); + equal(result.width, 20); + equal(result.height, 10); + equal(result.imageWidth, 20); + expectNear(result.pixel, GREEN, 'bottom-right pixel'); + }); + + for (const version of ['webgl', 'webgl2']) { + for (const sent of [false, true]) { + test(`${version} draws in the Worker (${sent ? 'sent' : 'made there'})`, async () => { + const result = await worker().call('webgl', sent ? { version, handle: handleOf(8, 8) } : { version }); + ok(result.context, `no ${version} context`); + expectNear(result.pixel, GREEN, 'readPixels'); + expectNear(result.drawn, GREEN, 'drawn as an image'); + }); + } + } + + test('bitmaprenderer shows a bitmap in the Worker', async () => { + const result = await worker().call('bitmaprenderer'); + expectNear(result.pixel, RED, 'pixel'); + }); + + for (const sent of [false, true]) { + test(`webgpu draws in the Worker (${sent ? 'sent' : 'made there'})`, async () => { + const result = await worker().call('webgpu', sent ? { handle: handleOf(8, 8) } : {}); + if (result.skipped) { + console.log(`SPEC|note|offscreen.worker|webgpu: ${result.skipped}`); + return; + } + ok(result.context, 'no webgpu context'); + equal(result.width, 8); + expectNear(result.pixel, BLUE, 'pixel'); + }); + } + + test('convertToBlob in the Worker', async () => { + const result = await worker().call('convertToBlob', { width: 4, height: 4 }); + equal(result.type, 'image/png'); + ok(result.size > 0, 'an empty blob'); + }); + + test('terminating a drawing Worker leaves the main thread drawing', async () => { + const drawing = new WorkerClient(); + await drawing.call('loop', { handle: handleOf(16, 16) }); + await new Promise((resolve) => setTimeout(resolve, 200)); + drawing.terminate(); + await new Promise((resolve) => setTimeout(resolve, 200)); + const { ctx } = make2D(4, 4); + ctx.fillStyle = 'red'; + ctx.fillRect(0, 0, 4, 4); + expectNear(pixelAt(ctx, 2, 2), RED, 'main thread pixel'); + }); + + test('(cleanup) the shared Worker ends', () => { + client?.terminate(); + client = null; + }); + }); +} diff --git a/tools/demo/canvas/spec/offscreen.worker.ts b/tools/demo/canvas/spec/offscreen.worker.ts new file mode 100644 index 000000000..56917e991 --- /dev/null +++ b/tools/demo/canvas/spec/offscreen.worker.ts @@ -0,0 +1,194 @@ +import '@nativescript/canvas/worker'; +import { OffscreenCanvas } from '@nativescript/canvas/worker'; + +declare const self: any; +declare const navigator: any; +declare function requestAnimationFrame(callback: (time: number) => void): number; + +function canvasFor(args: any): OffscreenCanvas { + if (args?.canvas) { + return args.canvas; + } + return args?.handle ? OffscreenCanvas._fromHandle(args.handle) : new OffscreenCanvas(args?.width ?? 8, args?.height ?? 8); +} + +function pixel2D(ctx: any, x: number, y: number): number[] { + const d = ctx.getImageData(x, y, 1, 1).data; + return [d[0], d[1], d[2], d[3]]; +} + +function pixelOf(source: any, width: number, height: number, x: number, y: number): number[] { + const ctx = new OffscreenCanvas(width, height).getContext('2d') as any; + ctx.drawImage(source, 0, 0); + return pixel2D(ctx, x, y); +} + +let looping: { ctx: any; frames: number } | null = null; + +let held: { canvas: OffscreenCanvas; ctx: any } | null = null; + +const ops: Record any> = { + '2d'(args) { + const canvas = canvasFor(args); + const ctx = canvas.getContext('2d', { threaded: args?.threaded }) as any; + ctx.fillStyle = 'red'; + ctx.fillRect(0, 0, canvas.width, canvas.height); + ctx.fillStyle = 'blue'; + ctx.fillRect(0, 0, 2, 2); + return { + width: canvas.width, + height: canvas.height, + corner: pixel2D(ctx, 1, 1), + rest: pixel2D(ctx, canvas.width - 1, canvas.height - 1), + }; + }, + + resize(args) { + const canvas = canvasFor(args); + const ctx = canvas.getContext('2d') as any; + canvas.width = 20; + canvas.height = 10; + ctx.fillStyle = 'lime'; + ctx.fillRect(0, 0, 20, 10); + return { width: canvas.width, height: canvas.height, pixel: pixel2D(ctx, 19, 9), imageWidth: ctx.getImageData(0, 0, 20, 10).width }; + }, + + webgl(args) { + const canvas = canvasFor(args); + const gl = canvas.getContext(args?.version ?? 'webgl', { preserveDrawingBuffer: !!args?.preserve }) as any; + if (!gl) { + return { context: false }; + } + gl.viewport(0, 0, canvas.width, canvas.height); + gl.clearColor(0, 1, 0, 1); + gl.clear(gl.COLOR_BUFFER_BIT); + const pixel = new Uint8Array(4); + gl.readPixels(0, 0, 1, 1, gl.RGBA, gl.UNSIGNED_BYTE, pixel); + return { context: true, pixel: Array.from(pixel), drawn: pixelOf(canvas, canvas.width, canvas.height, 2, 2) }; + }, + + bitmaprenderer(args) { + const source = new OffscreenCanvas(4, 4); + const sctx = source.getContext('2d') as any; + sctx.fillStyle = 'red'; + sctx.fillRect(0, 0, 4, 4); + const bitmap = source.transferToImageBitmap(); + const canvas = canvasFor({ width: 4, height: 4, ...args }); + const ctx = canvas.getContext('bitmaprenderer') as any; + ctx.transferFromImageBitmap(bitmap); + return { pixel: pixelOf(canvas, 4, 4, 2, 2) }; + }, + + async webgpu(args) { + if (!navigator?.gpu) { + return { skipped: 'no navigator.gpu' }; + } + const adapter = await navigator.gpu.requestAdapter(); + if (!adapter) { + return { skipped: 'no adapter' }; + } + const device = await adapter.requestDevice(); + const canvas = canvasFor(args); + const context = canvas.getContext('webgpu') as any; + if (!context) { + return { context: false }; + } + context.configure({ device, format: navigator.gpu.getPreferredCanvasFormat(), alphaMode: 'premultiplied' }); + const encoder = device.createCommandEncoder(); + const pass = encoder.beginRenderPass({ + colorAttachments: [{ view: context.getCurrentTexture().createView(), clearValue: { r: 0, g: 0, b: 1, a: 1 }, loadOp: 'clear', storeOp: 'store' }], + }); + pass.end(); + device.queue.submit([encoder.finish()]); + const bitmap = canvas.transferToImageBitmap(); + return { context: true, width: bitmap.width, pixel: pixelOf(bitmap, canvas.width, canvas.height, 2, 2) }; + }, + + async convertToBlob(args) { + const canvas = canvasFor(args); + const ctx = canvas.getContext('2d') as any; + ctx.fillRect(0, 0, canvas.width, canvas.height); + const blob = await canvas.convertToBlob(); + return { type: blob.type, size: blob.size }; + }, + + async frames() { + if (typeof requestAnimationFrame !== 'function') { + return { frames: -1 }; + } + let frames = 0; + const start = Date.now(); + await new Promise((resolve) => { + const tick = () => { + frames++; + if (Date.now() - start > 300) { + resolve(); + } else { + requestAnimationFrame(tick); + } + }; + requestAnimationFrame(tick); + }); + return { frames }; + }, + + adoptTwice(args) { + OffscreenCanvas._fromHandle(args.handle); + try { + OffscreenCanvas._fromHandle(args.handle); + return { threw: false }; + } catch (e) { + return { threw: true, name: e?.name }; + } + }, + + hold(args) { + const canvas = canvasFor(args); + held = { canvas, ctx: canvas.getContext('2d') }; + return { width: canvas.width }; + }, + + drawHeld(args) { + if (!held) { + throw new Error('nothing held'); + } + held.ctx.fillStyle = args?.color ?? 'red'; + held.ctx.fillRect(0, 0, held.canvas.width, held.canvas.height); + return { pixel: pixel2D(held.ctx, 1, 1) }; + }, + + sendBack(args) { + const canvas = new OffscreenCanvas(args?.width ?? 8, args?.height ?? 8); + return { canvas, received: args?.canvas instanceof OffscreenCanvas, transfer: [canvas] }; + }, + + loop(args) { + const canvas = canvasFor(args); + looping = { ctx: canvas.getContext('2d'), frames: 0 }; + const tick = () => { + if (!looping) { + return; + } + looping.frames++; + looping.ctx.fillStyle = looping.frames % 2 ? 'red' : 'blue'; + looping.ctx.fillRect(0, 0, canvas.width, canvas.height); + requestAnimationFrame(tick); + }; + requestAnimationFrame(tick); + return { started: true }; + }, +}; + +self.onmessage = async (event: any) => { + const { id, op, args } = event.data ?? {}; + try { + const run = ops[op]; + if (!run) { + throw new Error(`unknown op ${op}`); + } + const result = await run(args); + self.postMessage({ id, ok: true, result }, result?.transfer); + } catch (e) { + self.postMessage({ id, ok: false, name: e?.name ?? 'Error', message: e?.message ?? String(e) }); + } +};