diff --git a/.changeset/webgpu-canvas.md b/.changeset/webgpu-canvas.md new file mode 100644 index 00000000..c8b575f1 --- /dev/null +++ b/.changeset/webgpu-canvas.md @@ -0,0 +1,10 @@ +--- +'@gpuix/native': minor +'@gpuix/react': minor +--- + +Add a desktop WebGPU API and a `` element so apps can draw with wgpu and composite into the GPUI window. + +`createGPUCanvas()` / `installWebGpu()` expose `navigator.gpu`, `GPUDevice`, and `canvas.getContext('webgpu')` for Three.js `WebGPURenderer`. Present copies pixels through `paint_image` on every OS. Untextured materials work. MSAA, cube maps, and `writeTexture` are not implemented. Keep the `GPUCanvas` object alive and call `destroy()` when done. + +The cube example needs **three >= 0.175**. 0.170's `getCacheKey` hashes a circular array; Bun/JSC overflows, Node does not. diff --git a/README.md b/README.md index 71e41026..61b6f367 100644 --- a/README.md +++ b/README.md @@ -1950,7 +1950,39 @@ Bash, TOML, YAML, Markdown, HTML, CSS, C. | `img` | Local/data URL raster or SVG images | | `svg` | Tintable monochrome SVG icons from source or disk | | `anchored` | Positioned overlay | -| `canvas` | Custom drawing (planned) | +| `canvas` | WebGPU canvas (`createGPUCanvas`) | + +## WebGPU and Three.js + +Desktop GPUIX exposes a **minimal Three.js WebGPU subset** through +`@gpuix/react/webgpu`. `installWebGpu()` sets `navigator.gpu`. +`createGPUCanvas()` is the swap surface Three.js needs. + +```tsx +import { render } from '@gpuix/react' +import { createGPUCanvas, installWebGpu } from '@gpuix/react/webgpu' +import { WebGPURenderer } from 'three/webgpu' + +installWebGpu() +const canvas = createGPUCanvas(640, 480) +const renderer = new WebGPURenderer({ canvas, antialias: false }) +await renderer.init() + +render() +// Keep `canvas` alive and call canvas.destroy() when the view unmounts. +``` + +Present currently copies pixels through `paint_image` on every OS. The WebGPU +device is a separate wgpu instance from the window, so Linux cannot sample the +canvas texture in-scene yet. Untextured materials, vertex and index buffers, uniforms, and a depth buffer +work. MSAA (`antialias: true`), cube maps, stencil, `writeTexture`, +`copyTextureToBuffer`, render bundles, and query sets are not implemented. + +Run the cube example: + +```bash +cd examples && bun --hot three-webgpu.tsx +``` ## Images and icons diff --git a/bun.lock b/bun.lock index 49d859c6..2fea6213 100644 --- a/bun.lock +++ b/bun.lock @@ -54,6 +54,7 @@ "safe-mdx": "1.14.0", "shiki": "^3.0.0", "string-dedent": "^3.0.2", + "three": "^0.176.0", }, "devDependencies": { "@types/mdast": "^4.0.4", @@ -895,6 +896,8 @@ "tar": ["tar@7.5.7", "", { "dependencies": { "@isaacs/fs-minipass": "^4.0.0", "chownr": "^3.0.0", "minipass": "^7.1.2", "minizlib": "^3.1.0", "yallist": "^5.0.0" } }, "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ=="], + "three": ["three@0.176.0", "", {}, "sha512-PWRKYWQo23ojf9oZSlRGH8K09q7nRSWx6LY/HF/UUrMdYgN9i1e2OwJYHoQjwc6HF/4lvvYLC5YC1X8UJL2ZpA=="], + "tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="], "tinyexec": ["tinyexec@1.0.2", "", {}, "sha512-W/KYk+NFhkmsYpuHq5JykngiOCnxeVL8v8dFnqxSD8qEEdRfXk1SDM6JzNqcERbcGYj9tMrDQBYV9cjgnunFIg=="], diff --git a/docs/plans/macos-windows-zero-copy-canvas.md b/docs/plans/macos-windows-zero-copy-canvas.md new file mode 100644 index 00000000..82f565ab --- /dev/null +++ b/docs/plans/macos-windows-zero-copy-canvas.md @@ -0,0 +1,242 @@ +--- +title: Zero-copy WebGPU canvas on macOS and Windows +description: > + Plan to present a GPUIX GPUCanvas without a CPU readback. macOS uses + IOSurface plus BGRA paint_surface. Windows needs a wgpu renderer and + DXGI shared textures. +--- + +# Zero-copy WebGPU canvas on macOS and Windows + +`` today paints through `paint_image`. That is CPU BGRA every +frame. Linux can sample an `Arc` in-scene after the +gpui-ce compositor port. macOS and Windows cannot. + +This plan is only the present path. The napi WebGPU API stays. + +``` +Three.js ► wgpu GPUCanvas texture ► shared GPU buffer ► GPUI samples it + │ + macOS: IOSurface / CVPixelBuffer + Windows: DXGI NT handle (needs wgpu, not D3D11) + Linux: already Arc on the window device +``` + +## Where we are + +| OS | Window GPU | Canvas present today | Zero-copy door | +|---|---|---|---| +| **Linux** | wgpu 29 / Vulkan | `paint_surface(Arc)` if same device | Done in `remorses/zed` `gpuix` (`8802c83138`) | +| **macOS** | Metal | `paint_image` CPU snapshot | `paint_surface(CVPixelBuffer)` is YCbCr-only. Aborts on BGRA | +| **Windows** | Direct3D **11** | `paint_image` | No texture composite. wgpu is DX12 | + +Linux still fails if `GPU.create()` makes a **second** wgpu instance. +`GPUAdapter.requestDevice` must return `Window::gpu_context()` there. +That is a GPUIX change, not this plan. + +## macOS: IOSurface, then same `MTLDevice` + +Two steps. Do **A** first. **B** is the real browser path. + +### A. IOSurface → `CVPixelBuffer` → `paint_surface` + +GPUI already composites `CVPixelBuffer` on Metal. The buffer must be +YCbCr or the renderer **aborts** (`assert_eq!` in +`zed/crates/gpui_apple/src/metal_renderer.rs`). + +[zed#61291](https://github.com/zed-industries/zed/pull/61291) (open) +adds BGRA. **~88 lines. Copy it.** + +| File | Diff | +|---|---| +| `crates/gpui_apple/src/metal_renderer.rs` | +76 / −6. `draw_bgra_surface` via `CVMetalTextureCache` | +| `crates/gpui_apple/src/shaders.metal` | +12. `surface_fragment_bgra`, nearest, straight alpha | + +Author validated CEF at 1200×1602@2x, 60 fps. CPU vs `paint_image` +dropped from ~25–40% of a core to ~6%. + +After that lands on `gpuix`: + +1. Allocate an **IOSurface** (BGRA, canvas size × scale) +2. Create an `MTLTexture` with + `newTextureWithDescriptor:iosurface:plane:` +3. Wrap that Metal texture as `wgpu::Texture` with + `wgpu_hal::metal::Device::texture_from_raw` + + `create_texture_from_hal` +4. Three.js renders into that wgpu texture +5. Wrap the same IOSurface: + `CVPixelBufferCreateWithIOSurface` +6. `window.paint_surface(bounds, pixel_buffer)` + +No CPU copy. wgpu and Metal still may be **two devices**. On Apple +Silicon that is unified RAM. It is a GPU alias, not a memcpy. + +Pool 2–3 IOSurfaces. Do not allocate per frame. +[syphon-metal](https://github.com/BlueJayLouche/syphon-rs/tree/main/syphon-metal) +does that (`IOSurfacePool`). + +### B. Same `MTLDevice` (later) + +Wrap GPUI’s Metal device with wgpu-hal so the canvas texture **is** a +Metal texture GPUI can bind. + +wgpu already has the hooks: + +- `device_from_raw` / `queue_from_raw` / + `texture_from_raw` + ([gfx-rs/wgpu#3338](https://github.com/gfx-rs/wgpu/pull/3338), + [wgpu-hal metal device.rs](https://github.com/gfx-rs/wgpu/blob/trunk/wgpu-hal/src/metal/device.rs)) + +[zed#60573](https://github.com/zed-industries/zed/pull/60573) (closed) +is **not** this. Its Metal arm is an empty match. Background colour +shows. Do not copy that stub. + +Need new Metal `draw_surfaces` for a raw `MTLTexture`, plus +`Window::gpu_context()` on macOS. That is 1–2 weeks after A. + +## Windows: wgpu first, then DXGI share + +GPUIX Windows is **D3D11**. wgpu 29 is **DX12**. You cannot wrap a +D3D11 device as WebGPU. + +Order: + +1. Move GPUIX Windows to `gpui_wgpu` (gpui-ce already did this with + `wgpu-surfaces`). See + [gpui-ce#121](https://github.com/gpui-ce/gpui-ce/pull/121). +2. Then either: + - **Same device** (best). `GPU.create()` returns + `Window::gpu_context()`. Sample `Arc` like Linux. + - **DXGI NT handle** if the canvas must stay on another D3D12 + device. `CreateSharedHandle` → `OpenSharedHandle` → + `wgpu_hal::dx12::Device::texture_from_raw`. Sync with + `IDXGIKeyedMutex` or an `ID3D12Fence`. + +Do not invent a D3D11 keyed-mutex path into the current DirectX +renderer. That fights the wgpu migration. + +## Related work (copy these, do not rewrite) + +**GPUI / Zed** + +- [zed#61291](https://github.com/zed-industries/zed/pull/61291) — + BGRA `CVPixelBuffer` on Metal. **Use this.** +- [zed#60573](https://github.com/zed-industries/zed/pull/60573) — + wgpu external compositor. Linux-shaped. Metal no-op. Closed. +- [zed discussion #60572](https://github.com/zed-industries/zed/discussions/60572) — + design thread for 60573. +- [gpui-ce surface.rs](https://github.com/gpui-ce/gpui-ce/blob/main/crates/gpui/src/elements/surface.rs) — + `SurfaceSource::Texture` is Linux / Windows+wgpu only. macOS is + still `CVPixelBuffer`. +- [gpui-ce#39](https://github.com/gpui-ce/gpui-ce/commit/6d043b22e477) / + [gpui-ce#121](https://github.com/gpui-ce/gpui-ce/pull/121) — + Linux/Windows texture composite. Already ported to `gpuix`. + +**IOSurface + wgpu / Metal (the canvas wrap)** + +- [slint servo metal.rs](https://github.com/slint-ui/slint/blob/master/examples/servo/src/webview/rendering_context/metal.rs) — + IOSurface → `newTextureWithDescriptor:iosurface:plane:` → + `texture_from_raw` → `create_texture_from_hal`. Closest copy-paste. +- [grafting `raw_gl/metal.rs`](https://docs.rs/grafting/latest/grafting/) — + same three steps, then optional BGRA→RGBA blit. +- [wgpu-native-texture-interop](https://docs.rs/wgpu-native-texture-interop/latest/wgpu_native_texture_interop/) — + same Metal import, plus DX12 `OpenSharedHandle`. +- [bevy_cef#56](https://github.com/not-elm/bevy_cef/pull/56) — + CEF `OnAcceleratedPaint` IOSurface. Import **inside** the frame + encoder. Extra `queue.submit` from a callback races present. +- [syphon-metal](https://github.com/BlueJayLouche/syphon-rs/tree/main/syphon-metal) — + `IOSurfacePool`, `create_texture_from_iosurface`, + `MetalContext::from_wgpu_device`. +- [CefSwift](https://github.com/Rajaniraiyn/CefSwift) — + OSR → IOSurface → `CALayer`. Layer overlay, not in-scene. +- [encse/cef-test](https://github.com/encse/cef-test) — + older CEF + Metal HUD POC. +- [Chromium `io_surface.cc`](https://chromium.googlesource.com/chromium/src/+/master/ui/gfx/mac/io_surface.cc) — + how Chrome allocates IOSurfaces (`IOSurfaceCreate`, pixel format, + plane layout). + +**Windows DXGI** + +- [grafting `dx12_shared_texture.rs`](https://docs.rs/grafting/latest/grafting/) — + `CreateSharedHandle` / `OpenSharedHandle` / + `texture_from_raw`. +- [IDXGIKeyedMutex::AcquireSync](https://learn.microsoft.com/en-us/windows/win32/api/dxgi/nf-dxgi-idxgikeyedmutex-acquiresync) +- [ID3D12Device::OpenSharedHandle](https://learn.microsoft.com/en-us/windows/win32/api/d3d12/nf-d3d12-id3d12device-opensharedhandle) +- [wgpu#4067](https://github.com/gfx-rs/wgpu/issues/4067) — + public import/export of native textures is still “not yet” at the + wgpu-native C API. HAL `texture_from_raw` is the Rust path. + +**Apple docs** + +- [IOSurface](https://developer.apple.com/documentation/iosurface/iosurface) +- [CVPixelBuffer](https://developer.apple.com/documentation/corevideo/cvpixelbuffer) +- [MTLDevice](https://developer.apple.com/documentation/metal/mtldevice) + +## AppKit / CoreVideo APIs to use with WebGPU + +These are the handles, not a second canvas API. + +| API | Role | +|---|---| +| `IOSurfaceCreate` | Shared GPU buffer. Width, height, `kIOSurfacePixelFormat` = `'BGRA'` | +| `MTLDevice.newTextureWithDescriptor:iosurface:plane:` | Metal view of that buffer | +| `wgpu_hal::metal::Device::texture_from_raw` | wgpu view of that Metal texture | +| `CVPixelBufferCreateWithIOSurface` | GPUI `paint_surface` input | +| `CVMetalTextureCacheCreateTextureFromImage` | What #61291 already uses to bind the buffer in Metal | +| `IOSurfaceLock` / `Unlock` | Only if CPU ever touches the pages. Skip for GPU-only | + +Do **not** put a second `CAMetalLayer` over the GPUI window. That is +not a flex child. Clicks, clip, and z-order break. +[CefSwift](https://github.com/Rajaniraiyn/CefSwift) does that for a +webview overlay. GPUIX canvas must stay in the scene. + +## Phases + +**0. Linux same-device (GPUIX, small)** +`requestDevice` returns `window.gpu_context()`. Until then, Linux +must `paint_image` too or wgpu panics on `same_device`. + +**1. Cherry-pick [zed#61291](https://github.com/zed-industries/zed/pull/61291) onto `gpuix`** +Metal accepts BGRA `CVPixelBuffer`. ~88 lines. Test: wrap a solid +BGRA IOSurface and `paint_surface` it. No WebGPU yet. + +**2. macOS canvas on IOSurface** +Copy the Slint / grafting import. Pool surfaces. `` calls +`paint_surface` instead of `paint_image`. Keep CPU snapshot as +fallback if wrap fails. + +**3. Windows wgpu renderer** +Follow gpui-ce Windows wgpu. Then same-device composite like Linux. + +**4. Optional: macOS same `MTLDevice`** +Drop the IOSurface hop. Metal samples the wgpu texture directly. + +## Tests + +1. Native: create BGRA `CVPixelBuffer`, `paint_surface`, screenshot + is not black. Proves #61291. +2. Native: wgpu clear magenta into an IOSurface-backed texture, + composite, centre pixel is magenta. No `readPixels` CPU path. +3. `examples/three-webgpu.tsx` cube on macOS. Same PNG assert as + today, but `canvas_snapshot` must not run (log or counter). +4. Resize the canvas. New IOSurface. Old one dropped. +5. Windows: skip until wgpu renderer. Do not fake D3D11 share. + +## Do not + +- Wait for [zed#60573](https://github.com/zed-industries/zed/pull/60573). + Closed. Metal empty. +- Overlay a `CAMetalLayer`. +- Add Dawn or `@napi-rs/canvas`. +- Share across two wgpu instances without IOSurface / DXGI. +- Copy Slint’s Y-flip blit unless GPUI samples upside down. GPUI + Metal and wgpu Metal agree on origin more often than GL. + +## Size + +| Phase | Effort | +|---|---| +| 1. #61291 | 1 day | +| 2. IOSurface canvas | 3–5 days | +| 3. Windows wgpu | week+ (platform swap) | +| 4. Same MTLDevice | 1–2 weeks after 2 | diff --git a/examples/package.json b/examples/package.json index f182bec8..78e81a14 100644 --- a/examples/package.json +++ b/examples/package.json @@ -12,6 +12,7 @@ "chat": "bun --hot chat.tsx", "infinite-chat": "bun --hot infinite-chat.tsx", "timeline": "bun --hot timeline.tsx", + "three-webgpu": "bun --hot three-webgpu.tsx", "compile": "bun compile-chat.ts", "bench:serde": "bun bench-serialization.ts" }, @@ -22,7 +23,8 @@ "react": "^19.2.4", "safe-mdx": "1.14.0", "shiki": "^3.0.0", - "string-dedent": "^3.0.2" + "string-dedent": "^3.0.2", + "three": "^0.176.0" }, "devDependencies": { "@types/mdast": "^4.0.4", diff --git a/examples/three-webgpu.test.tsx b/examples/three-webgpu.test.tsx new file mode 100644 index 00000000..2ab5cb64 --- /dev/null +++ b/examples/three-webgpu.test.tsx @@ -0,0 +1,65 @@ +/// Three.js WebGPURenderer draws a cube into a GPUIX GPUCanvas. +import "./webgpu-polyfill.ts" +import fs from "fs" +import path from "path" +import { fileURLToPath } from "url" +import { describe, expect, it } from "vitest" +import React from "react" +import { BoxGeometry, Color, Mesh, MeshBasicMaterial, PerspectiveCamera, Scene } from "three" +import { WebGPURenderer } from "three/webgpu" +import { createGPUCanvas, installWebGpu } from "@gpuix/react/webgpu" +import { createTestRoot, hasNativeTestRenderer } from "@gpuix/react/testing" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip +const shots = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../packages/react/screenshots") + +describeNative("three webgpu cube", () => { + it("inits WebGPURenderer and paints a cube", { timeout: 30_000 }, async () => { + installWebGpu() + const canvas = createGPUCanvas(320, 240) + const scene = new Scene() + scene.background = new Color(0x11111b) + const camera = new PerspectiveCamera(50, 320 / 240, 0.1, 100) + camera.position.set(1.5, 1.2, 3) + camera.lookAt(0, 0, 0) + const mesh = new Mesh( + new BoxGeometry(1, 1, 1), + new MeshBasicMaterial({ color: 0xf38ba8 }), + ) + scene.add(mesh) + + const renderer = new WebGPURenderer({ canvas, antialias: false }) + await renderer.init() + expect(renderer.backend.isWebGPUBackend).toBe(true) + renderer.setSize(320, 240, false) + renderer.render(scene, camera) + + const root = createTestRoot({ width: 320, height: 240 }) + fs.mkdirSync(shots, { recursive: true }) + const blankPath = path.join(shots, "gpuix-three-webgpu-cube-blank.png") + const pngPath = path.join(shots, "gpuix-three-webgpu-cube.png") + root.render() + root.renderer.flush() + root.renderer.flush() + if (fs.existsSync(blankPath)) fs.unlinkSync(blankPath) + root.renderer.captureScreenshot(blankPath) + + root.render() + root.renderer.flush() + root.renderer.flush() + if (fs.existsSync(pngPath)) fs.unlinkSync(pngPath) + root.renderer.captureScreenshot(pngPath) + expect(fs.existsSync(pngPath)).toBe(true) + const pixels = canvas.readPixels() + expect(pixels.length).toBe(320 * 240 * 4) + const center = pixels.subarray((120 * 320 + 160) * 4, (120 * 320 + 160) * 4 + 3) + expect(center[0]).toBeGreaterThan(100) + const blank = fs.readFileSync(blankPath) + const painted = fs.readFileSync(pngPath) + if (!process.env.CI) { + expect(blank.equals(painted)).toBe(false) + } + renderer.dispose() + canvas.destroy() + }) +}) diff --git a/examples/three-webgpu.tsx b/examples/three-webgpu.tsx new file mode 100644 index 00000000..d671d3e7 --- /dev/null +++ b/examples/three-webgpu.tsx @@ -0,0 +1,86 @@ +/** + * GPUIX Three.js WebGPU cube. + * + * Draws a rotating cube with three/webgpu into a GPUCanvas, then composites + * that canvas into the GPUI window. Needs three >= 0.175: 0.170's getCacheKey + * hashes a circular array and Bun/JSC overflows. + */ +import "./webgpu-polyfill.ts" +import React, { useEffect, useState } from "react" +import { BoxGeometry, Color, Mesh, MeshBasicMaterial, PerspectiveCamera, Scene } from "three" +import { WebGPURenderer } from "three/webgpu" +import { render } from "@gpuix/react" +import { createGPUCanvas, installWebGpu } from "@gpuix/react/webgpu" + +installWebGpu() + +function CubeApp() { + const [canvas] = useState(() => createGPUCanvas(640, 480)) + + useEffect(() => { + let disposed = false + let frame = 0 + let webgpu: WebGPURenderer | undefined + ;(async () => { + const scene = new Scene() + scene.background = new Color(0x11111b) + const camera = new PerspectiveCamera(50, 640 / 480, 0.1, 100) + camera.position.z = 3 + const mesh = new Mesh( + new BoxGeometry(1, 1, 1), + new MeshBasicMaterial({ color: 0xf38ba8 }), + ) + scene.add(mesh) + + const renderer = new WebGPURenderer({ + canvas, + antialias: false, + }) + await renderer.init() + webgpu = renderer + if (disposed) { + renderer.dispose() + return + } + renderer.setSize(640, 480, false) + + const tick = () => { + if (disposed) return + mesh.rotation.x += 0.012 + mesh.rotation.y += 0.018 + renderer.render(scene, camera) + frame = requestAnimationFrame(tick) + } + tick() + })().catch((error) => { + console.error(error) + }) + return () => { + disposed = true + cancelAnimationFrame(frame) + webgpu?.dispose() + canvas.destroy() + } + }, [canvas]) + + return ( +
+ +
+ ) +} + +render(, { + title: "GPUIX Three.js WebGPU", + width: 800, + height: 600, + focus: process.env.GPUIX_BACKGROUND !== "1", +}) diff --git a/examples/webgpu-polyfill.ts b/examples/webgpu-polyfill.ts new file mode 100644 index 00000000..14b621ac --- /dev/null +++ b/examples/webgpu-polyfill.ts @@ -0,0 +1,15 @@ +/// three/webgpu reads `self` and requestAnimationFrame at import/init time. +const global = globalThis as { + self?: typeof globalThis + requestAnimationFrame?: typeof requestAnimationFrame + cancelAnimationFrame?: typeof cancelAnimationFrame +} +global.self ??= globalThis +global.requestAnimationFrame ??= ((callback: FrameRequestCallback) => { + return setTimeout(() => callback(Date.now()), 16) as unknown as number +}) as typeof requestAnimationFrame +global.cancelAnimationFrame ??= ((id: number) => { + clearTimeout(id) +}) as typeof cancelAnimationFrame +import { installWebGpu } from "@gpuix/react/webgpu" +installWebGpu() diff --git a/packages/native/Cargo.lock b/packages/native/Cargo.lock index cbf5ac1a..5f1f3b08 100644 --- a/packages/native/Cargo.lock +++ b/packages/native/Cargo.lock @@ -2535,12 +2535,14 @@ dependencies = [ "gpui", "gpui_macos", "gpui_platform", + "image", "js-sys", "log", "napi", "napi-build", "napi-derive", "parking_lot", + "pollster 0.4.0", "pulldown-cmark", "rmp-serde", "rustc-hash 2.1.1", @@ -2553,6 +2555,7 @@ dependencies = [ "wasm-bindgen", "web-sys", "web-time", + "wgpu", "windows 0.61.3", ] @@ -2880,6 +2883,7 @@ dependencies = [ "qoi", "ravif", "rayon", + "rgb", "tiff", "zune-core 0.5.1", "zune-jpeg 0.5.12", diff --git a/packages/native/Cargo.toml b/packages/native/Cargo.toml index e3dba864..ab5584de 100644 --- a/packages/native/Cargo.toml +++ b/packages/native/Cargo.toml @@ -45,6 +45,10 @@ syntect = { version = "5.3", default-features = false, features = ["regex-onig"] two-face = { version = "0.5.2", default-features = false, features = ["syntect-onig"] } gpui = { path = "../../zed/crates/gpui", default-features = false, features = ["font-kit", "profiler"] } gpui_platform = { path = "../../zed/crates/gpui_platform", default-features = false, features = ["font-kit", "wayland", "x11"] } +# Same wgpu as zed. Desktop WebGPU napi wraps this; do not add Dawn. +wgpu = "29.0.4" +image = "0.25" +pollster = "0.4" [target.'cfg(not(all(target_arch = "wasm32", target_os = "unknown")))'.dependencies] napi = { version = "3", features = ["napi8", "serde-json"] } diff --git a/packages/native/index.d.ts b/packages/native/index.d.ts index 170109d5..4e8314dc 100644 --- a/packages/native/index.d.ts +++ b/packages/native/index.d.ts @@ -1,5 +1,104 @@ /* auto-generated by NAPI-RS */ /* eslint-disable */ +export declare class Gpu { + static create(): Gpu + requestAdapter(powerPreference?: string | undefined | null): GPUAdapter + getPreferredCanvasFormat(): string +} +export type GPU = Gpu + +export declare class GpuAdapter { + requestDevice(): GPUDevice + get limits(): DeviceLimits + get info(): AdapterInfo + get isFallbackAdapter(): boolean +} +export type GPUAdapter = GpuAdapter + +export declare class GpuBindGroup { + +} +export type GPUBindGroup = GpuBindGroup + +export declare class GpuBindGroupLayout { + +} +export type GPUBindGroupLayout = GpuBindGroupLayout + +export declare class GpuBuffer { + get size(): number + get usage(): number + mapAsync(mode: number, offset?: number | undefined | null, size?: number | undefined | null): void + getMappedRange(offset?: number | undefined | null, size?: number | undefined | null): ArrayBuffer + unmap(): void + destroy(): void +} +export type GPUBuffer = GpuBuffer + +export declare class GpuCanvas { + constructor(width: number, height: number) + get id(): number + get width(): number + set width(width: number) + get height(): number + set height(height: number) + readPixels(): Buffer + destroy(): void + getContext(contextId: string): GPUCanvasContext +} +export type GPUCanvas = GpuCanvas + +export declare class GpuCanvasContext { + configure(configuration: GPUCanvasConfiguration, device: GpuDevice): void + getCurrentTexture(): GpuTexture + unconfigure(): void +} +export type GPUCanvasContext = GpuCanvasContext + +export declare class GpuCommandBuffer { + +} +export type GPUCommandBuffer = GpuCommandBuffer + +export declare class GpuCommandEncoder { + beginRenderPass(descriptor: RenderPassDescriptor, colorViews: Array, colorResolveViews?: Array | undefined | null, depthStencilView?: GpuTextureView | undefined | null): GPURenderPassEncoder + beginComputePass(descriptor?: ComputePassDescriptor | undefined | null): GPUComputePassEncoder + copyBufferToBuffer(source: GpuBuffer, sourceOffset: number, destination: GpuBuffer, destinationOffset: number, size: number): void + finish(): GPUCommandBuffer +} +export type GPUCommandEncoder = GpuCommandEncoder + +export declare class GpuComputePassEncoder { + setPipeline(pipeline: GpuComputePipeline): void + setBindGroup(index: number, bindGroup: GpuBindGroup, dynamicOffsets?: Array | undefined | null): void + dispatchWorkgroups(x: number, y?: number | undefined | null, z?: number | undefined | null): void + end(): void +} +export type GPUComputePassEncoder = GpuComputePassEncoder + +export declare class GpuComputePipeline { + getBindGroupLayout(index: number): GpuBindGroupLayout +} +export type GPUComputePipeline = GpuComputePipeline + +export declare class GpuDevice { + get queue(): GPUQueue + get label(): string | null + get limits(): DeviceLimits + createBuffer(descriptor: BufferDescriptor): GPUBuffer + createTexture(descriptor: TextureDescriptor): GPUTexture + createSampler(descriptor?: SamplerDescriptor | undefined | null): GPUSampler + createShaderModule(descriptor: ShaderModuleDescriptor): GPUShaderModule + createBindGroupLayout(descriptor: BindGroupLayoutDescriptor): GPUBindGroupLayout + createPipelineLayout(descriptor: PipelineLayoutDescriptor, bindGroupLayouts: Array): GPUPipelineLayout + createBindGroup(descriptor: BindGroupDescriptor, layout: GPUBindGroupLayout, entries: Array, buffers?: Array | undefined | null, textures?: Array | undefined | null, samplers?: Array | undefined | null): GPUBindGroup + createRenderPipeline(descriptor: RenderPipelineDescriptor, layout: GPUPipelineLayout | undefined | null, vertexModule: GPUShaderModule, fragmentModule?: GPUShaderModule | undefined | null): GPURenderPipeline + createComputePipeline(descriptor: ComputePipelineDescriptor, layout: GPUPipelineLayout | undefined | null, module: GPUShaderModule): GPUComputePipeline + createCommandEncoder(descriptor?: CommandEncoderDescriptor | undefined | null): GPUCommandEncoder + destroy(): void +} +export type GPUDevice = GpuDevice + /** The main GPUI renderer exposed to Node.js. */ export declare class GpuixRenderer { constructor(eventCallback?: (((err: Error | null, arg: EventPayload) => any)) | undefined | null) @@ -134,6 +233,60 @@ export declare class GpuixRenderer { captureScreenshot(path: string): void } +export declare class GpuPipelineLayout { + +} +export type GPUPipelineLayout = GpuPipelineLayout + +export declare class GpuQueue { + submit(commandBuffers: Array): void + onSubmittedWorkDone(): void + writeBuffer(buffer: GPUBuffer, offset: number, data: Buffer, dataOffset?: number | undefined | null, size?: number | undefined | null): void + get label(): string | null +} +export type GPUQueue = GpuQueue + +export declare class GpuRenderPassEncoder { + setPipeline(pipeline: GpuRenderPipeline): void + setBindGroup(index: number, bindGroup: GpuBindGroup, dynamicOffsets?: Array | undefined | null): void + setVertexBuffer(slot: number, buffer: GpuBuffer, offset?: number | undefined | null, size?: number | undefined | null): void + setIndexBuffer(buffer: GpuBuffer, indexFormat: string, offset?: number | undefined | null, size?: number | undefined | null): void + draw(vertexCount: number, instanceCount?: number | undefined | null, firstVertex?: number | undefined | null, firstInstance?: number | undefined | null): void + drawIndexed(indexCount: number, instanceCount?: number | undefined | null, firstIndex?: number | undefined | null, baseVertex?: number | undefined | null, firstInstance?: number | undefined | null): void + setViewport(x: number, y: number, width: number, height: number, minDepth?: number | undefined | null, maxDepth?: number | undefined | null): void + setScissorRect(x: number, y: number, width: number, height: number): void + end(): void +} +export type GPURenderPassEncoder = GpuRenderPassEncoder + +export declare class GpuRenderPipeline { + getBindGroupLayout(index: number): GpuBindGroupLayout +} +export type GPURenderPipeline = GpuRenderPipeline + +export declare class GpuSampler { + +} +export type GPUSampler = GpuSampler + +export declare class GpuShaderModule { + +} +export type GPUShaderModule = GpuShaderModule + +export declare class GpuTexture { + createView(descriptor?: TextureViewDescriptor | undefined | null): GPUTextureView + get width(): number + get height(): number + destroy(): void +} +export type GPUTexture = GpuTexture + +export declare class GpuTextureView { + +} +export type GPUTextureView = GpuTextureView + /** * GPU-backed GPUI test renderer. Uses VisualTestAppContext with the native * Metal or DirectX renderer and TestDispatcher for deterministic scheduling. @@ -338,6 +491,94 @@ export declare class TestGpuixRenderer { getWindowSize(): WindowSize } +export interface AdapterInfo { + vendor: string + architecture: string + device: string + description: string +} + +export interface BindGroupDescriptor { + label?: string +} + +export interface BindGroupEntry { + binding: number + resourceType: string + offset?: number + size?: number +} + +export interface BindGroupLayoutDescriptor { + label?: string + entries: Array +} + +export interface BindGroupLayoutEntry { + binding: number + visibility: number + buffer?: BufferBindingLayout + sampler?: SamplerBindingLayout + texture?: TextureBindingLayout + storageTexture?: StorageTextureBindingLayout +} + +export interface BlendComponent { + srcFactor: string + dstFactor: string + operation: string +} + +export interface BlendState { + color: BlendComponent + alpha: BlendComponent +} + +export interface BufferBindingLayout { + type?: string + hasDynamicOffset?: boolean + minBindingSize?: number +} + +export interface BufferDescriptor { + label?: string + size: number + usage: number + mappedAtCreation?: boolean +} + +export interface BufferUsage { + mapRead: number + mapWrite: number + copySrc: number + copyDst: number + index: number + vertex: number + uniform: number + storage: number + indirect: number + queryResolve: number +} + +export interface ColorTargetState { + format: string + blend?: BlendState + writeMask?: number +} + +export interface CommandEncoderDescriptor { + label?: string +} + +export interface ComputePassDescriptor { + label?: string +} + +export interface ComputePipelineDescriptor { + label?: string + entryPoint: string +} + /** Recorded draw times from the debug frame overlay. */ export interface DebugFrameOverlayStats { currentMs?: number @@ -348,6 +589,23 @@ export interface DebugFrameOverlayStats { samples: number } +export interface DepthStencilState { + format: string + depthWriteEnabled?: boolean + depthCompare?: string +} + +export interface DeviceLimits { + maxTextureDimension1D: number + maxTextureDimension2D: number + maxTextureDimension3D: number + maxBindGroups: number + maxBufferSize: number + maxUniformBufferBindingSize: number + minUniformBufferOffsetAlignment: number + maxComputeWorkgroupsPerDimension: number +} + export interface EdgeInsets { top: number right: number @@ -462,6 +720,32 @@ export interface EventPayload { modifiers?: EventModifiers } +export interface FragmentState { + entryPoint: string + targets: Array +} + +export declare function getPreferredCanvasFormat(): string + +export declare function gpuBufferUsage(): BufferUsage + +export interface GpuCanvasConfiguration { + format?: string + usage?: number + alphaMode?: string +} + +export interface GpuColor { + r: number + g: number + b: number + a: number +} + +export declare function gpuShaderStage(): ShaderStage + +export declare function gpuTextureUsage(): TextureUsage + /** True only when this binary compiled the real GPU test renderer. */ export declare function hasTestGpuixRenderer(): boolean @@ -493,6 +777,138 @@ export interface HighlightRect { height: number } +export interface MultisampleState { + count?: number + mask?: number + alphaToCoverageEnabled?: boolean +} + +export interface PipelineLayoutDescriptor { + label?: string +} + +export interface PrimitiveState { + topology?: string + frontFace?: string + cullMode?: string +} + +export interface RenderPassColorAttachment { + clearValue?: GpuColor + loadOp: string + storeOp: string +} + +export interface RenderPassDepthStencilAttachment { + depthClearValue?: number + depthLoadOp?: string + depthStoreOp?: string +} + +export interface RenderPassDescriptor { + label?: string + colorAttachments: Array + depthStencilAttachment?: RenderPassDepthStencilAttachment +} + +export interface RenderPipelineDescriptor { + label?: string + vertex: VertexState + primitive?: PrimitiveState + depthStencil?: DepthStencilState + multisample?: MultisampleState + fragment?: FragmentState +} + +export interface SamplerBindingLayout { + type?: string +} + +export interface SamplerDescriptor { + label?: string + addressModeU?: string + addressModeV?: string + addressModeW?: string + magFilter?: string + minFilter?: string + mipmapFilter?: string + lodMinClamp?: number + lodMaxClamp?: number + compare?: string + maxAnisotropy?: number +} + +export interface ShaderModuleDescriptor { + label?: string + code: string +} + +export interface ShaderStage { + vertex: number + fragment: number + compute: number +} + +export interface StorageTextureBindingLayout { + access?: string + format: string + viewDimension?: string +} + +export interface TextureBindingLayout { + sampleType?: string + viewDimension?: string + multisampled?: boolean +} + +export interface TextureDescriptor { + label?: string + width: number + height: number + depth?: number + format: string + usage: number + dimension?: string + mipLevelCount?: number + sampleCount?: number +} + +export interface TextureUsage { + copySrc: number + copyDst: number + textureBinding: number + storageBinding: number + renderAttachment: number +} + +export interface TextureViewDescriptor { + label?: string + format?: string + dimension?: string + aspect?: string + baseMipLevel?: number + mipLevelCount?: number + baseArrayLayer?: number + arrayLayerCount?: number +} + +export interface VertexAttribute { + format: string + offset: number + shaderLocation: number +} + +export interface VertexBufferLayout { + arrayStride: number + stepMode?: string + attributes: Array +} + +export interface VertexState { + entryPoint: string + buffers?: Array +} + export interface WindowInsets { safeArea: EdgeInsets ime: EdgeInsets diff --git a/packages/native/index.js b/packages/native/index.js index ee79c88b..4b97d183 100644 --- a/packages/native/index.js +++ b/packages/native/index.js @@ -576,6 +576,50 @@ if (!nativeBinding) { } module.exports = nativeBinding +module.exports.Gpu = nativeBinding.Gpu +module.exports.GPU = nativeBinding.GPU +module.exports.GpuAdapter = nativeBinding.GpuAdapter +module.exports.GPUAdapter = nativeBinding.GPUAdapter +module.exports.GpuBindGroup = nativeBinding.GpuBindGroup +module.exports.GPUBindGroup = nativeBinding.GPUBindGroup +module.exports.GpuBindGroupLayout = nativeBinding.GpuBindGroupLayout +module.exports.GPUBindGroupLayout = nativeBinding.GPUBindGroupLayout +module.exports.GpuBuffer = nativeBinding.GpuBuffer +module.exports.GPUBuffer = nativeBinding.GPUBuffer +module.exports.GpuCanvas = nativeBinding.GpuCanvas +module.exports.GPUCanvas = nativeBinding.GPUCanvas +module.exports.GpuCanvasContext = nativeBinding.GpuCanvasContext +module.exports.GPUCanvasContext = nativeBinding.GPUCanvasContext +module.exports.GpuCommandBuffer = nativeBinding.GpuCommandBuffer +module.exports.GPUCommandBuffer = nativeBinding.GPUCommandBuffer +module.exports.GpuCommandEncoder = nativeBinding.GpuCommandEncoder +module.exports.GPUCommandEncoder = nativeBinding.GPUCommandEncoder +module.exports.GpuComputePassEncoder = nativeBinding.GpuComputePassEncoder +module.exports.GPUComputePassEncoder = nativeBinding.GPUComputePassEncoder +module.exports.GpuComputePipeline = nativeBinding.GpuComputePipeline +module.exports.GPUComputePipeline = nativeBinding.GPUComputePipeline +module.exports.GpuDevice = nativeBinding.GpuDevice +module.exports.GPUDevice = nativeBinding.GPUDevice module.exports.GpuixRenderer = nativeBinding.GpuixRenderer +module.exports.GpuPipelineLayout = nativeBinding.GpuPipelineLayout +module.exports.GPUPipelineLayout = nativeBinding.GPUPipelineLayout +module.exports.GpuQueue = nativeBinding.GpuQueue +module.exports.GPUQueue = nativeBinding.GPUQueue +module.exports.GpuRenderPassEncoder = nativeBinding.GpuRenderPassEncoder +module.exports.GPURenderPassEncoder = nativeBinding.GPURenderPassEncoder +module.exports.GpuRenderPipeline = nativeBinding.GpuRenderPipeline +module.exports.GPURenderPipeline = nativeBinding.GPURenderPipeline +module.exports.GpuSampler = nativeBinding.GpuSampler +module.exports.GPUSampler = nativeBinding.GPUSampler +module.exports.GpuShaderModule = nativeBinding.GpuShaderModule +module.exports.GPUShaderModule = nativeBinding.GPUShaderModule +module.exports.GpuTexture = nativeBinding.GpuTexture +module.exports.GPUTexture = nativeBinding.GPUTexture +module.exports.GpuTextureView = nativeBinding.GpuTextureView +module.exports.GPUTextureView = nativeBinding.GPUTextureView module.exports.TestGpuixRenderer = nativeBinding.TestGpuixRenderer +module.exports.getPreferredCanvasFormat = nativeBinding.getPreferredCanvasFormat +module.exports.gpuBufferUsage = nativeBinding.gpuBufferUsage +module.exports.gpuShaderStage = nativeBinding.gpuShaderStage +module.exports.gpuTextureUsage = nativeBinding.gpuTextureUsage module.exports.hasTestGpuixRenderer = nativeBinding.hasTestGpuixRenderer diff --git a/packages/native/src/custom_elements/anchored.rs b/packages/native/src/custom_elements/anchored.rs index 7cafd390..1efb1b82 100644 --- a/packages/native/src/custom_elements/anchored.rs +++ b/packages/native/src/custom_elements/anchored.rs @@ -411,5 +411,5 @@ impl CustomElement for AnchoredElement { &["click", "mouseEnter", "mouseLeave"] } - fn destroy(&mut self) {} + fn destroy(&mut self, _window: Option<&mut gpui::Window>) {} } diff --git a/packages/native/src/custom_elements/canvas.rs b/packages/native/src/custom_elements/canvas.rs new file mode 100644 index 00000000..d7b1a6dd --- /dev/null +++ b/packages/native/src/custom_elements/canvas.rs @@ -0,0 +1,126 @@ +/// `` paints a GPUCanvas snapshot into the GPUI scene via `paint_image`. +/// +/// The WebGPU device is a separate wgpu instance from the window, so Linux +/// `paint_surface` cannot sample these textures yet. +use super::{CustomElement, CustomElementFactory, CustomRenderContext}; +use gpui::prelude::*; +use std::cell::RefCell; +use std::rc::Rc; +use std::sync::Arc; + +pub struct CanvasFactory; + +impl CustomElementFactory for CanvasFactory { + fn element_type(&self) -> &str { + "canvas" + } + + fn create(&self, _id: u64) -> Box { + Box::new(CanvasElement::default()) + } +} + +#[derive(Default)] +pub struct CanvasElement { + source: Option, + last_image: Rc>>>, +} + +impl CustomElement for CanvasElement { + fn render( + &mut self, + ctx: CustomRenderContext, + _window: &mut gpui::Window, + _cx: &mut gpui::Context, + ) -> gpui::AnyElement { + let host_id = gpui::SharedString::from(format!("__gpuix_canvas_{}", ctx.id)); + let source = self.source; + let last_image = self.last_image.clone(); + let el = super::custom_surface( + gpui::div() + .id(host_id) + .overflow_hidden() + .child( + gpui::canvas( + |_, _, _| (), + { + let last_image = last_image.clone(); + move |bounds, _, window, _| { + let next = source.and_then(crate::webgpu::canvas_snapshot); + let previous = last_image.borrow().clone(); + if let Some(previous) = previous { + let changed = next + .as_ref() + .is_none_or(|image| !Arc::ptr_eq(image, &previous)); + if changed { + last_image.borrow_mut().take(); + if let Err(error) = window.drop_image(previous) { + log::warn!("drop_image failed: {error:#}"); + } + } + } + if let Some(image) = next { + if let Err(error) = window.paint_image( + bounds, + bounds, + gpui::Corners::default(), + image.clone(), + 0, + false, + ) { + log::warn!("paint_image failed: {error:#}"); + } else { + *last_image.borrow_mut() = Some(image); + } + window.request_animation_frame(); + } + } + }, + ) + .size_full(), + ), + &ctx, + ); + el.into_any_element() + } + + fn set_prop(&mut self, key: &str, value: serde_json::Value) { + if key == "source" { + self.source = match &value { + serde_json::Value::Number(number) => number.as_u64().or_else(|| { + number.as_f64().and_then(|value| { + if value.is_finite() && value >= 0.0 && value.fract() == 0.0 { + Some(value as u64) + } else { + None + } + }) + }), + serde_json::Value::Object(object) => object.get("id").and_then(|value| { + value + .as_u64() + .or_else(|| value.as_f64().map(|value| value as u64)) + }), + _ => None, + }; + } + } + + fn supported_props(&self) -> &'static [&'static str] { + &["source"] + } + + fn supported_events(&self) -> &'static [&'static str] { + &["click", "mouseEnter", "mouseLeave"] + } + + fn destroy(&mut self, window: Option<&mut gpui::Window>) { + if let Some(image) = self.last_image.borrow_mut().take() { + if let Some(window) = window { + if let Err(error) = window.drop_image(image) { + log::warn!("drop_image failed: {error:#}"); + } + } + } + } +} diff --git a/packages/native/src/custom_elements/code.rs b/packages/native/src/custom_elements/code.rs index fc50029f..480d8798 100644 --- a/packages/native/src/custom_elements/code.rs +++ b/packages/native/src/custom_elements/code.rs @@ -263,7 +263,7 @@ impl CustomElement for CodeElement { &["click", "mouseEnter", "mouseLeave"] } - fn destroy(&mut self) {} + fn destroy(&mut self, _window: Option<&mut gpui::Window>) {} } /// Line-number gutter width, sized analytically from the digit count so the diff --git a/packages/native/src/custom_elements/diff.rs b/packages/native/src/custom_elements/diff.rs index 173d39d1..21c71c60 100644 --- a/packages/native/src/custom_elements/diff.rs +++ b/packages/native/src/custom_elements/diff.rs @@ -428,7 +428,7 @@ impl CustomElement for DiffElement { ] } - fn destroy(&mut self) { + fn destroy(&mut self, _window: Option<&mut gpui::Window>) { self.list_state = None; self.list_metrics = None; self.data = None; diff --git a/packages/native/src/custom_elements/img.rs b/packages/native/src/custom_elements/img.rs index c588627b..9d9fcda2 100644 --- a/packages/native/src/custom_elements/img.rs +++ b/packages/native/src/custom_elements/img.rs @@ -182,7 +182,7 @@ impl CustomElement for ImgElement { &["click", "mouseEnter", "mouseLeave"] } - fn destroy(&mut self) {} + fn destroy(&mut self, _window: Option<&mut gpui::Window>) {} } #[derive(Debug, Clone, Default)] @@ -300,5 +300,5 @@ impl CustomElement for SvgElement { &["click", "mouseEnter", "mouseLeave"] } - fn destroy(&mut self) {} + fn destroy(&mut self, _window: Option<&mut gpui::Window>) {} } diff --git a/packages/native/src/custom_elements/input.rs b/packages/native/src/custom_elements/input.rs index f7d69ad7..6f378ff5 100644 --- a/packages/native/src/custom_elements/input.rs +++ b/packages/native/src/custom_elements/input.rs @@ -476,7 +476,7 @@ impl CustomElement for TextEditorElement { ] } - fn destroy(&mut self) { + fn destroy(&mut self, _window: Option<&mut gpui::Window>) { self.state = None; } } diff --git a/packages/native/src/custom_elements/markdown.rs b/packages/native/src/custom_elements/markdown.rs index 9878e2e9..18e57a3d 100644 --- a/packages/native/src/custom_elements/markdown.rs +++ b/packages/native/src/custom_elements/markdown.rs @@ -133,7 +133,7 @@ impl CustomElement for MarkdownElement { &["linkClick", "click", "mouseEnter", "mouseLeave"] } - fn destroy(&mut self) { + fn destroy(&mut self, _window: Option<&mut gpui::Window>) { self.tree = None; } } diff --git a/packages/native/src/custom_elements/mod.rs b/packages/native/src/custom_elements/mod.rs index b7dea435..032fcf2c 100644 --- a/packages/native/src/custom_elements/mod.rs +++ b/packages/native/src/custom_elements/mod.rs @@ -14,6 +14,7 @@ use std::collections::{HashMap, HashSet}; use crate::renderer::EventCallback; pub mod anchored; +pub mod canvas; pub mod code; pub mod diff; pub mod img; @@ -208,8 +209,8 @@ pub trait CustomElement: 'static { /// Immutable event capability declaration for this adapter. fn supported_events(&self) -> &'static [&'static str]; - /// Clean up resources (GPUI entities, subscriptions, etc.) - fn destroy(&mut self); + /// Clean up resources (GPUI entities, subscriptions, atlas images). + fn destroy(&mut self, window: Option<&mut gpui::Window>); } /// Factory for creating CustomElement instances. @@ -284,6 +285,7 @@ impl CustomElementRegistry { registry.register(Box::new(input::InputFactory)); registry.register(Box::new(input::TextareaFactory)); registry.register(Box::new(anchored::AnchoredFactory)); + registry.register(Box::new(canvas::CanvasFactory)); registry.register(Box::new(img::ImgFactory)); registry.register(Box::new(img::SvgFactory)); registry.register(Box::new(code::CodeFactory)); @@ -305,7 +307,7 @@ impl CustomElementRegistry { .get(&id) .is_some_and(|entry| entry.element_type != element_type) { - self.destroy(id); + self.destroy(id, None); } match self.instances.entry(id) { @@ -353,14 +355,14 @@ impl CustomElementRegistry { } /// Called when React destroys an element. - pub fn destroy(&mut self, id: u64) { + pub fn destroy(&mut self, id: u64, window: Option<&mut gpui::Window>) { if let Some(mut entry) = self.instances.remove(&id) { - entry.element.destroy(); + entry.element.destroy(window); } } /// Remove and destroy instances whose IDs no longer exist in the tree. - pub fn prune_missing(&mut self, mut is_live: F) + pub fn prune_missing(&mut self, window: &mut gpui::Window, mut is_live: F) where F: FnMut(u64) -> bool, { @@ -372,7 +374,7 @@ impl CustomElementRegistry { .collect(); for id in stale_ids { - self.destroy(id); + self.destroy(id, Some(window)); } } @@ -385,7 +387,7 @@ impl CustomElementRegistry { pub fn destroy_all(&mut self) { let ids: Vec = self.instances.keys().copied().collect(); for id in ids { - self.destroy(id); + self.destroy(id, None); } } } @@ -424,7 +426,7 @@ mod tests { &["click"] } - fn destroy(&mut self) { + fn destroy(&mut self, _window: Option<&mut gpui::Window>) { self.destroyed.set(self.destroyed.get() + 1); } } diff --git a/packages/native/src/lib.rs b/packages/native/src/lib.rs index a69f0b94..4ee77883 100644 --- a/packages/native/src/lib.rs +++ b/packages/native/src/lib.rs @@ -24,6 +24,8 @@ mod renderer; // The data model is public so `examples/bench_serde.rs` measures the real // types instead of a copy that silently drifts from them. pub mod retained_tree; +#[cfg(not(target_family = "wasm"))] +mod webgpu; pub mod style; mod syntax; mod text; diff --git a/packages/native/src/renderer.rs b/packages/native/src/renderer.rs index f1a55cb7..70fa9a45 100644 --- a/packages/native/src/renderer.rs +++ b/packages/native/src/renderer.rs @@ -3856,7 +3856,7 @@ impl gpui::Render for GpuixView { // Ensure custom element instances are destroyed when their IDs disappear. self.custom_registry - .prune_missing(|id| tree.elements.contains_key(&id)); + .prune_missing(window, |id| tree.elements.contains_key(&id)); // Clean up scroll handles for destroyed elements (IDs removed from tree). // Scrollability-based cleanup (element still exists but style changed @@ -4053,11 +4053,11 @@ pub(crate) fn build_element( // (onClick, hover, focus, tabIndex) type-checked, registered a JS // listener, and then silently did nothing. "div" | "text" => { - ctx.custom_registry.destroy(id); + ctx.custom_registry.destroy(id, Some(window)); build_host_container(element, style, ctx, window, cx) } "virtual-list" => { - ctx.custom_registry.destroy(id); + ctx.custom_registry.destroy(id, Some(window)); build_virtual_list(element, ctx, window, cx) } diff --git a/packages/native/src/webgpu.rs b/packages/native/src/webgpu.rs new file mode 100644 index 00000000..4415c880 --- /dev/null +++ b/packages/native/src/webgpu.rs @@ -0,0 +1,2149 @@ +/// Desktop WebGPU napi over wgpu 29. Class layout follows @sylphx/webgpu. +/// +/// The device is a wgpu instance GPUIX owns, not the window device, so +/// Linux `paint_surface` cannot sample these textures. Present is a cached +/// `paint_image` snapshot on every OS until the window device is shared. +use napi::bindgen_prelude::*; +use napi_derive::napi; +use parking_lot::Mutex; +use std::collections::HashMap; +use std::sync::atomic::{AtomicU64, Ordering}; +use std::sync::Arc; + +static NEXT_CANVAS_ID: AtomicU64 = AtomicU64::new(1); +static SUBMIT_EPOCH: AtomicU64 = AtomicU64::new(0); +static CANVASES: Mutex>>>> = Mutex::new(None); + +fn canvases() -> parking_lot::MutexGuard<'static, Option>>>> { + CANVASES.lock() +} + +pub(crate) fn canvas_snapshot(id: u64) -> Option> { + let guard = canvases(); + let inner = guard.as_ref()?.get(&id)?.clone(); + drop(guard); + let result = inner.lock().snapshot_image(); + match result { + Ok(image) => Some(image), + Err(error) => { + log::warn!("GPUCanvas snapshot failed: {error}"); + None + } + } +} + +fn register_canvas(id: u64, inner: Arc>) { + canvases() + .get_or_insert_with(HashMap::new) + .insert(id, inner); +} + +fn unregister_canvas(id: u64) { + if let Some(map) = canvases().as_mut() { + map.remove(&id); + } +} + +#[napi] +pub struct GPU { + instance: wgpu::Instance, +} + +#[napi] +impl GPU { + #[napi(factory)] + pub fn create() -> Self { + Self { + instance: wgpu::Instance::new(wgpu::InstanceDescriptor { + backends: wgpu::Backends::PRIMARY, + flags: wgpu::InstanceFlags::default(), + backend_options: wgpu::BackendOptions::default(), + memory_budget_thresholds: wgpu::MemoryBudgetThresholds::default(), + display: None, + }), + } + } + + #[napi] + pub fn request_adapter(&self, power_preference: Option) -> Result { + let power_pref = match power_preference.as_deref() { + Some("low-power") => wgpu::PowerPreference::LowPower, + Some("high-performance") => wgpu::PowerPreference::HighPerformance, + _ => wgpu::PowerPreference::HighPerformance, + }; + let adapter = pollster::block_on(self.instance.request_adapter(&wgpu::RequestAdapterOptions { + power_preference: power_pref, + compatible_surface: None, + force_fallback_adapter: false, + })) + .map_err(|error| Error::from_reason(format!("No GPU adapter: {error}")))?; + Ok(GPUAdapter { adapter }) + } + + #[napi(js_name = "getPreferredCanvasFormat")] + pub fn get_preferred_canvas_format(&self) -> String { + "bgra8unorm".into() + } +} + +#[napi] +pub fn get_preferred_canvas_format() -> String { + "bgra8unorm".into() +} + +#[napi] +pub struct GPUAdapter { + adapter: wgpu::Adapter, +} + +#[napi] +impl GPUAdapter { + #[napi] + pub fn request_device(&self) -> Result { + let (device, queue) = pollster::block_on(self.adapter.request_device(&wgpu::DeviceDescriptor { + label: Some("gpuix_webgpu"), + required_features: wgpu::Features::empty(), + required_limits: self.adapter.limits(), + memory_hints: wgpu::MemoryHints::MemoryUsage, + trace: wgpu::Trace::Off, + experimental_features: wgpu::ExperimentalFeatures::disabled(), + })) + .map_err(|error| Error::from_reason(format!("Failed to request device: {error}")))?; + device.on_uncaptured_error(Arc::new(|error| { + log::error!("wgpu validation error: {error}"); + })); + Ok(GPUDevice::new(Arc::new(device), Arc::new(queue))) + } + + #[napi(getter)] + pub fn limits(&self) -> DeviceLimits { + DeviceLimits::from_wgpu(&self.adapter.limits()) + } + + #[napi(getter)] + pub fn info(&self) -> AdapterInfo { + let info = self.adapter.get_info(); + AdapterInfo { + vendor: info.vendor.to_string(), + architecture: String::new(), + device: info.name, + description: format!("{:?}", info.backend), + } + } + + #[napi(getter)] + pub fn is_fallback_adapter(&self) -> bool { + self.adapter.get_info().device_type == wgpu::DeviceType::Cpu + } +} + +#[napi(object)] +pub struct AdapterInfo { + pub vendor: String, + pub architecture: String, + pub device: String, + pub description: String, +} + +#[napi(object)] +pub struct DeviceLimits { + pub max_texture_dimension_1d: u32, + pub max_texture_dimension_2d: u32, + pub max_texture_dimension_3d: u32, + pub max_bind_groups: u32, + pub max_buffer_size: i64, + pub max_uniform_buffer_binding_size: i64, + pub min_uniform_buffer_offset_alignment: u32, + pub max_compute_workgroups_per_dimension: u32, +} + +impl DeviceLimits { + fn from_wgpu(limits: &wgpu::Limits) -> Self { + Self { + max_texture_dimension_1d: limits.max_texture_dimension_1d, + max_texture_dimension_2d: limits.max_texture_dimension_2d, + max_texture_dimension_3d: limits.max_texture_dimension_3d, + max_bind_groups: limits.max_bind_groups, + max_buffer_size: limits.max_buffer_size as i64, + max_uniform_buffer_binding_size: limits.max_uniform_buffer_binding_size as i64, + min_uniform_buffer_offset_alignment: limits.min_uniform_buffer_offset_alignment, + max_compute_workgroups_per_dimension: limits.max_compute_workgroups_per_dimension, + } + } +} + +#[napi] +pub struct GPUDevice { + pub(crate) device: Arc, + pub(crate) queue_internal: Arc, +} + +impl GPUDevice { + fn new(device: Arc, queue: Arc) -> Self { + Self { + device, + queue_internal: queue, + } + } +} + +#[napi] +impl GPUDevice { + #[napi(getter)] + pub fn queue(&self) -> GPUQueue { + GPUQueue { + queue: self.queue_internal.clone(), + device: self.device.clone(), + } + } + + #[napi(getter)] + pub fn label(&self) -> Option { + None + } + + #[napi(getter)] + pub fn limits(&self) -> DeviceLimits { + DeviceLimits::from_wgpu(&self.device.limits()) + } + + #[napi(js_name = "createBuffer")] + pub fn create_buffer(&self, descriptor: BufferDescriptor) -> Result { + let size = non_negative_u64(descriptor.size, "size")?; + let mapped_at_creation = descriptor.mapped_at_creation.unwrap_or(false); + let buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: descriptor.label.as_deref(), + size, + usage: wgpu::BufferUsages::from_bits_truncate(descriptor.usage), + mapped_at_creation, + }); + let mapped_bytes = if mapped_at_creation { + let length = usize::try_from(size) + .map_err(|_| Error::from_reason("size is too large for this platform"))?; + Some(MappedRange { + offset: 0, + end: size, + write: true, + bytes: Arc::new(Mutex::new(vec![0u8; length])), + }) + } else { + None + }; + Ok(GPUBuffer { + buffer: Arc::new(buffer), + device: self.device.clone(), + mapped: Mutex::new(mapped_bytes), + }) + } + + #[napi(js_name = "createTexture")] + pub fn create_texture(&self, descriptor: TextureDescriptor) -> Result { + Ok(GPUTexture::from_wgpu(self.create_wgpu_texture(&descriptor)?)) + } + + #[napi(js_name = "createSampler")] + pub fn create_sampler(&self, descriptor: Option) -> GPUSampler { + let descriptor = descriptor.unwrap_or_default(); + let sampler = self.device.create_sampler(&wgpu::SamplerDescriptor { + label: descriptor.label.as_deref(), + address_mode_u: parse_address_mode(descriptor.address_mode_u.as_deref()), + address_mode_v: parse_address_mode(descriptor.address_mode_v.as_deref()), + address_mode_w: parse_address_mode(descriptor.address_mode_w.as_deref()), + mag_filter: parse_filter_mode(descriptor.mag_filter.as_deref()), + min_filter: parse_filter_mode(descriptor.min_filter.as_deref()), + mipmap_filter: parse_mipmap_filter_mode(descriptor.mipmap_filter.as_deref()), + lod_min_clamp: descriptor.lod_min_clamp.unwrap_or(0.0) as f32, + lod_max_clamp: descriptor.lod_max_clamp.unwrap_or(32.0) as f32, + compare: parse_compare_function(descriptor.compare.as_deref()), + anisotropy_clamp: descriptor.max_anisotropy.unwrap_or(1), + border_color: None, + }); + GPUSampler { + sampler: Arc::new(sampler), + } + } + + #[napi(js_name = "createShaderModule")] + pub fn create_shader_module(&self, descriptor: ShaderModuleDescriptor) -> GPUShaderModule { + let shader = self.device.create_shader_module(wgpu::ShaderModuleDescriptor { + label: descriptor.label.as_deref(), + source: wgpu::ShaderSource::Wgsl(descriptor.code.into()), + }); + GPUShaderModule { + shader: Arc::new(shader), + } + } + + #[napi(js_name = "createBindGroupLayout")] + pub fn create_bind_group_layout( + &self, + descriptor: BindGroupLayoutDescriptor, + ) -> Result { + let entries: Result> = descriptor + .entries + .iter() + .map(convert_bind_group_layout_entry) + .collect(); + let entries = entries?; + let layout = self.device.create_bind_group_layout(&wgpu::BindGroupLayoutDescriptor { + label: descriptor.label.as_deref(), + entries: &entries, + }); + Ok(GPUBindGroupLayout { + layout: Arc::new(layout), + }) + } + + #[napi(js_name = "createPipelineLayout")] + pub fn create_pipeline_layout( + &self, + descriptor: PipelineLayoutDescriptor, + bind_group_layouts: Vec<&GPUBindGroupLayout>, + ) -> GPUPipelineLayout { + let layouts: Vec<_> = bind_group_layouts + .iter() + .map(|layout| Some(layout.layout.as_ref())) + .collect(); + let layout = self.device.create_pipeline_layout(&wgpu::PipelineLayoutDescriptor { + label: descriptor.label.as_deref(), + bind_group_layouts: &layouts, + immediate_size: 0, + }); + GPUPipelineLayout { + layout: Arc::new(layout), + } + } + + #[napi(js_name = "createBindGroup")] + pub fn create_bind_group( + &self, + descriptor: BindGroupDescriptor, + layout: &GPUBindGroupLayout, + entries: Vec, + buffers: Option>, + textures: Option>, + samplers: Option>, + ) -> Result { + let mut buffer_index = 0; + let mut texture_index = 0; + let mut sampler_index = 0; + let wgpu_entries: Result> = entries + .iter() + .map(|entry| { + let resource = match entry.resource_type.as_str() { + "buffer" => { + let buffers = buffers + .as_ref() + .ok_or_else(|| Error::from_reason("No buffers for bind group"))?; + let buffer = buffers + .get(buffer_index) + .ok_or_else(|| Error::from_reason("Not enough buffers"))?; + buffer_index += 1; + wgpu::BindingResource::Buffer(wgpu::BufferBinding { + buffer: &buffer.buffer, + offset: entry.offset.unwrap_or(0) as u64, + size: entry + .size + .and_then(|size| std::num::NonZeroU64::new(size as u64)), + }) + } + "texture" => { + let textures = textures + .as_ref() + .ok_or_else(|| Error::from_reason("No textures for bind group"))?; + let texture = textures + .get(texture_index) + .ok_or_else(|| Error::from_reason("Not enough textures"))?; + texture_index += 1; + wgpu::BindingResource::TextureView(&texture.view) + } + "sampler" => { + let samplers = samplers + .as_ref() + .ok_or_else(|| Error::from_reason("No samplers for bind group"))?; + let sampler = samplers + .get(sampler_index) + .ok_or_else(|| Error::from_reason("Not enough samplers"))?; + sampler_index += 1; + wgpu::BindingResource::Sampler(&sampler.sampler) + } + other => { + return Err(Error::from_reason(format!("Invalid resource_type: {other}"))); + } + }; + Ok(wgpu::BindGroupEntry { + binding: entry.binding, + resource, + }) + }) + .collect(); + let wgpu_entries = wgpu_entries?; + let bind_group = self.device.create_bind_group(&wgpu::BindGroupDescriptor { + label: descriptor.label.as_deref(), + layout: &layout.layout, + entries: &wgpu_entries, + }); + Ok(GPUBindGroup { + bind_group: Arc::new(bind_group), + }) + } + + #[napi(js_name = "createRenderPipeline")] + pub fn create_render_pipeline( + &self, + descriptor: RenderPipelineDescriptor, + layout: Option<&GPUPipelineLayout>, + vertex_module: &GPUShaderModule, + fragment_module: Option<&GPUShaderModule>, + ) -> Result { + let vertex_attributes: Vec> = match &descriptor.vertex.buffers { + Some(buffers) => buffers + .iter() + .map(|buffer| { + buffer + .attributes + .iter() + .map(|attribute| { + Ok(wgpu::VertexAttribute { + format: parse_vertex_format(&attribute.format)?, + offset: attribute.offset as u64, + shader_location: attribute.shader_location, + }) + }) + .collect::>>() + }) + .collect::>>()?, + None => Vec::new(), + }; + let vertex_buffers: Vec = descriptor + .vertex + .buffers + .as_ref() + .map(|buffers| { + buffers + .iter() + .enumerate() + .map(|(index, buffer)| wgpu::VertexBufferLayout { + array_stride: buffer.array_stride as u64, + step_mode: if buffer.step_mode.as_deref() == Some("instance") { + wgpu::VertexStepMode::Instance + } else { + wgpu::VertexStepMode::Vertex + }, + attributes: &vertex_attributes[index], + }) + .collect() + }) + .unwrap_or_default(); + let primitive = descriptor + .primitive + .as_ref() + .map(parse_primitive) + .unwrap_or_default(); + let depth_stencil = descriptor + .depth_stencil + .as_ref() + .map(parse_depth_stencil) + .transpose()?; + let frag_targets: Vec> = match descriptor.fragment.as_ref() { + Some(fragment) => fragment + .targets + .iter() + .map(|target| { + Ok(Some(wgpu::ColorTargetState { + format: parse_texture_format(&target.format)?, + blend: target.blend.as_ref().map(parse_blend), + write_mask: target + .write_mask + .map(|mask| { + wgpu::ColorWrites::from_bits(mask).unwrap_or(wgpu::ColorWrites::ALL) + }) + .unwrap_or(wgpu::ColorWrites::ALL), + })) + }) + .collect::>>()?, + None => Vec::new(), + }; + let fragment = if let (Some(fragment), Some(module)) = + (descriptor.fragment.as_ref(), fragment_module) + { + Some(wgpu::FragmentState { + module: &module.shader, + entry_point: Some(&fragment.entry_point), + compilation_options: wgpu::PipelineCompilationOptions::default(), + targets: &frag_targets, + }) + } else { + None + }; + let pipeline = self.device.create_render_pipeline(&wgpu::RenderPipelineDescriptor { + label: descriptor.label.as_deref(), + layout: layout.map(|layout| layout.layout.as_ref()), + vertex: wgpu::VertexState { + module: &vertex_module.shader, + entry_point: Some(&descriptor.vertex.entry_point), + compilation_options: wgpu::PipelineCompilationOptions::default(), + buffers: &vertex_buffers, + }, + fragment, + primitive, + depth_stencil, + multisample: wgpu::MultisampleState { + count: descriptor + .multisample + .as_ref() + .and_then(|state| state.count) + .unwrap_or(1), + mask: descriptor + .multisample + .as_ref() + .and_then(|state| state.mask) + .map(u64::from) + .unwrap_or(!0), + alpha_to_coverage_enabled: descriptor + .multisample + .as_ref() + .and_then(|state| state.alpha_to_coverage_enabled) + .unwrap_or(false), + }, + multiview_mask: None, + cache: None, + }); + Ok(GPURenderPipeline { + pipeline: Arc::new(pipeline), + }) + } + + #[napi(js_name = "createComputePipeline")] + pub fn create_compute_pipeline( + &self, + descriptor: ComputePipelineDescriptor, + layout: Option<&GPUPipelineLayout>, + module: &GPUShaderModule, + ) -> GPUComputePipeline { + let pipeline = self.device.create_compute_pipeline(&wgpu::ComputePipelineDescriptor { + label: descriptor.label.as_deref(), + layout: layout.map(|layout| layout.layout.as_ref()), + module: &module.shader, + entry_point: Some(&descriptor.entry_point), + compilation_options: wgpu::PipelineCompilationOptions::default(), + cache: None, + }); + GPUComputePipeline { + pipeline: Arc::new(pipeline), + } + } + + #[napi(js_name = "createCommandEncoder")] + pub fn create_command_encoder( + &self, + descriptor: Option, + ) -> GPUCommandEncoder { + let encoder = self.device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: descriptor.as_ref().and_then(|descriptor| descriptor.label.as_deref()), + }); + GPUCommandEncoder { + encoder: Some(encoder), + device: self.device.clone(), + } + } + + #[napi] + pub fn destroy(&self) { + // wgpu drops the device when the last Arc is released. + } + + fn create_wgpu_texture(&self, descriptor: &TextureDescriptor) -> Result { + let dimension = match descriptor.dimension.as_deref() { + Some("1d") => wgpu::TextureDimension::D1, + Some("3d") => wgpu::TextureDimension::D3, + _ => wgpu::TextureDimension::D2, + }; + Ok(self.device.create_texture(&wgpu::TextureDescriptor { + label: descriptor.label.as_deref(), + size: wgpu::Extent3d { + width: descriptor.width.max(1), + height: descriptor.height.max(1), + depth_or_array_layers: descriptor.depth.unwrap_or(1).max(1), + }, + mip_level_count: descriptor.mip_level_count.unwrap_or(1), + sample_count: descriptor.sample_count.unwrap_or(1), + dimension, + format: parse_texture_format(&descriptor.format)?, + usage: wgpu::TextureUsages::from_bits_truncate(descriptor.usage), + view_formats: &[], + })) + } +} + +#[napi] +pub struct GPUQueue { + queue: Arc, + device: Arc, +} + +#[napi] +impl GPUQueue { + #[napi] + pub fn submit(&self, command_buffers: Vec<&mut GPUCommandBuffer>) { + let buffers: Vec = command_buffers + .into_iter() + .filter_map(|buffer| buffer.buffer.take()) + .collect(); + self.queue.submit(buffers); + SUBMIT_EPOCH.fetch_add(1, Ordering::Release); + } + + #[napi(js_name = "onSubmittedWorkDone")] + pub fn on_submitted_work_done(&self) -> Result<()> { + self.device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }) + .map_err(|error| { + Error::from_reason(format!("onSubmittedWorkDone poll failed: {error:?}")) + })?; + Ok(()) + } + + #[napi(js_name = "writeBuffer")] + pub fn write_buffer( + &self, + buffer: &GPUBuffer, + offset: i64, + data: Buffer, + data_offset: Option, + size: Option, + ) { + let bytes = data.as_ref(); + let start = data_offset.unwrap_or(0).max(0) as usize; + let copy_size = size.map(|size| size.max(0) as usize); + let end = copy_size + .map(|size| start.saturating_add(size)) + .unwrap_or(bytes.len()) + .min(bytes.len()); + let slice = if start >= bytes.len() { + &[] + } else { + &bytes[start..end] + }; + if offset < 0 { + return; + } + self.queue + .write_buffer(&buffer.buffer, offset as u64, slice); + } + + #[napi(getter)] + pub fn label(&self) -> Option { + None + } +} + +struct MappedRange { + offset: u64, + end: u64, + write: bool, + bytes: Arc>>, +} + +#[napi] +pub struct GPUBuffer { + buffer: Arc, + device: Arc, + mapped: Mutex>, +} + +#[napi] +impl GPUBuffer { + #[napi(getter)] + pub fn size(&self) -> f64 { + self.buffer.size() as f64 + } + + #[napi(getter)] + pub fn usage(&self) -> u32 { + self.buffer.usage().bits() + } + + #[napi(js_name = "mapAsync")] + pub fn map_async(&self, mode: u32, offset: Option, size: Option) -> Result<()> { + let offset = finite_u64(offset.unwrap_or(0.0), "offset")?; + let mapped_size = match size { + Some(size) => finite_u64(size, "size")?, + None => self.buffer.size().saturating_sub(offset), + }; + let end = offset + .checked_add(mapped_size) + .ok_or_else(|| Error::from_reason("mapAsync range overflow"))?; + if end > self.buffer.size() { + return Err(Error::from_reason("mapAsync range is out of bounds")); + } + let mode = if mode & 0x0002 != 0 { + wgpu::MapMode::Write + } else { + wgpu::MapMode::Read + }; + let (sender, receiver) = std::sync::mpsc::sync_channel(1); + self.buffer.slice(offset..end).map_async(mode, move |result| { + let _ = sender.send(result); + }); + self.device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }) + .map_err(|error| Error::from_reason(format!("mapAsync poll failed: {error:?}")))?; + receiver + .recv() + .map_err(|_| Error::from_reason("mapAsync channel closed"))? + .map_err(|error| Error::from_reason(format!("mapAsync failed: {error}")))?; + let bytes = if mode == wgpu::MapMode::Read { + self.buffer + .slice(offset..end) + .get_mapped_range() + .to_vec() + } else { + vec![0u8; (end - offset) as usize] + }; + *self.mapped.lock() = Some(MappedRange { + offset, + end, + write: mode == wgpu::MapMode::Write, + bytes: Arc::new(Mutex::new(bytes)), + }); + Ok(()) + } + + #[napi(js_name = "getMappedRange")] + pub fn get_mapped_range( + &self, + env: Env, + offset: Option, + size: Option, + ) -> Result> { + let mapped = self.mapped.lock(); + let range = mapped + .as_ref() + .ok_or_else(|| Error::from_reason("Buffer is not mapped"))?; + if offset.unwrap_or(0.0) != 0.0 || size.is_some() { + return Err(Error::from_reason( + "getMappedRange offset/size slices are not supported yet", + )); + } + let keep_alive = range.bytes.clone(); + let mut bytes = keep_alive.lock(); + let (data, len) = (bytes.as_mut_ptr(), bytes.len()); + drop(bytes); + unsafe { ArrayBuffer::from_external(&env, data, len, keep_alive, |_, _keep| {}) } + } + + #[napi] + pub fn unmap(&self) { + if let Some(range) = self.mapped.lock().take() { + if range.write { + let bytes = range.bytes.lock(); + if !bytes.is_empty() { + self.buffer + .slice(range.offset..range.end) + .get_mapped_range_mut() + .copy_from_slice(&bytes); + } + } + self.buffer.unmap(); + } + } + + #[napi] + pub fn destroy(&self) { + self.buffer.destroy(); + } +} + +#[napi] +pub struct GPUTexture { + texture: Arc, +} + +impl GPUTexture { + fn from_wgpu(texture: wgpu::Texture) -> Self { + Self { + texture: Arc::new(texture), + } + } +} + +#[napi] +impl GPUTexture { + #[napi(js_name = "createView")] + pub fn create_view(&self, descriptor: Option) -> Result { + let descriptor = descriptor.unwrap_or_default(); + let format = descriptor + .format + .as_deref() + .map(parse_texture_format) + .transpose()?; + let view = self.texture.create_view(&wgpu::TextureViewDescriptor { + label: descriptor.label.as_deref(), + format, + dimension: parse_view_dimension(descriptor.dimension.as_deref()), + aspect: match descriptor.aspect.as_deref() { + Some("depth-only") => wgpu::TextureAspect::DepthOnly, + Some("stencil-only") => wgpu::TextureAspect::StencilOnly, + _ => wgpu::TextureAspect::All, + }, + base_mip_level: descriptor.base_mip_level.unwrap_or(0), + mip_level_count: descriptor.mip_level_count, + base_array_layer: descriptor.base_array_layer.unwrap_or(0), + array_layer_count: descriptor.array_layer_count, + usage: None, + }); + Ok(GPUTextureView { + view: Arc::new(view), + }) + } + + #[napi(getter)] + pub fn width(&self) -> u32 { + self.texture.width() + } + + #[napi(getter)] + pub fn height(&self) -> u32 { + self.texture.height() + } + + #[napi] + pub fn destroy(&self) { + self.texture.destroy(); + } +} + +#[napi(object)] +#[derive(Default)] +pub struct TextureViewDescriptor { + pub label: Option, + pub format: Option, + pub dimension: Option, + pub aspect: Option, + pub base_mip_level: Option, + pub mip_level_count: Option, + pub base_array_layer: Option, + pub array_layer_count: Option, +} + +#[napi] +pub struct GPUTextureView { + view: Arc, +} + +#[napi] +pub struct GPUSampler { + sampler: Arc, +} + +#[napi] +pub struct GPUShaderModule { + shader: Arc, +} + +#[napi] +pub struct GPUBindGroupLayout { + layout: Arc, +} + +#[napi] +pub struct GPUPipelineLayout { + layout: Arc, +} + +#[napi] +pub struct GPUBindGroup { + bind_group: Arc, +} + +#[napi] +pub struct GPURenderPipeline { + pipeline: Arc, +} + +#[napi] +impl GPURenderPipeline { + #[napi(js_name = "getBindGroupLayout")] + pub fn get_bind_group_layout(&self, index: u32) -> GPUBindGroupLayout { + GPUBindGroupLayout { + layout: Arc::new(self.pipeline.get_bind_group_layout(index)), + } + } +} + +#[napi] +pub struct GPUComputePipeline { + pipeline: Arc, +} + +#[napi] +impl GPUComputePipeline { + #[napi(js_name = "getBindGroupLayout")] + pub fn get_bind_group_layout(&self, index: u32) -> GPUBindGroupLayout { + GPUBindGroupLayout { + layout: Arc::new(self.pipeline.get_bind_group_layout(index)), + } + } +} + +#[napi] +pub struct GPUCommandEncoder { + encoder: Option, + #[allow(dead_code)] + device: Arc, +} + +#[napi] +impl GPUCommandEncoder { + #[napi(js_name = "beginRenderPass")] + pub fn begin_render_pass( + &mut self, + descriptor: RenderPassDescriptor, + color_views: Vec<&GPUTextureView>, + color_resolve_views: Option>>, + depth_stencil_view: Option<&GPUTextureView>, + ) -> Result { + if color_resolve_views + .as_ref() + .is_some_and(|views| views.iter().any(Option::is_some)) + { + return Err(Error::from_reason( + "MSAA resolveTarget is not supported yet. Use antialias: false.", + )); + } + let encoder = self + .encoder + .as_mut() + .ok_or_else(|| Error::from_reason("Command encoder already finished"))?; + if color_views.len() != descriptor.color_attachments.len() { + return Err(Error::from_reason( + "colorViews length must match colorAttachments", + )); + } + let color_attachments: Vec> = descriptor + .color_attachments + .iter() + .enumerate() + .map(|(index, attachment)| { + let view = &color_views[index]; + let load = match attachment.load_op.as_str() { + "clear" => wgpu::LoadOp::Clear( + attachment + .clear_value + .as_ref() + .map(|color| wgpu::Color { + r: color.r, + g: color.g, + b: color.b, + a: color.a, + }) + .unwrap_or(wgpu::Color::BLACK), + ), + _ => wgpu::LoadOp::Load, + }; + let store = if attachment.store_op == "discard" { + wgpu::StoreOp::Discard + } else { + wgpu::StoreOp::Store + }; + Some(wgpu::RenderPassColorAttachment { + view: &view.view, + resolve_target: None, + ops: wgpu::Operations { load, store }, + depth_slice: None, + }) + }) + .collect(); + let depth_stencil_attachment = descriptor.depth_stencil_attachment.as_ref().and_then(|attachment| { + let view = depth_stencil_view?; + Some(wgpu::RenderPassDepthStencilAttachment { + view: &view.view, + depth_ops: Some(wgpu::Operations { + load: if attachment.depth_load_op.as_deref() == Some("clear") { + wgpu::LoadOp::Clear(attachment.depth_clear_value.unwrap_or(1.0) as f32) + } else { + wgpu::LoadOp::Load + }, + store: if attachment.depth_store_op.as_deref() == Some("discard") { + wgpu::StoreOp::Discard + } else { + wgpu::StoreOp::Store + }, + }), + stencil_ops: None, + }) + }); + let pass = encoder.begin_render_pass(&wgpu::RenderPassDescriptor { + label: descriptor.label.as_deref(), + color_attachments: &color_attachments, + depth_stencil_attachment, + timestamp_writes: None, + occlusion_query_set: None, + multiview_mask: None, + }); + Ok(GPURenderPassEncoder { + pass: Some(pass.forget_lifetime()), + }) + } + + #[napi(js_name = "beginComputePass")] + pub fn begin_compute_pass( + &mut self, + descriptor: Option, + ) -> Result { + let encoder = self + .encoder + .as_mut() + .ok_or_else(|| Error::from_reason("Command encoder already finished"))?; + let pass = encoder.begin_compute_pass(&wgpu::ComputePassDescriptor { + label: descriptor.as_ref().and_then(|descriptor| descriptor.label.as_deref()), + timestamp_writes: None, + }); + Ok(GPUComputePassEncoder { + pass: Some(pass.forget_lifetime()), + }) + } + + #[napi(js_name = "copyBufferToBuffer")] + pub fn copy_buffer_to_buffer( + &mut self, + source: &GPUBuffer, + source_offset: i64, + destination: &GPUBuffer, + destination_offset: i64, + size: i64, + ) -> Result<()> { + let encoder = self + .encoder + .as_mut() + .ok_or_else(|| Error::from_reason("Command encoder already finished"))?; + encoder.copy_buffer_to_buffer( + &source.buffer, + non_negative_u64(source_offset, "sourceOffset")?, + &destination.buffer, + non_negative_u64(destination_offset, "destinationOffset")?, + non_negative_u64(size, "size")?, + ); + Ok(()) + } + + #[napi] + pub fn finish(&mut self) -> Result { + let encoder = self + .encoder + .take() + .ok_or_else(|| Error::from_reason("Command encoder already finished"))?; + Ok(GPUCommandBuffer { + buffer: Some(encoder.finish()), + }) + } +} + +#[napi] +pub struct GPUCommandBuffer { + buffer: Option, +} + +#[napi] +pub struct GPURenderPassEncoder { + pass: Option>, +} + +#[napi] +impl GPURenderPassEncoder { + #[napi(js_name = "setPipeline")] + pub fn set_pipeline(&mut self, pipeline: &GPURenderPipeline) -> Result<()> { + self.pass_mut()?.set_pipeline(&pipeline.pipeline); + Ok(()) + } + + #[napi(js_name = "setBindGroup")] + pub fn set_bind_group( + &mut self, + index: u32, + bind_group: &GPUBindGroup, + dynamic_offsets: Option>, + ) -> Result<()> { + let offsets = dynamic_offsets.unwrap_or_default(); + self.pass_mut()? + .set_bind_group(index, bind_group.bind_group.as_ref(), &offsets); + Ok(()) + } + + #[napi(js_name = "setVertexBuffer")] + pub fn set_vertex_buffer( + &mut self, + slot: u32, + buffer: &GPUBuffer, + offset: Option, + size: Option, + ) -> Result<()> { + let slice = buffer_slice(&buffer.buffer, offset, size); + self.pass_mut()?.set_vertex_buffer(slot, slice); + Ok(()) + } + + #[napi(js_name = "setIndexBuffer")] + pub fn set_index_buffer( + &mut self, + buffer: &GPUBuffer, + index_format: String, + offset: Option, + size: Option, + ) -> Result<()> { + let format = match index_format.as_str() { + "uint16" => wgpu::IndexFormat::Uint16, + _ => wgpu::IndexFormat::Uint32, + }; + let slice = buffer_slice(&buffer.buffer, offset, size); + self.pass_mut()?.set_index_buffer(slice, format); + Ok(()) + } + + #[napi] + pub fn draw( + &mut self, + vertex_count: u32, + instance_count: Option, + first_vertex: Option, + first_instance: Option, + ) -> Result<()> { + let first_vertex = first_vertex.unwrap_or(0); + let first_instance = first_instance.unwrap_or(0); + let last_vertex = first_vertex + .checked_add(vertex_count) + .ok_or_else(|| Error::from_reason("draw vertex range overflow"))?; + let last_instance = first_instance + .checked_add(instance_count.unwrap_or(1)) + .ok_or_else(|| Error::from_reason("draw instance range overflow"))?; + self.pass_mut()?.draw(first_vertex..last_vertex, first_instance..last_instance); + Ok(()) + } + + #[napi(js_name = "drawIndexed")] + pub fn draw_indexed( + &mut self, + index_count: u32, + instance_count: Option, + first_index: Option, + base_vertex: Option, + first_instance: Option, + ) -> Result<()> { + let first_index = first_index.unwrap_or(0); + let first_instance = first_instance.unwrap_or(0); + let last_index = first_index + .checked_add(index_count) + .ok_or_else(|| Error::from_reason("drawIndexed index range overflow"))?; + let last_instance = first_instance + .checked_add(instance_count.unwrap_or(1)) + .ok_or_else(|| Error::from_reason("drawIndexed instance range overflow"))?; + self.pass_mut()?.draw_indexed( + first_index..last_index, + base_vertex.unwrap_or(0), + first_instance..last_instance, + ); + Ok(()) + } + + #[napi(js_name = "setViewport")] + pub fn set_viewport( + &mut self, + x: f64, + y: f64, + width: f64, + height: f64, + min_depth: Option, + max_depth: Option, + ) -> Result<()> { + self.pass_mut()?.set_viewport( + x as f32, + y as f32, + width as f32, + height as f32, + min_depth.unwrap_or(0.0) as f32, + max_depth.unwrap_or(1.0) as f32, + ); + Ok(()) + } + + #[napi(js_name = "setScissorRect")] + pub fn set_scissor_rect(&mut self, x: u32, y: u32, width: u32, height: u32) -> Result<()> { + self.pass_mut()?.set_scissor_rect(x, y, width, height); + Ok(()) + } + + #[napi] + pub fn end(&mut self) { + self.drop_pass(); + } + + fn pass_mut(&mut self) -> Result<&mut wgpu::RenderPass<'static>> { + self.pass + .as_mut() + .ok_or_else(|| Error::from_reason("Render pass already ended")) + } + + fn drop_pass(&mut self) { + self.pass.take(); + } +} + +#[napi] +pub struct GPUComputePassEncoder { + pass: Option>, +} + +#[napi] +impl GPUComputePassEncoder { + #[napi(js_name = "setPipeline")] + pub fn set_pipeline(&mut self, pipeline: &GPUComputePipeline) -> Result<()> { + self.pass_mut()?.set_pipeline(&pipeline.pipeline); + Ok(()) + } + + #[napi(js_name = "setBindGroup")] + pub fn set_bind_group( + &mut self, + index: u32, + bind_group: &GPUBindGroup, + dynamic_offsets: Option>, + ) -> Result<()> { + let offsets = dynamic_offsets.unwrap_or_default(); + self.pass_mut()? + .set_bind_group(index, bind_group.bind_group.as_ref(), &offsets); + Ok(()) + } + + #[napi(js_name = "dispatchWorkgroups")] + pub fn dispatch_workgroups( + &mut self, + x: u32, + y: Option, + z: Option, + ) -> Result<()> { + self.pass_mut()? + .dispatch_workgroups(x, y.unwrap_or(1), z.unwrap_or(1)); + Ok(()) + } + + #[napi] + pub fn end(&mut self) { + self.drop_pass(); + } + + fn pass_mut(&mut self) -> Result<&mut wgpu::ComputePass<'static>> { + self.pass + .as_mut() + .ok_or_else(|| Error::from_reason("Compute pass already ended")) + } + + fn drop_pass(&mut self) { + self.pass.take(); + } +} + +struct GpuCanvasInner { + #[allow(dead_code)] + id: u64, + width: u32, + height: u32, + device: Option>, + queue: Option>, + format: wgpu::TextureFormat, + texture: Option>, + cached_image: Option>, + snapshot_generation: u64, + destroyed: bool, +} + +impl GpuCanvasInner { + fn configure(&mut self, device: Arc, queue: Arc, format: wgpu::TextureFormat) -> Result<()> { + if self.destroyed { + return Err(Error::from_reason("GPUCanvas has been destroyed")); + } + self.device = Some(device.clone()); + self.queue = Some(queue); + self.format = format; + self.rebuild_textures(&device); + Ok(()) + } + + fn set_size(&mut self, width: u32, height: u32) { + let width = width.max(1); + let height = height.max(1); + if self.width == width && self.height == height { + return; + } + self.width = width; + self.height = height; + if let Some(device) = self.device.clone() { + self.rebuild_textures(&device); + } + } + + fn rebuild_textures(&mut self, device: &wgpu::Device) { + let width = self.width.max(1); + let height = self.height.max(1); + let usage = wgpu::TextureUsages::RENDER_ATTACHMENT + | wgpu::TextureUsages::COPY_SRC + | wgpu::TextureUsages::TEXTURE_BINDING; + self.texture = Some(Arc::new(device.create_texture(&wgpu::TextureDescriptor { + label: Some("gpuix_canvas"), + size: wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + mip_level_count: 1, + sample_count: 1, + dimension: wgpu::TextureDimension::D2, + format: self.format, + usage, + view_formats: &[], + }))); + self.cached_image = None; + self.snapshot_generation = 0; + } + + fn current_texture(&self) -> Option> { + self.texture.clone() + } + + fn snapshot_image(&mut self) -> Result> { + let epoch = SUBMIT_EPOCH.load(Ordering::Acquire); + if self.snapshot_generation == epoch { + if let Some(image) = &self.cached_image { + return Ok(image.clone()); + } + } + let device = self + .device + .clone() + .ok_or_else(|| Error::from_reason("GPUCanvas is not configured"))?; + let queue = self + .queue + .clone() + .ok_or_else(|| Error::from_reason("GPUCanvas is not configured"))?; + let texture = self + .current_texture() + .ok_or_else(|| Error::from_reason("GPUCanvas has no texture"))?; + let width = texture.width(); + let height = texture.height(); + let bytes_per_row = (width as usize * 4).next_multiple_of(256); + let buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("gpuix_canvas_readback"), + size: bytes_per_row as u64 * height as u64, + usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, + mapped_at_creation: false, + }); + let mut encoder = device.create_command_encoder(&wgpu::CommandEncoderDescriptor { + label: Some("gpuix_canvas_copy"), + }); + encoder.copy_texture_to_buffer( + wgpu::TexelCopyTextureInfo { + texture: &texture, + mip_level: 0, + origin: wgpu::Origin3d::ZERO, + aspect: wgpu::TextureAspect::All, + }, + wgpu::TexelCopyBufferInfo { + buffer: &buffer, + layout: wgpu::TexelCopyBufferLayout { + offset: 0, + bytes_per_row: Some(bytes_per_row as u32), + rows_per_image: Some(height), + }, + }, + wgpu::Extent3d { + width, + height, + depth_or_array_layers: 1, + }, + ); + queue.submit(Some(encoder.finish())); + buffer.slice(..).map_async(wgpu::MapMode::Read, |_| ()); + device + .poll(wgpu::PollType::Wait { + submission_index: None, + timeout: None, + }) + .map_err(|error| Error::from_reason(format!("GPU readback poll failed: {error:?}")))?; + let mapped = buffer.slice(..).get_mapped_range(); + let mut pixels = image::RgbaImage::new(width, height); + let row_bytes = width as usize * 4; + let swap_to_bgra = !matches!( + self.format, + wgpu::TextureFormat::Bgra8Unorm | wgpu::TextureFormat::Bgra8UnormSrgb + ); + for y in 0..height as usize { + let src = &mapped[y * bytes_per_row..][..row_bytes]; + let dest = &mut pixels.as_mut()[y * row_bytes..][..row_bytes]; + dest.copy_from_slice(src); + if swap_to_bgra { + for pixel in dest.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + } + } + drop(mapped); + buffer.unmap(); + let image = Arc::new(gpui::RenderImage::new(vec![image::Frame::new(pixels)])); + self.cached_image = Some(image.clone()); + self.snapshot_generation = epoch; + Ok(image) + } + + fn read_pixels(&mut self) -> Result> { + let image = self.snapshot_image()?; + let bytes = image + .as_bytes(0) + .ok_or_else(|| Error::from_reason("GPUCanvas snapshot has no pixels"))?; + let mut rgba = bytes.to_vec(); + for pixel in rgba.chunks_exact_mut(4) { + pixel.swap(0, 2); + } + Ok(rgba) + } +} + +#[napi] +pub struct GPUCanvas { + id: u64, + inner: Arc>, +} + +#[napi] +impl GPUCanvas { + #[napi(constructor)] + pub fn new(width: u32, height: u32) -> Self { + let id = NEXT_CANVAS_ID.fetch_add(1, Ordering::Relaxed); + let inner = Arc::new(Mutex::new(GpuCanvasInner { + id, + width: width.max(1), + height: height.max(1), + device: None, + queue: None, + format: wgpu::TextureFormat::Bgra8Unorm, + texture: None, + cached_image: None, + snapshot_generation: 0, + destroyed: false, + })); + register_canvas(id, inner.clone()); + Self { id, inner } + } + + #[napi(getter)] + pub fn id(&self) -> f64 { + self.id as f64 + } + + #[napi(getter)] + pub fn width(&self) -> u32 { + self.inner.lock().width + } + + #[napi(setter)] + pub fn set_width(&self, width: u32) { + let mut inner = self.inner.lock(); + let height = inner.height; + inner.set_size(width, height); + } + + #[napi(getter)] + pub fn height(&self) -> u32 { + self.inner.lock().height + } + + #[napi(setter)] + pub fn set_height(&self, height: u32) { + let mut inner = self.inner.lock(); + let width = inner.width; + inner.set_size(width, height); + } + + #[napi(js_name = "readPixels")] + pub fn read_pixels(&self) -> Result { + Ok(Buffer::from(self.inner.lock().read_pixels()?)) + } + + #[napi] + pub fn destroy(&self) { + unregister_canvas(self.id); + let mut inner = self.inner.lock(); + inner.destroyed = true; + inner.texture = None; + inner.cached_image = None; + inner.device = None; + inner.queue = None; + } + + #[napi(js_name = "getContext")] + pub fn get_context(&self, context_id: String) -> Result { + if context_id != "webgpu" { + return Err(Error::from_reason(format!( + "Only getContext(\"webgpu\") is supported, got {context_id}" + ))); + } + Ok(GPUCanvasContext { + inner: self.inner.clone(), + }) + } +} + +impl Drop for GPUCanvas { + fn drop(&mut self) { + unregister_canvas(self.id); + self.inner.lock().destroyed = true; + } +} + +#[napi] +pub struct GPUCanvasContext { + inner: Arc>, +} + +#[napi] +impl GPUCanvasContext { + #[napi] + pub fn configure(&self, configuration: GPUCanvasConfiguration, device: &GPUDevice) -> Result<()> { + let format = parse_texture_format(configuration.format.as_deref().unwrap_or("bgra8unorm"))?; + if !matches!( + format, + wgpu::TextureFormat::Rgba8Unorm + | wgpu::TextureFormat::Rgba8UnormSrgb + | wgpu::TextureFormat::Bgra8Unorm + | wgpu::TextureFormat::Bgra8UnormSrgb + ) { + return Err(Error::from_reason(format!( + "GPUCanvas only supports 8-bit RGBA/BGRA formats, got {format:?}" + ))); + } + self.inner.lock().configure( + device.device.clone(), + device.queue_internal.clone(), + format, + ) + } + + #[napi(js_name = "getCurrentTexture")] + pub fn get_current_texture(&self) -> Result { + let inner = self.inner.lock(); + if inner.destroyed { + return Err(Error::from_reason("GPUCanvas has been destroyed")); + } + let texture = inner + .current_texture() + .ok_or_else(|| Error::from_reason("GPUCanvas is not configured"))?; + Ok(GPUTexture { texture }) + } + + #[napi] + pub fn unconfigure(&self) { + let mut inner = self.inner.lock(); + inner.texture = None; + inner.cached_image = None; + inner.device = None; + inner.queue = None; + } +} + +#[napi(object)] +pub struct GPUCanvasConfiguration { + pub format: Option, + pub usage: Option, + pub alpha_mode: Option, +} + +#[napi] +pub fn gpu_buffer_usage() -> BufferUsage { + BufferUsage { + map_read: wgpu::BufferUsages::MAP_READ.bits(), + map_write: wgpu::BufferUsages::MAP_WRITE.bits(), + copy_src: wgpu::BufferUsages::COPY_SRC.bits(), + copy_dst: wgpu::BufferUsages::COPY_DST.bits(), + index: wgpu::BufferUsages::INDEX.bits(), + vertex: wgpu::BufferUsages::VERTEX.bits(), + uniform: wgpu::BufferUsages::UNIFORM.bits(), + storage: wgpu::BufferUsages::STORAGE.bits(), + indirect: wgpu::BufferUsages::INDIRECT.bits(), + query_resolve: wgpu::BufferUsages::QUERY_RESOLVE.bits(), + } +} + +#[napi] +pub fn gpu_texture_usage() -> TextureUsage { + TextureUsage { + copy_src: wgpu::TextureUsages::COPY_SRC.bits(), + copy_dst: wgpu::TextureUsages::COPY_DST.bits(), + texture_binding: wgpu::TextureUsages::TEXTURE_BINDING.bits(), + storage_binding: wgpu::TextureUsages::STORAGE_BINDING.bits(), + render_attachment: wgpu::TextureUsages::RENDER_ATTACHMENT.bits(), + } +} + +#[napi] +pub fn gpu_shader_stage() -> ShaderStage { + ShaderStage { + vertex: wgpu::ShaderStages::VERTEX.bits(), + fragment: wgpu::ShaderStages::FRAGMENT.bits(), + compute: wgpu::ShaderStages::COMPUTE.bits(), + } +} + +#[napi(object)] +pub struct BufferUsage { + pub map_read: u32, + pub map_write: u32, + pub copy_src: u32, + pub copy_dst: u32, + pub index: u32, + pub vertex: u32, + pub uniform: u32, + pub storage: u32, + pub indirect: u32, + pub query_resolve: u32, +} + +#[napi(object)] +pub struct TextureUsage { + pub copy_src: u32, + pub copy_dst: u32, + pub texture_binding: u32, + pub storage_binding: u32, + pub render_attachment: u32, +} + +#[napi(object)] +pub struct ShaderStage { + pub vertex: u32, + pub fragment: u32, + pub compute: u32, +} + +#[napi(object)] +pub struct BufferDescriptor { + pub label: Option, + pub size: i64, + pub usage: u32, + #[napi(js_name = "mappedAtCreation")] + pub mapped_at_creation: Option, +} + +#[napi(object)] +pub struct TextureDescriptor { + pub label: Option, + pub width: u32, + pub height: u32, + pub depth: Option, + pub format: String, + pub usage: u32, + pub dimension: Option, + pub mip_level_count: Option, + pub sample_count: Option, +} + +#[napi(object)] +#[derive(Default)] +pub struct SamplerDescriptor { + pub label: Option, + pub address_mode_u: Option, + pub address_mode_v: Option, + pub address_mode_w: Option, + pub mag_filter: Option, + pub min_filter: Option, + pub mipmap_filter: Option, + pub lod_min_clamp: Option, + pub lod_max_clamp: Option, + pub compare: Option, + pub max_anisotropy: Option, +} + +#[napi(object)] +pub struct ShaderModuleDescriptor { + pub label: Option, + pub code: String, +} + +#[napi(object)] +pub struct PipelineLayoutDescriptor { + pub label: Option, +} + +#[napi(object)] +pub struct ComputePipelineDescriptor { + pub label: Option, + #[napi(js_name = "entryPoint")] + pub entry_point: String, +} + +#[napi(object)] +pub struct CommandEncoderDescriptor { + pub label: Option, +} + +#[napi(object)] +pub struct BindGroupDescriptor { + pub label: Option, +} + +#[napi(object)] +pub struct BindGroupLayoutDescriptor { + pub label: Option, + pub entries: Vec, +} + +#[napi(object)] +pub struct BindGroupLayoutEntry { + pub binding: u32, + pub visibility: u32, + pub buffer: Option, + pub sampler: Option, + pub texture: Option, + #[napi(js_name = "storageTexture")] + pub storage_texture: Option, +} + +#[napi(object)] +pub struct BufferBindingLayout { + #[napi(js_name = "type")] + pub ty: Option, + #[napi(js_name = "hasDynamicOffset")] + pub has_dynamic_offset: Option, + #[napi(js_name = "minBindingSize")] + pub min_binding_size: Option, +} + +#[napi(object)] +pub struct SamplerBindingLayout { + #[napi(js_name = "type")] + pub ty: Option, +} + +#[napi(object)] +pub struct TextureBindingLayout { + #[napi(js_name = "sampleType")] + pub sample_type: Option, + #[napi(js_name = "viewDimension")] + pub view_dimension: Option, + pub multisampled: Option, +} + +#[napi(object)] +pub struct StorageTextureBindingLayout { + pub access: Option, + pub format: String, + #[napi(js_name = "viewDimension")] + pub view_dimension: Option, +} + +#[napi(object)] +pub struct BindGroupEntry { + pub binding: u32, + pub resource_type: String, + pub offset: Option, + pub size: Option, +} + +#[napi(object)] +pub struct RenderPipelineDescriptor { + pub label: Option, + pub vertex: VertexState, + pub primitive: Option, + #[napi(js_name = "depthStencil")] + pub depth_stencil: Option, + pub multisample: Option, + pub fragment: Option, +} + +#[napi(object)] +pub struct VertexState { + #[napi(js_name = "entryPoint")] + pub entry_point: String, + pub buffers: Option>, +} + +#[napi(object)] +pub struct VertexBufferLayout { + #[napi(js_name = "arrayStride")] + pub array_stride: i64, + #[napi(js_name = "stepMode")] + pub step_mode: Option, + pub attributes: Vec, +} + +#[napi(object)] +pub struct VertexAttribute { + pub format: String, + pub offset: i64, + #[napi(js_name = "shaderLocation")] + pub shader_location: u32, +} + +#[napi(object)] +pub struct PrimitiveState { + pub topology: Option, + #[napi(js_name = "frontFace")] + pub front_face: Option, + #[napi(js_name = "cullMode")] + pub cull_mode: Option, +} + +#[napi(object)] +pub struct DepthStencilState { + pub format: String, + #[napi(js_name = "depthWriteEnabled")] + pub depth_write_enabled: Option, + #[napi(js_name = "depthCompare")] + pub depth_compare: Option, +} + +#[napi(object)] +pub struct MultisampleState { + pub count: Option, + pub mask: Option, + #[napi(js_name = "alphaToCoverageEnabled")] + pub alpha_to_coverage_enabled: Option, +} + +#[napi(object)] +pub struct FragmentState { + #[napi(js_name = "entryPoint")] + pub entry_point: String, + pub targets: Vec, +} + +#[napi(object)] +pub struct ColorTargetState { + pub format: String, + pub blend: Option, + #[napi(js_name = "writeMask")] + pub write_mask: Option, +} + +#[napi(object)] +pub struct BlendState { + pub color: BlendComponent, + pub alpha: BlendComponent, +} + +#[napi(object)] +pub struct BlendComponent { + #[napi(js_name = "srcFactor")] + pub src_factor: String, + #[napi(js_name = "dstFactor")] + pub dst_factor: String, + pub operation: String, +} + +#[napi(object)] +pub struct RenderPassDescriptor { + pub label: Option, + pub color_attachments: Vec, + pub depth_stencil_attachment: Option, +} + +#[napi(object)] +pub struct RenderPassColorAttachment { + pub clear_value: Option, + pub load_op: String, + pub store_op: String, +} + +#[napi(object)] +pub struct GpuColor { + pub r: f64, + pub g: f64, + pub b: f64, + pub a: f64, +} + +#[napi(object)] +pub struct RenderPassDepthStencilAttachment { + pub depth_clear_value: Option, + pub depth_load_op: Option, + pub depth_store_op: Option, +} + +#[napi(object)] +pub struct ComputePassDescriptor { + pub label: Option, +} + +fn buffer_slice(buffer: &wgpu::Buffer, offset: Option, size: Option) -> wgpu::BufferSlice<'_> { + match (offset, size) { + (Some(offset), Some(size)) => { + let offset = offset.max(0.0) as u64; + let size = size.max(0.0) as u64; + let end = offset.saturating_add(size).min(buffer.size()); + buffer.slice(offset.min(buffer.size())..end) + } + (Some(offset), None) => buffer.slice(offset.max(0.0) as u64..), + _ => buffer.slice(..), + } +} + +fn non_negative_u64(value: i64, name: &str) -> Result { + u64::try_from(value).map_err(|_| Error::from_reason(format!("{name} must be non-negative"))) +} + +fn finite_u64(value: f64, name: &str) -> Result { + if !value.is_finite() || value < 0.0 || value.fract() != 0.0 || value > u64::MAX as f64 { + return Err(Error::from_reason(format!("{name} must be a non-negative integer"))); + } + Ok(value as u64) +} + +fn parse_texture_format(format: &str) -> Result { + Ok(match format { + "r8unorm" => wgpu::TextureFormat::R8Unorm, + "rg8unorm" => wgpu::TextureFormat::Rg8Unorm, + "rgba8unorm" => wgpu::TextureFormat::Rgba8Unorm, + "rgba8unorm-srgb" => wgpu::TextureFormat::Rgba8UnormSrgb, + "bgra8unorm" => wgpu::TextureFormat::Bgra8Unorm, + "bgra8unorm-srgb" => wgpu::TextureFormat::Bgra8UnormSrgb, + "rgba8uint" => wgpu::TextureFormat::Rgba8Uint, + "rgba8sint" => wgpu::TextureFormat::Rgba8Sint, + "rgba16float" => wgpu::TextureFormat::Rgba16Float, + "rgba32float" => wgpu::TextureFormat::Rgba32Float, + "depth24plus" => wgpu::TextureFormat::Depth24Plus, + "depth24plus-stencil8" => wgpu::TextureFormat::Depth24PlusStencil8, + "depth32float" => wgpu::TextureFormat::Depth32Float, + _ => { + return Err(Error::from_reason(format!( + "Unsupported texture format: {format}" + ))); + } + }) +} + +fn parse_vertex_format(format: &str) -> Result { + Ok(match format { + "uint8x2" => wgpu::VertexFormat::Uint8x2, + "uint8x4" => wgpu::VertexFormat::Uint8x4, + "sint8x2" => wgpu::VertexFormat::Sint8x2, + "sint8x4" => wgpu::VertexFormat::Sint8x4, + "unorm8x2" => wgpu::VertexFormat::Unorm8x2, + "unorm8x4" => wgpu::VertexFormat::Unorm8x4, + "snorm8x2" => wgpu::VertexFormat::Snorm8x2, + "snorm8x4" => wgpu::VertexFormat::Snorm8x4, + "uint16x2" => wgpu::VertexFormat::Uint16x2, + "uint16x4" => wgpu::VertexFormat::Uint16x4, + "sint16x2" => wgpu::VertexFormat::Sint16x2, + "sint16x4" => wgpu::VertexFormat::Sint16x4, + "unorm16x2" => wgpu::VertexFormat::Unorm16x2, + "unorm16x4" => wgpu::VertexFormat::Unorm16x4, + "snorm16x2" => wgpu::VertexFormat::Snorm16x2, + "snorm16x4" => wgpu::VertexFormat::Snorm16x4, + "float16x2" => wgpu::VertexFormat::Float16x2, + "float16x4" => wgpu::VertexFormat::Float16x4, + "float32" => wgpu::VertexFormat::Float32, + "float32x2" => wgpu::VertexFormat::Float32x2, + "float32x3" => wgpu::VertexFormat::Float32x3, + "float32x4" => wgpu::VertexFormat::Float32x4, + "uint32" => wgpu::VertexFormat::Uint32, + "uint32x2" => wgpu::VertexFormat::Uint32x2, + "uint32x3" => wgpu::VertexFormat::Uint32x3, + "uint32x4" => wgpu::VertexFormat::Uint32x4, + "sint32" => wgpu::VertexFormat::Sint32, + "sint32x2" => wgpu::VertexFormat::Sint32x2, + "sint32x3" => wgpu::VertexFormat::Sint32x3, + "sint32x4" => wgpu::VertexFormat::Sint32x4, + _ => { + return Err(Error::from_reason(format!( + "Unsupported vertex format: {format}" + ))); + } + }) +} + +fn parse_view_dimension(dimension: Option<&str>) -> Option { + Some(match dimension? { + "1d" => wgpu::TextureViewDimension::D1, + "2d" => wgpu::TextureViewDimension::D2, + "2d-array" => wgpu::TextureViewDimension::D2Array, + "cube" => wgpu::TextureViewDimension::Cube, + "cube-array" => wgpu::TextureViewDimension::CubeArray, + "3d" => wgpu::TextureViewDimension::D3, + _ => return None, + }) +} + +fn parse_address_mode(mode: Option<&str>) -> wgpu::AddressMode { + match mode { + Some("repeat") => wgpu::AddressMode::Repeat, + Some("mirror-repeat") => wgpu::AddressMode::MirrorRepeat, + _ => wgpu::AddressMode::ClampToEdge, + } +} + +fn parse_filter_mode(mode: Option<&str>) -> wgpu::FilterMode { + match mode { + Some("linear") => wgpu::FilterMode::Linear, + _ => wgpu::FilterMode::Nearest, + } +} + +fn parse_mipmap_filter_mode(mode: Option<&str>) -> wgpu::MipmapFilterMode { + match mode { + Some("linear") => wgpu::MipmapFilterMode::Linear, + _ => wgpu::MipmapFilterMode::Nearest, + } +} + +fn parse_compare_function(func: Option<&str>) -> Option { + match func { + Some("never") => Some(wgpu::CompareFunction::Never), + Some("less") => Some(wgpu::CompareFunction::Less), + Some("equal") => Some(wgpu::CompareFunction::Equal), + Some("less-equal") => Some(wgpu::CompareFunction::LessEqual), + Some("greater") => Some(wgpu::CompareFunction::Greater), + Some("not-equal") => Some(wgpu::CompareFunction::NotEqual), + Some("greater-equal") => Some(wgpu::CompareFunction::GreaterEqual), + Some("always") => Some(wgpu::CompareFunction::Always), + _ => None, + } +} + +fn parse_blend_factor(factor: &str) -> wgpu::BlendFactor { + match factor { + "zero" => wgpu::BlendFactor::Zero, + "src" => wgpu::BlendFactor::Src, + "one-minus-src" => wgpu::BlendFactor::OneMinusSrc, + "src-alpha" => wgpu::BlendFactor::SrcAlpha, + "one-minus-src-alpha" => wgpu::BlendFactor::OneMinusSrcAlpha, + "dst" => wgpu::BlendFactor::Dst, + "one-minus-dst" => wgpu::BlendFactor::OneMinusDst, + "dst-alpha" => wgpu::BlendFactor::DstAlpha, + "one-minus-dst-alpha" => wgpu::BlendFactor::OneMinusDstAlpha, + "src-alpha-saturated" => wgpu::BlendFactor::SrcAlphaSaturated, + "constant" => wgpu::BlendFactor::Constant, + "one-minus-constant" => wgpu::BlendFactor::OneMinusConstant, + _ => wgpu::BlendFactor::One, + } +} + +fn parse_blend_operation(operation: &str) -> wgpu::BlendOperation { + match operation { + "subtract" => wgpu::BlendOperation::Subtract, + "reverse-subtract" => wgpu::BlendOperation::ReverseSubtract, + "min" => wgpu::BlendOperation::Min, + "max" => wgpu::BlendOperation::Max, + _ => wgpu::BlendOperation::Add, + } +} + +fn parse_blend(blend: &BlendState) -> wgpu::BlendState { + wgpu::BlendState { + color: wgpu::BlendComponent { + src_factor: parse_blend_factor(&blend.color.src_factor), + dst_factor: parse_blend_factor(&blend.color.dst_factor), + operation: parse_blend_operation(&blend.color.operation), + }, + alpha: wgpu::BlendComponent { + src_factor: parse_blend_factor(&blend.alpha.src_factor), + dst_factor: parse_blend_factor(&blend.alpha.dst_factor), + operation: parse_blend_operation(&blend.alpha.operation), + }, + } +} + +fn parse_primitive(primitive: &PrimitiveState) -> wgpu::PrimitiveState { + wgpu::PrimitiveState { + topology: match primitive.topology.as_deref() { + Some("point-list") => wgpu::PrimitiveTopology::PointList, + Some("line-list") => wgpu::PrimitiveTopology::LineList, + Some("line-strip") => wgpu::PrimitiveTopology::LineStrip, + Some("triangle-strip") => wgpu::PrimitiveTopology::TriangleStrip, + _ => wgpu::PrimitiveTopology::TriangleList, + }, + front_face: if primitive.front_face.as_deref() == Some("cw") { + wgpu::FrontFace::Cw + } else { + wgpu::FrontFace::Ccw + }, + cull_mode: match primitive.cull_mode.as_deref() { + Some("front") => Some(wgpu::Face::Front), + Some("back") => Some(wgpu::Face::Back), + _ => None, + }, + ..Default::default() + } +} + +fn parse_depth_stencil(state: &DepthStencilState) -> Result { + Ok(wgpu::DepthStencilState { + format: parse_texture_format(&state.format)?, + depth_write_enabled: Some(state.depth_write_enabled.unwrap_or(true)), + depth_compare: Some( + parse_compare_function(state.depth_compare.as_deref()) + .unwrap_or(wgpu::CompareFunction::Less), + ), + stencil: wgpu::StencilState::default(), + bias: wgpu::DepthBiasState::default(), + }) +} + +fn convert_bind_group_layout_entry(entry: &BindGroupLayoutEntry) -> Result { + let visibility = wgpu::ShaderStages::from_bits_truncate(entry.visibility); + let ty = if let Some(buffer) = &entry.buffer { + wgpu::BindingType::Buffer { + ty: match buffer.ty.as_deref() { + Some("storage") => wgpu::BufferBindingType::Storage { read_only: false }, + Some("read-only-storage") => wgpu::BufferBindingType::Storage { read_only: true }, + _ => wgpu::BufferBindingType::Uniform, + }, + has_dynamic_offset: buffer.has_dynamic_offset.unwrap_or(false), + min_binding_size: buffer + .min_binding_size + .and_then(|size| std::num::NonZeroU64::new(size as u64)), + } + } else if let Some(sampler) = &entry.sampler { + wgpu::BindingType::Sampler(match sampler.ty.as_deref() { + Some("non-filtering") => wgpu::SamplerBindingType::NonFiltering, + Some("comparison") => wgpu::SamplerBindingType::Comparison, + _ => wgpu::SamplerBindingType::Filtering, + }) + } else if let Some(texture) = &entry.texture { + wgpu::BindingType::Texture { + sample_type: match texture.sample_type.as_deref() { + Some("unfilterable-float") => wgpu::TextureSampleType::Float { filterable: false }, + Some("depth") => wgpu::TextureSampleType::Depth, + Some("sint") => wgpu::TextureSampleType::Sint, + Some("uint") => wgpu::TextureSampleType::Uint, + _ => wgpu::TextureSampleType::Float { filterable: true }, + }, + view_dimension: parse_view_dimension(texture.view_dimension.as_deref()) + .unwrap_or(wgpu::TextureViewDimension::D2), + multisampled: texture.multisampled.unwrap_or(false), + } + } else if let Some(storage) = &entry.storage_texture { + wgpu::BindingType::StorageTexture { + access: match storage.access.as_deref() { + Some("read-only") => wgpu::StorageTextureAccess::ReadOnly, + Some("read-write") => wgpu::StorageTextureAccess::ReadWrite, + _ => wgpu::StorageTextureAccess::WriteOnly, + }, + format: parse_texture_format(&storage.format)?, + view_dimension: parse_view_dimension(storage.view_dimension.as_deref()) + .unwrap_or(wgpu::TextureViewDimension::D2), + } + } else { + return Err(Error::from_reason( + "Bind group layout entry needs buffer, sampler, texture, or storageTexture", + )); + }; + Ok(wgpu::BindGroupLayoutEntry { + binding: entry.binding, + visibility: visibility, + ty, + count: None, + }) +} diff --git a/packages/react/jsx-dev-runtime.d.ts b/packages/react/jsx-dev-runtime.d.ts index e987c1f0..20fa7920 100644 --- a/packages/react/jsx-dev-runtime.d.ts +++ b/packages/react/jsx-dev-runtime.d.ts @@ -6,6 +6,7 @@ import type * as React from "react" import type { AnchoredProps, + CanvasProps, CodeProps, DiffProps, ImgProps, @@ -33,7 +34,7 @@ export namespace JSX { text: Props img: ImgProps svg: SvgProps - canvas: Props + canvas: CanvasProps input: InputProps textarea: TextareaProps anchored: AnchoredProps diff --git a/packages/react/jsx-runtime.d.ts b/packages/react/jsx-runtime.d.ts index 1c005b18..d0cf36fa 100644 --- a/packages/react/jsx-runtime.d.ts +++ b/packages/react/jsx-runtime.d.ts @@ -7,6 +7,7 @@ import type * as React from "react" import type { AnchoredProps, + CanvasProps, CodeProps, DiffProps, ImgProps, @@ -34,7 +35,7 @@ export namespace JSX { text: Props img: ImgProps svg: SvgProps - canvas: Props + canvas: CanvasProps input: InputProps textarea: TextareaProps anchored: AnchoredProps diff --git a/packages/react/package.json b/packages/react/package.json index 5ad166ea..b5f8c7e7 100644 --- a/packages/react/package.json +++ b/packages/react/package.json @@ -45,6 +45,11 @@ "types": "./dist/automation/index.d.ts", "import": "./dist/automation/index.js", "default": "./dist/automation/index.js" + }, + "./webgpu": { + "types": "./dist/webgpu.d.ts", + "import": "./dist/webgpu.js", + "default": "./dist/webgpu.js" } }, "files": [ diff --git a/packages/react/src/__tests__/webgpu.test.tsx b/packages/react/src/__tests__/webgpu.test.tsx new file mode 100644 index 00000000..3bd90b5c --- /dev/null +++ b/packages/react/src/__tests__/webgpu.test.tsx @@ -0,0 +1,112 @@ +/// WebGPU napi + paints a GPU triangle into the GPUI scene. +import fs from "fs" +import { describe, expect, it } from "vitest" +import React from "react" +import { GPUBufferUsage, GPUCanvas, GPUTextureUsage, gpu } from "../webgpu.js" +import { createTestRoot, hasNativeTestRenderer } from "../testing.js" +import { SHOTS_DIR, expectScreenshotsDiffer } from "./test-utils.js" + +const describeNative = hasNativeTestRenderer ? describe : describe.skip + +const TRIANGLE_WGSL = ` +struct VertexOut { + @builtin(position) position: vec4, + @location(0) color: vec4, +} + +@vertex +fn vs_main(@builtin(vertex_index) index: u32) -> VertexOut { + var positions = array, 3>( + vec2(0.0, 0.7), + vec2(-0.7, -0.7), + vec2(0.7, -0.7), + ); + var colors = array, 3>( + vec3(1.0, 0.2, 0.3), + vec3(0.2, 1.0, 0.4), + vec3(0.2, 0.4, 1.0), + ); + var out: VertexOut; + out.position = vec4(positions[index], 0.0, 1.0); + out.color = vec4(colors[index], 1.0); + return out; +} + +@fragment +fn fs_main(input: VertexOut) -> @location(0) vec4 { + return input.color; +} +` + +describeNative("webgpu canvas", () => { + it("creates a GPU device and paints a triangle", async () => { + const adapter = await gpu.requestAdapter() + const device = await adapter.requestDevice() + const canvas = new GPUCanvas(256, 256) + const context = canvas.getContext("webgpu") + const format = gpu.getPreferredCanvasFormat() + context.configure({ + device, + format, + usage: GPUTextureUsage.RENDER_ATTACHMENT | GPUTextureUsage.COPY_SRC, + }) + + const shader = device.createShaderModule({ code: TRIANGLE_WGSL }) + const pipeline = device.createRenderPipeline({ + layout: "auto", + vertex: { module: shader, entryPoint: "vs_main" }, + fragment: { + module: shader, + entryPoint: "fs_main", + targets: [{ format }], + }, + }) + const texture = context.getCurrentTexture() + const encoder = device.createCommandEncoder() + const pass = encoder.beginRenderPass({ + colorAttachments: [ + { + view: texture.createView(), + loadOp: "clear", + storeOp: "store", + clearValue: { r: 1, g: 0, b: 1, a: 1 }, + }, + ], + }) + pass.setPipeline(pipeline) + pass.draw(3) + pass.end() + device.queue.submit([encoder.finish()]) + + expect(canvas.id).toBeGreaterThan(0) + expect(GPUBufferUsage.VERTEX).toBeGreaterThan(0) + + const root = createTestRoot({ width: 256, height: 256 }) + const blankPath = `${SHOTS_DIR}/gpuix-webgpu-triangle-blank.png` + const path = `${SHOTS_DIR}/gpuix-webgpu-triangle.png` + root.render() + root.renderer.flush() + root.renderer.flush() + if (fs.existsSync(blankPath)) fs.unlinkSync(blankPath) + root.renderer.captureScreenshot(blankPath) + + root.render( + , + ) + root.renderer.flush() + root.renderer.flush() + if (fs.existsSync(path)) fs.unlinkSync(path) + root.renderer.captureScreenshot(path) + expect(fs.existsSync(path)).toBe(true) + const pixels = canvas.readPixels() + const at = (x: number, y: number) => + Array.from(pixels.subarray((y * 256 + x) * 4, (y * 256 + x) * 4 + 4)) + expect(at(4, 4)).toEqual([255, 0, 255, 255]) + expect(at(128, 40)).not.toEqual([255, 0, 255, 255]) + const canvases = root.renderer.findByType("canvas") + expect(canvases.length).toBe(1) + expect(canvases[0]?.customProps?.source).toBe(canvas.id) + expectScreenshotsDiffer(blankPath, path) + canvas.destroy() + }) +}) diff --git a/packages/react/src/index.ts b/packages/react/src/index.ts index e3900886..efb935a7 100644 --- a/packages/react/src/index.ts +++ b/packages/react/src/index.ts @@ -77,6 +77,7 @@ export type { // Re-export types export type { MotionDivProps } from "./components/index.js" export type { + CanvasProps, CursorValue, DebugFrameOverlayMode, DebugFrameOverlayStats, diff --git a/packages/react/src/types/host.ts b/packages/react/src/types/host.ts index 25d27967..a80784c5 100644 --- a/packages/react/src/types/host.ts +++ b/packages/react/src/types/host.ts @@ -479,6 +479,11 @@ export type VirtualListProps = windowStart?: number }) +export interface CanvasProps extends Props { + /** Numeric id from `createGPUCanvas()` / `GPUCanvas.id`. */ + source?: number | { id: number } +} + // Props for native rendering. export interface ImgProps extends Props { src?: string diff --git a/packages/react/src/webgpu.ts b/packages/react/src/webgpu.ts new file mode 100644 index 00000000..c201d119 --- /dev/null +++ b/packages/react/src/webgpu.ts @@ -0,0 +1,428 @@ +/// Spec-shaped WebGPU facade over the @gpuix/native napi classes. +/// +/// Three.js WebGPURenderer calls navigator.gpu, canvas.getContext('webgpu'), +/// and device.createXxx with nested descriptors. The napi layer takes split +/// class arguments. This file is the only place that translation lives. +import { + Gpu as NativeGPU, + GpuAdapter as NativeGPUAdapter, + GpuCanvas as NativeGPUCanvas, + GpuCanvasContext as NativeGPUCanvasContext, + GpuCommandEncoder as NativeGPUCommandEncoder, + GpuDevice as NativeGPUDevice, + GpuSampler as NativeGPUSampler, + gpuBufferUsage, + gpuShaderStage, + gpuTextureUsage, +} from "@gpuix/native" + +const bufferUsage = gpuBufferUsage() +const textureUsage = gpuTextureUsage() +const shaderStage = gpuShaderStage() + +export const GPUBufferUsage = { + MAP_READ: bufferUsage.mapRead, + MAP_WRITE: bufferUsage.mapWrite, + COPY_SRC: bufferUsage.copySrc, + COPY_DST: bufferUsage.copyDst, + INDEX: bufferUsage.index, + VERTEX: bufferUsage.vertex, + UNIFORM: bufferUsage.uniform, + STORAGE: bufferUsage.storage, + INDIRECT: bufferUsage.indirect, + QUERY_RESOLVE: bufferUsage.queryResolve, +} + +export const GPUTextureUsage = { + COPY_SRC: textureUsage.copySrc, + COPY_DST: textureUsage.copyDst, + TEXTURE_BINDING: textureUsage.textureBinding, + STORAGE_BINDING: textureUsage.storageBinding, + RENDER_ATTACHMENT: textureUsage.renderAttachment, +} + +export const GPUShaderStage = { + VERTEX: shaderStage.vertex, + FRAGMENT: shaderStage.fragment, + COMPUTE: shaderStage.compute, +} + +export const GPUMapMode = { + READ: 0x0001, + WRITE: 0x0002, +} + +export class GPU { + #inner = NativeGPU.create() + + requestAdapter(options?: { powerPreference?: string }) { + return Promise.resolve(new GPUAdapter(this.#inner.requestAdapter(options?.powerPreference))) + } + + getPreferredCanvasFormat() { + return this.#inner.getPreferredCanvasFormat() + } +} + +const emptyFeatures = { + has(_name: string) { + return false + }, +} + +export class GPUAdapter { + readonly features = emptyFeatures + + constructor(private inner: NativeGPUAdapter) {} + + requestDevice(_descriptor?: object) { + return Promise.resolve(wrapDevice(this.inner.requestDevice())) + } + + get info() { + return this.inner.info + } + + get isFallbackAdapter() { + return this.inner.isFallbackAdapter + } +} + +function wrapDevice(inner: NativeGPUDevice) { + const device = inner as NativeGPUDevice & { + createCommandEncoder: NativeGPUDevice["createCommandEncoder"] + createRenderPipeline: NativeGPUDevice["createRenderPipeline"] + createComputePipeline: NativeGPUDevice["createComputePipeline"] + createBindGroup: NativeGPUDevice["createBindGroup"] + createPipelineLayout: NativeGPUDevice["createPipelineLayout"] + createTexture: NativeGPUDevice["createTexture"] + lost: Promise<{ reason: string; message: string }> + } + // Three.js treats a resolved `device.lost` as a dead GPU. + device.lost = new Promise(() => {}) + Object.defineProperty(device, "features", { + value: emptyFeatures, + enumerable: true, + }) + + const createTexture = inner.createTexture.bind(inner) + device.createTexture = ((descriptor: { + label?: string + size?: number | number[] | { width: number; height?: number; depthOrArrayLayers?: number } + width?: number + height?: number + depth?: number + format: string + usage: number + dimension?: string + mipLevelCount?: number + sampleCount?: number + }) => { + const size = descriptor.size + const width = Array.isArray(size) + ? size[0] + : typeof size === "number" + ? size + : (size?.width ?? descriptor.width ?? 1) + const height = Array.isArray(size) + ? (size[1] ?? 1) + : typeof size === "object" + ? (size.height ?? 1) + : (descriptor.height ?? 1) + const depth = Array.isArray(size) + ? size[2] + : typeof size === "object" + ? size.depthOrArrayLayers + : descriptor.depth + return createTexture({ + label: descriptor.label, + width, + height, + depth, + format: descriptor.format, + usage: descriptor.usage, + dimension: descriptor.dimension, + mipLevelCount: descriptor.mipLevelCount, + sampleCount: descriptor.sampleCount, + }) + }) as NativeGPUDevice["createTexture"] + + const createPipelineLayout = inner.createPipelineLayout.bind(inner) + device.createPipelineLayout = ((descriptor: { + label?: string + bindGroupLayouts: Parameters[1] + }) => { + return createPipelineLayout({ label: descriptor.label }, descriptor.bindGroupLayouts) + }) as NativeGPUDevice["createPipelineLayout"] + + const createBindGroup = inner.createBindGroup.bind(inner) + device.createBindGroup = ((descriptor: { + label?: string + layout: Parameters[1] + entries: Array<{ + binding: number + resource: { buffer?: unknown; offset?: number; size?: number } | unknown + }> + }) => { + const buffers: unknown[] = [] + const textures: unknown[] = [] + const samplers: unknown[] = [] + const entries = descriptor.entries.map((entry) => { + const resource = entry.resource as { buffer?: unknown; offset?: number; size?: number } + if (resource && typeof resource === "object" && "buffer" in resource && resource.buffer) { + buffers.push(resource.buffer) + return { + binding: entry.binding, + resourceType: "buffer", + offset: resource.offset, + size: resource.size, + } + } + if (resource instanceof NativeGPUSampler) { + samplers.push(resource) + return { binding: entry.binding, resourceType: "sampler" } + } + textures.push(resource) + return { binding: entry.binding, resourceType: "texture" } + }) + return createBindGroup( + { label: descriptor.label }, + descriptor.layout, + entries, + buffers as never, + textures as never, + samplers as never, + ) + }) as NativeGPUDevice["createBindGroup"] + + const createRenderPipeline = inner.createRenderPipeline.bind(inner) + device.createRenderPipeline = ((descriptor: { + label?: string + layout?: Parameters[1] | "auto" + vertex: { + module: Parameters[2] + entryPoint?: string + buffers?: unknown + } + fragment?: { + module: Parameters[3] + entryPoint?: string + targets: unknown + } + primitive?: unknown + depthStencil?: unknown + multisample?: unknown + }) => { + const layout = descriptor.layout === "auto" ? undefined : descriptor.layout + return createRenderPipeline( + { + label: descriptor.label, + vertex: { + entryPoint: descriptor.vertex.entryPoint ?? "main", + buffers: descriptor.vertex.buffers as never, + }, + fragment: descriptor.fragment + ? { + entryPoint: descriptor.fragment.entryPoint ?? "main", + targets: descriptor.fragment.targets as never, + } + : undefined, + primitive: descriptor.primitive as never, + depthStencil: descriptor.depthStencil as never, + multisample: descriptor.multisample as never, + }, + layout, + descriptor.vertex.module, + descriptor.fragment?.module, + ) + }) as NativeGPUDevice["createRenderPipeline"] + + const createComputePipeline = inner.createComputePipeline.bind(inner) + device.createComputePipeline = ((descriptor: { + label?: string + layout?: Parameters[1] + compute: { + module: Parameters[2] + entryPoint?: string + } + }) => { + return createComputePipeline( + { + label: descriptor.label, + entryPoint: descriptor.compute.entryPoint ?? "main", + }, + descriptor.layout, + descriptor.compute.module, + ) + }) as unknown as NativeGPUDevice["createComputePipeline"] + + const createCommandEncoder = inner.createCommandEncoder.bind(inner) + device.createCommandEncoder = ((descriptor?: { label?: string }) => { + return wrapEncoder(createCommandEncoder(descriptor)) + }) as NativeGPUDevice["createCommandEncoder"] + + const createBuffer = inner.createBuffer.bind(inner) + device.createBuffer = ((descriptor: Parameters[0]) => { + const buffer = createBuffer(descriptor) + const mapAsync = buffer.mapAsync.bind(buffer) + buffer.mapAsync = ((...args: Parameters) => + Promise.resolve(mapAsync(...args))) as typeof buffer.mapAsync + return buffer + }) as NativeGPUDevice["createBuffer"] + + const queue = inner.queue as NativeGPUDevice["queue"] & { + onSubmittedWorkDone?: () => Promise + } + const onSubmittedWorkDone = queue.onSubmittedWorkDone?.bind(queue) + queue.onSubmittedWorkDone = () => Promise.resolve().then(() => onSubmittedWorkDone?.()) + Object.defineProperty(device, "queue", { + value: queue, + enumerable: true, + }) + + return device +} + +function wrapEncoder(encoder: NativeGPUCommandEncoder) { + const originalBeginRenderPass = encoder.beginRenderPass.bind(encoder) + encoder.beginRenderPass = ((descriptor: { + label?: string + colorAttachments: Array<{ + view: unknown + resolveTarget?: unknown + loadOp: string + storeOp: string + clearValue?: { r: number; g: number; b: number; a: number } + }> + depthStencilAttachment?: { + view: unknown + depthClearValue?: number + depthLoadOp?: string + depthStoreOp?: string + } + }) => { + return originalBeginRenderPass( + { + label: descriptor.label, + colorAttachments: descriptor.colorAttachments.map((attachment) => ({ + loadOp: attachment.loadOp, + storeOp: attachment.storeOp, + clearValue: attachment.clearValue, + })), + depthStencilAttachment: descriptor.depthStencilAttachment + ? { + depthClearValue: descriptor.depthStencilAttachment.depthClearValue, + depthLoadOp: descriptor.depthStencilAttachment.depthLoadOp, + depthStoreOp: descriptor.depthStencilAttachment.depthStoreOp, + } + : undefined, + }, + descriptor.colorAttachments.map((attachment) => attachment.view) as never, + descriptor.colorAttachments.map((attachment) => attachment.resolveTarget ?? null) as never, + descriptor.depthStencilAttachment?.view as never, + ) + }) as NativeGPUCommandEncoder["beginRenderPass"] + return encoder +} + +export class GPUCanvas { + #inner: NativeGPUCanvas + #context: ReturnType | null = null + style: Record = {} + + constructor(width = 1, height = 1) { + this.#inner = new NativeGPUCanvas(width, height) + } + + get id() { + return this.#inner.id + } + + get width() { + return this.#inner.width + } + + set width(value: number) { + this.#inner.width = value + } + + get height() { + return this.#inner.height + } + + set height(value: number) { + this.#inner.height = value + } + + get clientWidth() { + return this.#inner.width + } + + get clientHeight() { + return this.#inner.height + } + + addEventListener(_type: string, _listener?: () => void) {} + + removeEventListener(_type: string, _listener?: () => void) {} + + readPixels() { + return this.#inner.readPixels() + } + + destroy() { + this.#inner.destroy() + } + + getContext(contextId: string) { + if (contextId !== "webgpu") { + throw new Error(`Only getContext("webgpu") is supported, got ${contextId}`) + } + this.#context ??= wrapCanvasContext(this.#inner.getContext(contextId)) + return this.#context + } +} + +function wrapCanvasContext(context: NativeGPUCanvasContext) { + const originalConfigure = context.configure.bind(context) + context.configure = ((configuration: { + device: object + format?: string + usage?: number + alphaMode?: string + }) => { + originalConfigure( + { + format: configuration.format, + usage: configuration.usage, + alphaMode: configuration.alphaMode, + }, + configuration.device as NativeGPUDevice, + ) + }) as NativeGPUCanvasContext["configure"] + return context +} + +export function createGPUCanvas(width: number, height: number) { + return new GPUCanvas(width, height) +} + +export const gpu = new GPU() + +export function installWebGpu(target: { navigator?: { gpu?: GPU } } = globalThis as never) { + const global = target as { + navigator?: { gpu?: GPU; userAgent?: string } + GPUBufferUsage?: typeof GPUBufferUsage + GPUTextureUsage?: typeof GPUTextureUsage + GPUShaderStage?: typeof GPUShaderStage + GPUMapMode?: typeof GPUMapMode + } + const navigator = (global.navigator ??= {}) + navigator.gpu = gpu + navigator.userAgent ??= "gpuix" + global.GPUBufferUsage = GPUBufferUsage + global.GPUTextureUsage = GPUTextureUsage + global.GPUShaderStage = GPUShaderStage + global.GPUMapMode = GPUMapMode + return gpu +} diff --git a/zed b/zed index df3c9b72..8802c831 160000 --- a/zed +++ b/zed @@ -1 +1 @@ -Subproject commit df3c9b72600b9fd9e5eeb2794286642a7df86fdd +Subproject commit 8802c8313838b638f2f599332f65e097ae4c6c15