diff --git a/packages/native-renderer/Cargo.toml b/packages/native-renderer/Cargo.toml index 00e4dc39ce..046a09fcf4 100644 --- a/packages/native-renderer/Cargo.toml +++ b/packages/native-renderer/Cargo.toml @@ -5,7 +5,7 @@ edition = "2021" [dependencies] base64 = "0.22" -skia-safe = { version = "0.93", features = ["textlayout", "gpu", "metal"] } +skia-safe = { version = "0.93", features = ["textlayout", "gpu", "metal", "vulkan"] } serde = { version = "1", features = ["derive"] } serde_json = "1" diff --git a/packages/native-renderer/benches/render_bench.rs b/packages/native-renderer/benches/render_bench.rs index 2cd1dca3b7..154ee52f2a 100644 --- a/packages/native-renderer/benches/render_bench.rs +++ b/packages/native-renderer/benches/render_bench.rs @@ -120,11 +120,10 @@ fn bench_paint_frame(c: &mut Criterion) { }); } -#[cfg(target_os = "macos")] fn bench_gpu_paint_frame(c: &mut Criterion) { let scene = build_test_scene(); - let mut surface = RenderSurface::new_metal_gpu(1920, 1080) - .expect("Metal GPU surface required for this benchmark"); + let mut surface = RenderSurface::new_gpu_or_raster(1920, 1080) + .expect("GPU or raster surface required for this benchmark"); c.bench_function("gpu_paint_1080p_20_elements", |b| { let mut images = ImageCache::new(); @@ -199,9 +198,7 @@ fn build_30_frame_timeline() -> BakedTimeline { } } -/// End-to-end: GPU paint + JPEG encode + FFmpeg MJPEG pipe for 30 frames. -/// Uses JPEG to minimize pipe data (50KB/frame vs 8.3MB/frame raw). -#[cfg(target_os = "macos")] +/// End-to-end: CPU raster + JPEG encode + FFmpeg MJPEG pipe for 30 frames. fn bench_e2e_gpu_jpeg_30_frames(c: &mut Criterion) { use hyperframes_native_renderer::pipeline::{render_animated, RenderConfig}; @@ -223,11 +220,7 @@ fn bench_e2e_gpu_jpeg_30_frames(c: &mut Criterion) { }); } -/// End-to-end benchmark: GPU paint + raw BGRA + FFmpeg write for 30 frames. -/// -/// This measures the complete `render_animated_gpu` pipeline on a realistic -/// 1080p scene so we can track total per-frame cost including encode and I/O. -#[cfg(target_os = "macos")] +/// End-to-end benchmark: GPU paint + raw pixel pipe + FFmpeg hw encode for 30 frames. fn bench_e2e_gpu_30_frames(c: &mut Criterion) { use hyperframes_native_renderer::pipeline::{render_animated_gpu, RenderConfig}; @@ -249,7 +242,6 @@ fn bench_e2e_gpu_30_frames(c: &mut Criterion) { }); } -#[cfg(target_os = "macos")] criterion_group!( benches, bench_paint_frame, @@ -257,6 +249,6 @@ criterion_group!( bench_e2e_gpu_jpeg_30_frames, bench_e2e_gpu_30_frames ); -#[cfg(not(target_os = "macos"))] +#[cfg(any())] // dead code — kept for reference criterion_group!(benches, bench_paint_frame); criterion_main!(benches); diff --git a/packages/native-renderer/src/paint/canvas.rs b/packages/native-renderer/src/paint/canvas.rs index 1e0a3c52a0..502cd1e205 100644 --- a/packages/native-renderer/src/paint/canvas.rs +++ b/packages/native-renderer/src/paint/canvas.rs @@ -3,7 +3,6 @@ use skia_safe::{ Surface, }; -#[cfg(target_os = "macos")] use skia_safe::gpu; /// A Skia rendering surface backed by either CPU raster or GPU (Metal). @@ -17,7 +16,6 @@ use skia_safe::gpu; pub struct RenderSurface { surface: Surface, /// Keeps the GPU context alive for GPU-backed surfaces. `None` for raster. - #[cfg(target_os = "macos")] _gpu_context: Option, } @@ -28,7 +26,6 @@ impl RenderSurface { .ok_or_else(|| format!("failed to create {width}x{height} raster surface"))?; Ok(Self { surface, - #[cfg(target_os = "macos")] _gpu_context: None, }) } @@ -84,6 +81,67 @@ impl RenderSurface { }) } + /// Create a Vulkan GPU-accelerated surface (Linux with NVIDIA/AMD/Intel). + /// + /// Uses Skia's Vulkan backend via the system's Vulkan ICD. This is the + /// production path for cloud GPU instances (A10G, T4, L4). + #[cfg(target_os = "linux")] + pub fn new_vulkan_gpu(width: i32, height: i32) -> Result { + let backend = unsafe { + gpu::vk::BackendContext::new(std::ptr::null(), std::ptr::null(), std::ptr::null()) + }; + + // Skia's Vulkan backend needs a GetProc callback. Use the built-in + // Vulkan context factory which handles instance/device creation. + let mut context = gpu::direct_contexts::make_vulkan(&backend, None) + .ok_or("failed to create Skia Vulkan DirectContext — is Vulkan installed?")?; + + let image_info = ImageInfo::new( + (width, height), + ColorType::RGBA8888, + AlphaType::Premul, + None, + ); + + let surface = gpu::surfaces::render_target( + &mut context, + gpu::Budgeted::Yes, + &image_info, + None, + gpu::SurfaceOrigin::TopLeft, + None, + false, + false, + ) + .ok_or("failed to create Vulkan GPU surface")?; + + Ok(Self { + surface, + _gpu_context: Some(context), + }) + } + + /// Create the best available GPU surface for the current platform. + /// Falls back to CPU raster if no GPU is available. + pub fn new_gpu_or_raster(width: i32, height: i32) -> Result { + // Try GPU first, fall back to raster. + #[cfg(target_os = "macos")] + { + match Self::new_metal_gpu(width, height) { + Ok(s) => return Ok(s), + Err(e) => eprintln!("[native-renderer] Metal GPU unavailable ({e}), falling back to raster"), + } + } + #[cfg(target_os = "linux")] + { + match Self::new_vulkan_gpu(width, height) { + Ok(s) => return Ok(s), + Err(e) => eprintln!("[native-renderer] Vulkan GPU unavailable ({e}), falling back to raster"), + } + } + Self::new_raster(width, height) + } + /// Get the Skia canvas for drawing operations. pub fn canvas(&mut self) -> &Canvas { self.surface.canvas() @@ -126,7 +184,6 @@ impl RenderSurface { } fn encode_image(&mut self, format: EncodedImageFormat, quality: u32) -> Option> { - #[cfg(target_os = "macos")] self.flush_and_submit(); let image = self.surface.image_snapshot(); @@ -168,10 +225,14 @@ impl RenderSurface { /// /// This is a no-op on raster surfaces. On GPU surfaces, it ensures all /// queued draw calls are submitted before pixel readback or timing. - #[cfg(target_os = "macos")] pub fn flush_and_submit(&mut self) { if let Some(ctx) = self._gpu_context.as_mut() { ctx.flush_and_submit(); } } + + /// Returns true if this surface is GPU-backed (Metal or Vulkan). + pub fn is_gpu(&self) -> bool { + self._gpu_context.is_some() + } } diff --git a/packages/native-renderer/src/pipeline.rs b/packages/native-renderer/src/pipeline.rs index 5fd6fc3d8a..d5f2d60d3f 100644 --- a/packages/native-renderer/src/pipeline.rs +++ b/packages/native-renderer/src/pipeline.rs @@ -261,16 +261,12 @@ fn apply_deltas_recursive( // ── Chunk-Parallel GPU Pipeline (macOS Metal) ───────────────────────────── -/// Render an animated scene on the GPU with a background BGRA pipe to FFmpeg. +/// Render an animated scene with GPU acceleration (Metal on macOS, Vulkan on +/// Linux) and a background raw-pixel pipe to FFmpeg with hardware encoding. /// -/// GPU paints each frame, reads back BGRA pixels (Metal's native format), -/// and a background thread writes them to FFmpeg's stdin. Using BGRA avoids -/// the GPU-side BGRA→RGBA conversion during readback. -/// -/// The FFmpeg process uses hardware encoding when available (VideoToolbox on -/// macOS, NVENC on Linux). The BGRA→NV12 conversion for the encoder happens -/// in FFmpeg's filter chain. -#[cfg(target_os = "macos")] +/// Falls back to CPU raster if no GPU is available (Docker without GPU access). +/// Hardware encoding auto-detects: VideoToolbox (macOS), NVENC (NVIDIA), +/// VAAPI (Intel/AMD), libx264 (CPU fallback). pub fn render_animated_gpu( scene: &Scene, timeline: &BakedTimeline, @@ -283,7 +279,7 @@ pub fn render_animated_gpu( let width = scene.width as i32; let height = scene.height as i32; - let mut surface = RenderSurface::new_metal_gpu(width, height)?; + let mut surface = RenderSurface::new_gpu_or_raster(width, height)?; let mut images = ImageCache::new(); let (frame_tx, writer, _encoder) = diff --git a/packages/native-renderer/tests/animated_test.rs b/packages/native-renderer/tests/animated_test.rs index 9d6de32ede..0f116e108b 100644 --- a/packages/native-renderer/tests/animated_test.rs +++ b/packages/native-renderer/tests/animated_test.rs @@ -1,7 +1,6 @@ use std::collections::HashMap; use std::path::Path; -#[cfg(target_os = "macos")] use hyperframes_native_renderer::pipeline::render_animated_gpu; use hyperframes_native_renderer::pipeline::{render_animated, RenderConfig}; use hyperframes_native_renderer::scene::{ @@ -123,7 +122,6 @@ fn render_animated_scene_to_mp4() { std::fs::remove_file(output_path).ok(); } -#[cfg(target_os = "macos")] #[test] fn render_animated_gpu_scene_to_mp4() { let scene = make_animated_scene();