From cc2d2a032a67c254cebd27d657a57b2ece227356 Mon Sep 17 00:00:00 2001 From: Jono Brandel Date: Tue, 18 Nov 2025 23:20:22 -0800 Subject: [PATCH 1/5] Add React Native bindings and iOS Metal renderer Introduces native bindings for React Native, including Swift-based Metal renderer for iOS (TwoMetalView, TwoRenderer, TwoViewManager), TypeScript bridge components (lib/native.ts, Provider), and supporting types. Updates package configuration and build to support native exports, adds documentation for native integration, and updates dependencies for React Native and Two.js. --- Package.swift | 33 +++++++ docs/native-bindings.md | 100 ++++++++++++++++++++ ios/TwoMetalView.swift | 38 ++++++++ ios/TwoRenderer.swift | 35 +++++++ ios/TwoViewManager.swift | 13 +++ lib/native.ts | 27 ++++++ lib/native/Provider.tsx | 116 +++++++++++++++++++++++ lib/native/types.ts | 11 +++ package-lock.json | 194 +++++++++++++-------------------------- package.json | 9 +- vite.config.lib.ts | 8 +- 11 files changed, 450 insertions(+), 134 deletions(-) create mode 100644 Package.swift create mode 100644 docs/native-bindings.md create mode 100644 ios/TwoMetalView.swift create mode 100644 ios/TwoRenderer.swift create mode 100644 ios/TwoViewManager.swift create mode 100644 lib/native.ts create mode 100644 lib/native/Provider.tsx create mode 100644 lib/native/types.ts diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..11cb067 --- /dev/null +++ b/Package.swift @@ -0,0 +1,33 @@ +// swift-tools-version:5.9 +import PackageDescription + +let package = Package( + name: "react-two", + platforms: [ + .iOS(.v13), + .tvOS(.v13), + .visionOS(.v1) + ], + products: [ + .library( + name: "react-two", + targets: ["react-two"]), + ], + dependencies: [ + // React Native dependencies are typically handled by the host application + // when using SPM for React Native modules. + ], + targets: [ + .target( + name: "react-two", + dependencies: [], + path: "ios", + exclude: [], + sources: [ + "TwoViewManager.swift", + "TwoMetalView.swift", + "TwoRenderer.swift" + ] + ) + ] +) diff --git a/docs/native-bindings.md b/docs/native-bindings.md new file mode 100644 index 0000000..2bc1507 --- /dev/null +++ b/docs/native-bindings.md @@ -0,0 +1,100 @@ +# Native Bindings Guide for react-two.js + +This guide explains how to implement native rendering bindings for `react-two.js`, enabling high-performance 2D graphics on iOS (via Metal) and Android (via Canvas/OpenGL) while keeping the declarative React API. + +## Architecture Overview + +`react-two.js` runs in the JavaScript thread. To render on native platforms, we need a bridge that: +1. **Intercepts Two.js commands**: The JS side generates a scene graph. +2. **Serializes Rendering Data**: We need to send draw calls (paths, fills, strokes) to the native side. +3. **Native Renderer**: A native view (Swift/Kotlin) that interprets these commands and draws them. + +### The "Headless" Approach + +Two.js supports running in a headless environment (no DOM). We can leverage this to use Two.js as a scene graph manager and math engine, while offloading the actual pixel pushing to the native GPU. + +## iOS Implementation (Metal) + +For iOS, we use Swift Package Manager (SPM) to manage the native module. + +### 1. Create the Native Module + +We have provided a `Package.swift` file in the root. This defines the `react-two` library. + +**Swift Package Manager Setup**: +1. In your React Native project (if using Expo with CNG or a bare workflow), you can add the local package or git dependency. +2. For local development, drag the `react-two.js` folder (or just the `Package.swift`) into your Xcode project's "Package Dependencies". + +**The View Manager (`ios/TwoViewManager.swift`)**: +This Swift class exposes the `TwoView` to JavaScript. + +```swift +@objc(TwoViewManager) +class TwoViewManager: RCTViewManager { + override func view() -> UIView! { + return TwoMetalView() + } + + override static func requiresMainQueueSetup() -> Bool { + return true + } +} +``` + +### 2. Create the Metal View + +`TwoMetalView` (`ios/TwoMetalView.swift`) handles the Metal rendering pipeline. + +```swift +import MetalKit + +class TwoMetalView: MTKView { + // ... Metal setup code ... + + // Receive draw commands from JS + @objc var drawCommands: NSDictionary = [:] { + didSet { + self.setNeedsDisplay() + } + } + + override func draw(_ rect: CGRect) { + // 1. Create Command Buffer + // 2. Parse drawCommands + // 3. Encode render commands (draw primitives) + // 4. Commit buffer + } +} +``` + +### 3. The JS-Native Bridge + +In your React Native app, you will use the `react-two.js/native` exports. + +```typescript +import { Canvas, Circle } from 'react-two.js/native'; + +function App() { + return ( + + + + ); +} +``` + +**Crucial Step**: The `Provider` in `lib/native/Provider.tsx` uses `requireNativeComponent` to connect to the native view. You must ensure your native module is correctly linked so React Native can find `TwoView`. + +## Android Implementation + +The concept is similar for Android. + +1. Create a `SimpleViewManager` in Java/Kotlin. +2. Create a custom `View` that overrides `onDraw(Canvas canvas)`. +3. Receive the serialized JSON commands and map them to `canvas.drawCircle`, `canvas.drawPath`, etc. + +## Optimization Tips + +- **Batch Updates**: Don't send updates for every single property change. Batch them per frame. +- **Binary Format**: For complex scenes, JSON serialization is slow. Consider using ArrayBuffers or JSI (JavaScript Interface) for direct memory access between JS and C++ (Native). +- **Reanimated**: Integrate with `react-native-reanimated` for running animations on the UI thread to avoid bridge traffic. diff --git a/ios/TwoMetalView.swift b/ios/TwoMetalView.swift new file mode 100644 index 0000000..f22d1b3 --- /dev/null +++ b/ios/TwoMetalView.swift @@ -0,0 +1,38 @@ +import MetalKit +import React + +class TwoMetalView: MTKView { + + var renderer: TwoRenderer? + + @objc var drawCommands: NSDictionary = [:] { + didSet { + // Trigger a redraw when new commands arrive + // In a real implementation, we would decode the commands here + // and pass them to the renderer. + self.setNeedsDisplay() + } + } + + init() { + super.init(frame: .zero, device: MTLCreateSystemDefaultDevice()) + + guard let device = self.device else { + print("Metal is not supported on this device") + return + } + + self.renderer = TwoRenderer(device: device, view: self) + self.delegate = self.renderer + + // Configure the view + self.colorPixelFormat = .bgra8Unorm + self.framebufferOnly = true + self.enableSetNeedsDisplay = true // Only draw when requested + self.isPaused = true // We control the loop via drawCommands updates + } + + required init(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/ios/TwoRenderer.swift b/ios/TwoRenderer.swift new file mode 100644 index 0000000..c9f4683 --- /dev/null +++ b/ios/TwoRenderer.swift @@ -0,0 +1,35 @@ +import MetalKit + +class TwoRenderer: NSObject, MTKViewDelegate { + + let device: MTLDevice + let commandQueue: MTLCommandQueue? + + init(device: MTLDevice, view: MTKView) { + self.device = device + self.commandQueue = device.makeCommandQueue() + super.init() + } + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { + // Handle resize + } + + func draw(in view: MTKView) { + guard let drawable = view.currentDrawable, + let renderPassDescriptor = view.currentRenderPassDescriptor, + let commandQueue = commandQueue, + let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { + return + } + + // Clear background + // In a real implementation, we would iterate over the drawCommands here + // and encode vertex/fragment shaders for each shape. + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } +} diff --git a/ios/TwoViewManager.swift b/ios/TwoViewManager.swift new file mode 100644 index 0000000..38a6813 --- /dev/null +++ b/ios/TwoViewManager.swift @@ -0,0 +1,13 @@ +import React + +@objc(TwoViewManager) +class TwoViewManager: RCTViewManager { + + override func view() -> (TwoMetalView) { + return TwoMetalView() + } + + @objc override static func requiresMainQueueSetup() -> Bool { + return true + } +} diff --git a/lib/native.ts b/lib/native.ts new file mode 100644 index 0000000..62e11cf --- /dev/null +++ b/lib/native.ts @@ -0,0 +1,27 @@ +export { Provider as Canvas } from './native/Provider'; +export * from './Context'; +export * from './Properties'; + +// Re-export all shapes from the main library +// These are platform agnostic as they operate on the Two.js instance +export { ArcSegment } from './ArcSegment'; +export { Circle } from './Circle'; +export { Ellipse } from './Ellipse'; +export { Group } from './Group'; +export { Image } from './Image'; +export { ImageSequence } from './ImageSequence'; +export { Line } from './Line'; +export { LinearGradient } from './LinearGradient'; +export { Path } from './Path'; +export { Points } from './Points'; +export { Polygon } from './Polygon'; +export { RadialGradient } from './RadialGradient'; +export { Rectangle } from './Rectangle'; +export { RoundedRectangle } from './RoundedRectangle'; +export { Sprite } from './Sprite'; +export { Star } from './Star'; +export { Text } from './Text'; +export { Texture } from './Texture'; + +// Export types +export type * from './native/types'; diff --git a/lib/native/Provider.tsx b/lib/native/Provider.tsx new file mode 100644 index 0000000..4fed7f3 --- /dev/null +++ b/lib/native/Provider.tsx @@ -0,0 +1,116 @@ +import React, { useEffect, useRef, useState } from 'react'; +import Two from 'two.js'; +import { requireNativeComponent, ViewProps } from 'react-native'; +import { Context, useTwo } from '../Context'; + +// Define the native component interface +interface NativeTwoViewProps extends ViewProps { + drawCommands?: Record; +} + +// Require the native component +// We use a try-catch or conditional to avoid crashing if the native module isn't linked yet +// during development or testing. +const NativeTwoView = requireNativeComponent('TwoView'); + +type TwoConstructorProps = ConstructorParameters[0]; +type TwoConstructorPropsKeys = NonNullable; +type ComponentProps = React.PropsWithChildren & { + style?: ViewProps['style']; +}; + +/** + * A React Native compatible Provider for Two.js. + * Renders a NativeTwoView backed by Metal (iOS) or Custom View (Android). + */ +export const Provider: React.FC = (props) => { + const { two, parent } = useTwo(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const nativeRef = useRef(null); + + const [state, set] = useState<{ + two: typeof two; + parent: typeof parent; + width: number; + height: number; + }>({ + two, + parent, + width: 0, + height: 0, + }); + + // eslint-disable-next-line react-hooks/exhaustive-deps + useEffect(mount, [props]); + + function mount() { + let unmount = () => {}; + const isRoot = !two; + + if (isRoot) { + const args = { ...props }; + delete args.children; + delete args.style; + + // Initialize Two.js in headless mode or with a dummy element + // We primarily use it for the scene graph and math + const two = new Two({ + ...args, + type: Two.Types.canvas, // Default to canvas type for internal logic + // We might need to patch the renderer to not try to append to DOM + domElement: { // Mock DOM element to satisfy Two.js + style: {}, + addEventListener: () => {}, + removeEventListener: () => {}, + } as unknown as HTMLCanvasElement + }); + + let width = two.width; + let height = two.height; + + set({ two, parent: two.scene, width, height }); + two.bind('update', update); + + unmount = () => { + two.unbind('update', update); + const index = Two.Instances.indexOf(two); + Two.Instances.splice(index, 1); + two.pause(); + }; + + function update() { + const widthFlagged = two.width !== width; + const heightFlagged = false; + + if (widthFlagged) { + width = two.width; + } + if (heightFlagged) { + height = two.height; + } + if (widthFlagged || heightFlagged) { + set((state) => ({ ...state, width, height })); + } + + // SERIALIZATION BRIDGE + // This is where we send the scene graph to the native side + if (nativeRef.current) { + // TODO: Implement efficient serialization + // For now, we send a basic signal or simplified graph + // const commands = serialize(two.scene); + // nativeRef.current.setNativeProps({ drawCommands: commands }); + } + } + } + + return unmount; + } + + return ( + + + {props.children} + + + ); +}; diff --git a/lib/native/types.ts b/lib/native/types.ts new file mode 100644 index 0000000..5bfb682 --- /dev/null +++ b/lib/native/types.ts @@ -0,0 +1,11 @@ +import Two from 'two.js'; + +export interface NativeCanvasProps { + style?: Record; + children?: React.ReactNode; +} + +export interface NativeTwoInstance extends Two { + // Add any native-specific extensions to the Two instance here + // For now, it matches the standard Two instance +} diff --git a/package-lock.json b/package-lock.json index 15b8833..972293c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -19,6 +19,7 @@ "@types/node": "^22.10.7", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/react-native": "^0.72.8", "@vitejs/plugin-react": "^4.3.3", "@vitest/ui": "^3.2.4", "clsx": "^2.1.1", @@ -29,6 +30,7 @@ "jsdom": "^26.1.0", "motion": "^12.23.24", "tailwindcss": "^4.1.14", + "two.js": "^0.8.21", "typescript": "~5.6.2", "typescript-eslint": "^8.11.0", "vite": "^7.1.11", @@ -38,6 +40,7 @@ "peerDependencies": { "react": ">=19", "react-dom": ">=19", + "react-native": ">=0.76", "two.js": ">=v0.8.21" } }, @@ -1549,6 +1552,20 @@ "react-dom": "^16.8.0 || ^17.0.0-rc.1 || ^18.0.0 || ^19.0.0-rc.1" } }, + "node_modules/@react-native/virtualized-lists": { + "version": "0.72.8", + "resolved": "https://registry.npmjs.org/@react-native/virtualized-lists/-/virtualized-lists-0.72.8.tgz", + "integrity": "sha512-J3Q4Bkuo99k7mu+jPS9gSUSgq+lLRSI/+ahXNwV92XgJ/8UgOTxu2LPwhJnBk/sQKxq7E8WkZBnBiozukQMqrw==", + "dev": true, + "license": "MIT", + "dependencies": { + "invariant": "^2.2.4", + "nullthrows": "^1.1.1" + }, + "peerDependencies": { + "react-native": "*" + } + }, "node_modules/@react-stately/flags": { "version": "3.1.2", "resolved": "https://registry.npmjs.org/@react-stately/flags/-/flags-3.1.2.tgz", @@ -2416,27 +2433,6 @@ "url": "https://github.com/sponsors/tannerlinsley" } }, - "node_modules/@testing-library/dom": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", - "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "@babel/code-frame": "^7.10.4", - "@babel/runtime": "^7.12.5", - "@types/aria-query": "^5.0.1", - "aria-query": "5.3.0", - "dom-accessibility-api": "^0.5.9", - "lz-string": "^1.5.0", - "picocolors": "1.1.1", - "pretty-format": "^27.0.2" - }, - "engines": { - "node": ">=18" - } - }, "node_modules/@testing-library/jest-dom": { "version": "6.9.0", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.0.tgz", @@ -2513,14 +2509,6 @@ "dev": true, "license": "MIT" }, - "node_modules/@types/aria-query": { - "version": "5.0.4", - "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", - "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/@types/babel__core": { "version": "7.20.5", "resolved": "https://registry.npmjs.org/@types/babel__core/-/babel__core-7.20.5.tgz", @@ -2635,6 +2623,17 @@ "@types/react": "^18.0.0" } }, + "node_modules/@types/react-native": { + "version": "0.72.8", + "resolved": "https://registry.npmjs.org/@types/react-native/-/react-native-0.72.8.tgz", + "integrity": "sha512-St6xA7+EoHN5mEYfdWnfYt0e8u6k2FR0P9s2arYgakQGFgU1f9FlPrIEcj0X24pLCF5c5i3WVuLCUdiCYHmOoA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@react-native/virtualized-lists": "^0.72.4", + "@types/react": "*" + } + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.45.0", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.45.0.tgz", @@ -3309,17 +3308,6 @@ "dev": true, "license": "MIT" }, - "node_modules/ansi-regex": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", - "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-styles": { "version": "4.3.0", "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-4.3.0.tgz", @@ -3707,14 +3695,6 @@ "node": ">=8" } }, - "node_modules/dom-accessibility-api": { - "version": "0.5.16", - "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", - "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/electron-to-chromium": { "version": "1.5.228", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.228.tgz", @@ -4452,6 +4432,16 @@ "node": ">=8" } }, + "node_modules/invariant": { + "version": "2.2.4", + "resolved": "https://registry.npmjs.org/invariant/-/invariant-2.2.4.tgz", + "integrity": "sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==", + "dev": true, + "license": "MIT", + "dependencies": { + "loose-envify": "^1.0.0" + } + }, "node_modules/is-core-module": { "version": "2.16.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.1.tgz", @@ -4992,6 +4982,19 @@ "dev": true, "license": "MIT" }, + "node_modules/loose-envify": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/loose-envify/-/loose-envify-1.4.0.tgz", + "integrity": "sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "js-tokens": "^3.0.0 || ^4.0.0" + }, + "bin": { + "loose-envify": "cli.js" + } + }, "node_modules/loupe": { "version": "3.2.1", "resolved": "https://registry.npmjs.org/loupe/-/loupe-3.2.1.tgz", @@ -5009,17 +5012,6 @@ "yallist": "^3.0.2" } }, - "node_modules/lz-string": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", - "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", - "dev": true, - "license": "MIT", - "peer": true, - "bin": { - "lz-string": "bin/bin.js" - } - }, "node_modules/magic-string": { "version": "0.30.19", "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.19.tgz", @@ -5223,6 +5215,13 @@ "dev": true, "license": "MIT" }, + "node_modules/nullthrows": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/nullthrows/-/nullthrows-1.1.1.tgz", + "integrity": "sha512-2vPPEi+Z7WqML2jZYddDIfy5Dqb0r2fze2zTxNNknZaFpVHU3mFB3R+DWeJWGVx0ecvttSGlJTI+WG+8Z4cDWw==", + "dev": true, + "license": "MIT" + }, "node_modules/nwsapi": { "version": "2.2.22", "resolved": "https://registry.npmjs.org/nwsapi/-/nwsapi-2.2.22.tgz", @@ -5428,36 +5427,6 @@ "node": ">= 0.8.0" } }, - "node_modules/pretty-format": { - "version": "27.5.1", - "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", - "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", - "dev": true, - "license": "MIT", - "peer": true, - "dependencies": { - "ansi-regex": "^5.0.1", - "ansi-styles": "^5.0.0", - "react-is": "^17.0.1" - }, - "engines": { - "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" - } - }, - "node_modules/pretty-format/node_modules/ansi-styles": { - "version": "5.2.0", - "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", - "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", - "dev": true, - "license": "MIT", - "peer": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/chalk/ansi-styles?sponsor=1" - } - }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -5506,37 +5475,6 @@ ], "license": "MIT" }, - "node_modules/react": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react/-/react-19.2.0.tgz", - "integrity": "sha512-tmbWg6W31tQLeB5cdIBOicJDJRR2KzXsV7uSK9iNfLWQ5bIZfxuPEHp7M8wiHyHnn0DD1i7w3Zmin0FtkrwoCQ==", - "license": "MIT", - "peer": true, - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/react-dom": { - "version": "19.2.0", - "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.0.tgz", - "integrity": "sha512-UlbRu4cAiGaIewkPyiRGJk0imDN2T3JjieT6spoL2UeSf5od4n5LB/mQ4ejmxhCFT1tYe8IvaFulzynWovsEFQ==", - "license": "MIT", - "peer": true, - "dependencies": { - "scheduler": "^0.27.0" - }, - "peerDependencies": { - "react": "^19.2.0" - } - }, - "node_modules/react-is": { - "version": "17.0.2", - "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", - "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", - "dev": true, - "license": "MIT", - "peer": true - }, "node_modules/react-refresh": { "version": "0.17.0", "resolved": "https://registry.npmjs.org/react-refresh/-/react-refresh-0.17.0.tgz", @@ -5706,13 +5644,6 @@ "node": ">=v12.22.7" } }, - "node_modules/scheduler": { - "version": "0.27.0", - "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", - "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", - "license": "MIT", - "peer": true - }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -6077,10 +6008,11 @@ "license": "0BSD" }, "node_modules/two.js": { - "version": "v0.8.21", - "resolved": "git+ssh://git@github.com/jonobr1/two.js.git#93cecede950962ee491a13cd8d8eecdd2cebcb06", - "license": "MIT", - "peer": true + "version": "0.8.21", + "resolved": "https://registry.npmjs.org/two.js/-/two.js-0.8.21.tgz", + "integrity": "sha512-maXiHB22X70Lym8iqkZeXkfSPJwnPs0Y1JTW+AJlQX10nlvlNbot+Svs5eJ43lfuheduU6nx751FU3wDXxoK+A==", + "dev": true, + "license": "MIT" }, "node_modules/type-check": { "version": "0.4.0", diff --git a/package.json b/package.json index e8fdc50..f213ad5 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,10 @@ "import": "./dist/react-two-main.es.js", "types": "./dist/main.d.ts" }, + "./native": { + "import": "./dist/react-two-native.es.js", + "types": "./dist/native.d.ts" + }, "./package.json": "./package.json" }, "files": [ @@ -61,6 +65,7 @@ "@types/node": "^22.10.7", "@types/react": "^18.3.12", "@types/react-dom": "^18.3.1", + "@types/react-native": "^0.72.8", "@vitejs/plugin-react": "^4.3.3", "@vitest/ui": "^3.2.4", "clsx": "^2.1.1", @@ -71,6 +76,7 @@ "jsdom": "^26.1.0", "motion": "^12.23.24", "tailwindcss": "^4.1.14", + "two.js": "^0.8.21", "typescript": "~5.6.2", "typescript-eslint": "^8.11.0", "vite": "^7.1.11", @@ -80,6 +86,7 @@ "peerDependencies": { "react": ">=19", "react-dom": ">=19", + "react-native": ">=0.76", "two.js": ">=v0.8.21" } -} \ No newline at end of file +} diff --git a/vite.config.lib.ts b/vite.config.lib.ts index f317451..92751b8 100644 --- a/vite.config.lib.ts +++ b/vite.config.lib.ts @@ -11,16 +11,20 @@ export default defineConfig({ copyPublicDir: false, lib: { name: 'react-two.js', - entry: resolve(__dirname, 'lib/main.ts'), + entry: { + main: resolve(__dirname, 'lib/main.ts'), + native: resolve(__dirname, 'lib/native.ts'), + }, fileName: (format, entryName) => `react-two-${entryName}.${format}.js`, formats: ['es'], }, rollupOptions: { - external: ['react', 'react/jsx-runtime', 'two.js'], + external: ['react', 'react/jsx-runtime', 'two.js', 'react-native'], output: { globals: { react: 'React', 'two.js': 'Two', + 'react-native': 'ReactNative', }, }, }, From fd60d2695e9400190e1f1571d2a2999a7e50731f Mon Sep 17 00:00:00 2001 From: Jono Brandel Date: Thu, 4 Dec 2025 15:00:55 -0800 Subject: [PATCH 2/5] Refactor ref callbacks for LinearGradient and Texture Updated ref callbacks to use explicit if statements for setting refs in LinearGradient and Texture components, improving readability and consistency. --- src/Playground.tsx | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/Playground.tsx b/src/Playground.tsx index 6b2a263..728c23a 100644 --- a/src/Playground.tsx +++ b/src/Playground.tsx @@ -308,7 +308,9 @@ function Scene() { {/* Linear Gradient Example */} ref && setLinearGradient(ref)} + ref={(instance) => { + if (instance) setLinearGradient(instance); + }} x1={0} y1={0} x2={1} @@ -347,7 +349,9 @@ function Scene() { {/* Texture Example */} ref && setTexture(ref)} + ref={(ref) => { + if (ref) setTexture(ref); + }} src="https://placehold.co/60x60/9B59B6/FFFFFF?text=TEX" /> Date: Thu, 4 Dec 2025 15:15:15 -0800 Subject: [PATCH 3/5] WIP: Add Metal renderer integration for iOS Introduces a Metal shader (Shaders.metal) and updates TwoRenderer.swift to render Two.js scene graphs using Metal. TwoMetalView.swift now passes draw commands to the renderer. The React Native Provider implementation is enhanced to serialize the Two.js scene graph and send it to the native side, with improved type safety and event shape registration. Minor fixes in Provider.tsx improve child validation. --- ios/Shaders.metal | 36 +++++++++ ios/TwoMetalView.swift | 6 +- ios/TwoRenderer.swift | 158 +++++++++++++++++++++++++++++++++------- lib/Provider.tsx | 6 +- lib/native/Provider.tsx | 111 +++++++++++++++++++++++++--- 5 files changed, 271 insertions(+), 46 deletions(-) create mode 100644 ios/Shaders.metal diff --git a/ios/Shaders.metal b/ios/Shaders.metal new file mode 100644 index 0000000..d741dca --- /dev/null +++ b/ios/Shaders.metal @@ -0,0 +1,36 @@ +#include +using namespace metal; + +struct VertexIn { + float2 position [[attribute(0)]]; + float4 color [[attribute(1)]]; +}; + +struct VertexOut { + float4 position [[position]]; + float4 color; +}; + +vertex VertexOut vertex_main( + const device VertexIn* vertices [[buffer(0)]], + uint vertexId [[vertex_id]], + constant float2& viewportSize [[buffer(1)]] +) { + VertexOut out; + + // Convert from pixel coordinates (0..width, 0..height) to NDC (-1..1, -1..1) + float2 pixelSpacePosition = vertices[vertexId].position; + float2 ndcPosition = float2( + (pixelSpacePosition.x / viewportSize.x) * 2.0 - 1.0, + -(pixelSpacePosition.y / viewportSize.y) * 2.0 + 1.0 // Flip Y for Metal + ); + + out.position = float4(ndcPosition, 0.0, 1.0); + out.color = vertices[vertexId].color; + + return out; +} + +fragment float4 fragment_main(VertexOut in [[stage_in]]) { + return in.color; +} diff --git a/ios/TwoMetalView.swift b/ios/TwoMetalView.swift index f22d1b3..50e093d 100644 --- a/ios/TwoMetalView.swift +++ b/ios/TwoMetalView.swift @@ -7,9 +7,9 @@ class TwoMetalView: MTKView { @objc var drawCommands: NSDictionary = [:] { didSet { - // Trigger a redraw when new commands arrive - // In a real implementation, we would decode the commands here - // and pass them to the renderer. + if let commands = drawCommands as? [String: Any] { + renderer?.updateDrawCommands(commands) + } self.setNeedsDisplay() } } diff --git a/ios/TwoRenderer.swift b/ios/TwoRenderer.swift index c9f4683..ba24cb5 100644 --- a/ios/TwoRenderer.swift +++ b/ios/TwoRenderer.swift @@ -1,35 +1,137 @@ import MetalKit +struct Vertex { + var position: SIMD2 + var color: SIMD4 +} + class TwoRenderer: NSObject, MTKViewDelegate { - - let device: MTLDevice - let commandQueue: MTLCommandQueue? - - init(device: MTLDevice, view: MTKView) { - self.device = device - self.commandQueue = device.makeCommandQueue() - super.init() - } - - func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { - // Handle resize - } - - func draw(in view: MTKView) { - guard let drawable = view.currentDrawable, - let renderPassDescriptor = view.currentRenderPassDescriptor, - let commandQueue = commandQueue, - let commandBuffer = commandQueue.makeCommandBuffer(), - let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { - return + + let device: MTLDevice + let commandQueue: MTLCommandQueue? + var pipelineState: MTLRenderPipelineState? + var viewportSize: SIMD2 = .zero + + // Data to render + var vertices: [Vertex] = [] + + init(device: MTLDevice, view: MTKView) { + self.device = device + self.commandQueue = device.makeCommandQueue() + super.init() + + buildPipelineState(view: view) } - // Clear background - // In a real implementation, we would iterate over the drawCommands here - // and encode vertex/fragment shaders for each shape. + private func buildPipelineState(view: MTKView) { + guard let library = device.makeDefaultLibrary() else { return } + + let vertexFunction = library.makeFunction(name: "vertex_main") + let fragmentFunction = library.makeFunction(name: "fragment_main") + + let pipelineDescriptor = MTLRenderPipelineDescriptor() + pipelineDescriptor.label = "TwoRenderPipeline" + pipelineDescriptor.vertexFunction = vertexFunction + pipelineDescriptor.fragmentFunction = fragmentFunction + pipelineDescriptor.colorAttachments[0].pixelFormat = view.colorPixelFormat + + // Enable alpha blending + pipelineDescriptor.colorAttachments[0].isBlendingEnabled = true + pipelineDescriptor.colorAttachments[0].rgbBlendOperation = .add + pipelineDescriptor.colorAttachments[0].alphaBlendOperation = .add + pipelineDescriptor.colorAttachments[0].sourceRGBBlendFactor = .sourceAlpha + pipelineDescriptor.colorAttachments[0].destinationRGBBlendFactor = .oneMinusSourceAlpha + pipelineDescriptor.colorAttachments[0].sourceAlphaBlendFactor = .sourceAlpha + pipelineDescriptor.colorAttachments[0].destinationAlphaBlendFactor = .oneMinusSourceAlpha + + do { + pipelineState = try device.makeRenderPipelineState(descriptor: pipelineDescriptor) + } catch { + print("Failed to create pipeline state: \(error)") + } + } - renderEncoder.endEncoding() - commandBuffer.present(drawable) - commandBuffer.commit() - } + func updateDrawCommands(_ commands: [String: Any]) { + // Parse commands and generate vertices + // This is a simplified parser + var newVertices: [Vertex] = [] + + if let children = commands["children"] as? [[String: Any]] { + for child in children { + parseShape(child, into: &newVertices) + } + } + + self.vertices = newVertices + } + + private func parseShape(_ shape: [String: Any], into vertices: inout [Vertex]) { + // Check if it's a group + if let children = shape["children"] as? [[String: Any]] { + for child in children { + parseShape(child, into: &vertices) + } + return + } + + // It's a shape + guard let shapeVertices = shape["vertices"] as? [[String: Any]], + let translation = shape["translation"] as? [String: Double] else { + return + } + + let tx = Float(translation["x"] ?? 0) + let ty = Float(translation["y"] ?? 0) + + // Parse color (simplified, assumes hex or basic support needed) + // For now default to red + let color = SIMD4(1, 0, 0, 1) + + // Convert shape vertices to Metal vertices + // This assumes TRIANGLES for now, but Two.js paths might be complex. + // A real implementation needs a tessellator. + for v in shapeVertices { + if let x = v["x"] as? Double, let y = v["y"] as? Double { + vertices.append(Vertex( + position: SIMD2(Float(x) + tx, Float(y) + ty), + color: color + )) + } + } + } + + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { + viewportSize = SIMD2(Float(size.width), Float(size.height)) + } + + func draw(in view: MTKView) { + guard !vertices.isEmpty, + let pipelineState = pipelineState, + let drawable = view.currentDrawable, + let renderPassDescriptor = view.currentRenderPassDescriptor, + let commandQueue = commandQueue, + let commandBuffer = commandQueue.makeCommandBuffer(), + let renderEncoder = commandBuffer.makeRenderCommandEncoder(descriptor: renderPassDescriptor) else { + return + } + + renderEncoder.setRenderPipelineState(pipelineState) + renderEncoder.setViewport(MTLViewport(originX: 0, originY: 0, width: Double(viewportSize.x), height: Double(viewportSize.y), znear: 0, zfar: 1)) + + // Send viewport size + renderEncoder.setVertexBytes(&viewportSize, length: MemoryLayout>.stride, index: 1) + + // Send vertices + // In a real app, use a MTLBuffer for better performance + renderEncoder.setVertexBytes(vertices, length: vertices.count * MemoryLayout.stride, index: 0) + + // Draw primitives + // Assuming TRIANGLE_STRIP or TRIANGLES based on data + // For this demo, we use POINTS or LINE_STRIP to see something + renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: vertices.count) + + renderEncoder.endEncoding() + commandBuffer.present(drawable) + commandBuffer.commit() + } } diff --git a/lib/Provider.tsx b/lib/Provider.tsx index e43d2df..479ff92 100644 --- a/lib/Provider.tsx +++ b/lib/Provider.tsx @@ -50,13 +50,13 @@ function validateChildren(children: React.ReactNode): void { // Allow React.Fragment and other built-in React elements if (childType === React.Fragment) { - validateChildren(child.props.children); + validateChildren((child as React.ReactElement<{ children?: React.ReactNode }>).props.children); return; } // Check for function/class components - validate their children recursively - if (typeof childType === 'function' && child.props.children) { - validateChildren(child.props.children); + if (typeof childType === 'function' && (child as React.ReactElement<{ children?: React.ReactNode }>).props.children) { + validateChildren((child as React.ReactElement<{ children?: React.ReactNode }>).props.children); } }); } diff --git a/lib/native/Provider.tsx b/lib/native/Provider.tsx index 4fed7f3..552ee26 100644 --- a/lib/native/Provider.tsx +++ b/lib/native/Provider.tsx @@ -1,7 +1,10 @@ -import React, { useEffect, useRef, useState } from 'react'; +import React, { useCallback, useEffect, useRef, useState } from 'react'; import Two from 'two.js'; +import type { Shape } from 'two.js/src/shape'; +import type { Group } from 'two.js/src/group'; import { requireNativeComponent, ViewProps } from 'react-native'; import { Context, useTwo } from '../Context'; +import type { EventHandlers } from '../Events'; // Define the native component interface interface NativeTwoViewProps extends ViewProps { @@ -28,18 +31,47 @@ export const Provider: React.FC = (props) => { // eslint-disable-next-line @typescript-eslint/no-explicit-any const nativeRef = useRef(null); + // Store registered shapes for event handling (future implementation) + // eslint-disable-next-line @typescript-eslint/no-unused-vars + const eventShapes = useRef; parent?: Group }>>(new Map()); + const [state, set] = useState<{ two: typeof two; parent: typeof parent; width: number; height: number; + registerEventShape: ( + shape: Shape | Group, + handlers: Partial, + parent?: Group + ) => void; + unregisterEventShape: (shape: Shape | Group) => void; }>({ two, parent, width: 0, height: 0, + registerEventShape: () => {}, + unregisterEventShape: () => {}, }); + // Register a shape with event handlers + const registerEventShape = useCallback( + ( + shape: Shape | Group, + handlers: Partial, + parentGroup?: Group + ) => { + eventShapes.current.set(shape, { shape, handlers, parent: parentGroup }); + }, + [] + ); + + // Unregister a shape + const unregisterEventShape = useCallback((shape: Shape | Group) => { + eventShapes.current.delete(shape); + }, []); + // eslint-disable-next-line react-hooks/exhaustive-deps useEffect(mount, [props]); @@ -52,13 +84,11 @@ export const Provider: React.FC = (props) => { delete args.children; delete args.style; - // Initialize Two.js in headless mode or with a dummy element - // We primarily use it for the scene graph and math + // Initialize Two.js in headless mode const two = new Two({ ...args, - type: Two.Types.canvas, // Default to canvas type for internal logic - // We might need to patch the renderer to not try to append to DOM - domElement: { // Mock DOM element to satisfy Two.js + type: Two.Types.canvas, + domElement: { style: {}, addEventListener: () => {}, removeEventListener: () => {}, @@ -68,7 +98,16 @@ export const Provider: React.FC = (props) => { let width = two.width; let height = two.height; - set({ two, parent: two.scene, width, height }); + set((prev) => ({ + ...prev, + two, + parent: two.scene, + width, + height, + registerEventShape, + unregisterEventShape, + })); + two.bind('update', update); unmount = () => { @@ -93,12 +132,11 @@ export const Provider: React.FC = (props) => { } // SERIALIZATION BRIDGE - // This is where we send the scene graph to the native side if (nativeRef.current) { - // TODO: Implement efficient serialization - // For now, we send a basic signal or simplified graph - // const commands = serialize(two.scene); - // nativeRef.current.setNativeProps({ drawCommands: commands }); + // Simple serialization of the scene graph + // We only serialize what's necessary for the native renderer + const commands = serializeScene(two.scene); + nativeRef.current.setNativeProps({ drawCommands: commands }); } } } @@ -114,3 +152,52 @@ export const Provider: React.FC = (props) => { ); }; + +// Helper to serialize the scene graph +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function serializeScene(scene: Group): any { + // This is a simplified serializer. + // In a real app, you'd want to optimize this to only send diffs or use a binary format. + return { + id: scene.id, + children: scene.children.map((childNode) => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const child = childNode as any; + const base = { + id: child.id, + type: child.constructor.name, + translation: { x: child.translation.x, y: child.translation.y }, + rotation: child.rotation, + scale: typeof child.scale === 'number' ? { x: child.scale, y: child.scale } : { x: child.scale.x, y: child.scale.y }, + opacity: child.opacity, + visible: child.visible, + }; + + if (child instanceof Two.Group) { + return { + ...base, + // eslint-disable-next-line @typescript-eslint/no-explicit-any + children: (child as any).children.map((c: any) => serializeScene(c)), // Recursive for groups + }; + } else { + // Shape specific properties + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const shape = child as any; + return { + ...base, + fill: shape.fill, + stroke: shape.stroke, + linewidth: shape.linewidth, + // Add other shape-specific props here (radius, width, height, vertices, etc.) + // For the demo, we assume basic shapes + ...(shape.radius && { radius: shape.radius }), + ...(shape.width && { width: shape.width }), + ...(shape.height && { height: shape.height }), + ...(shape.vertices && { + vertices: shape.vertices.map((v: any) => ({ x: v.x, y: v.y, command: v.command })) + }), + }; + } + }), + }; +} From 129198715aaaf48b65ac58c0c07cb32fb319c0dd Mon Sep 17 00:00:00 2001 From: Jono Brandel Date: Fri, 5 Dec 2025 23:16:48 -0800 Subject: [PATCH 4/5] [WIP] Add Objective-C TwoViewManager Added TwoViewManager.m for React Native integration and updated Package.swift to include new source files. Enhanced TwoRenderer.swift to support hierarchical 2D transforms (translation, rotation, scale) and hex color parsing for shapes. --- Package.swift | 6 ++- ios/TwoRenderer.swift | 103 ++++++++++++++++++++++++++++++++++++------ ios/TwoViewManager.m | 7 +++ 3 files changed, 100 insertions(+), 16 deletions(-) create mode 100644 ios/TwoViewManager.m diff --git a/Package.swift b/Package.swift index 11cb067..0528c78 100644 --- a/Package.swift +++ b/Package.swift @@ -24,9 +24,11 @@ let package = Package( path: "ios", exclude: [], sources: [ - "TwoViewManager.swift", + "TwoViewManager.swift", + "TwoViewManager.m", "TwoMetalView.swift", - "TwoRenderer.swift" + "TwoRenderer.swift", + "Shaders.metal" // Also add the shaders! ] ) ] diff --git a/ios/TwoRenderer.swift b/ios/TwoRenderer.swift index ba24cb5..78601e4 100644 --- a/ios/TwoRenderer.swift +++ b/ios/TwoRenderer.swift @@ -53,53 +53,128 @@ class TwoRenderer: NSObject, MTKViewDelegate { func updateDrawCommands(_ commands: [String: Any]) { // Parse commands and generate vertices - // This is a simplified parser var newVertices: [Vertex] = [] + // Start with identity matrix + let identity = matrix_float3x3(diagonal: SIMD3(1, 1, 1)) + if let children = commands["children"] as? [[String: Any]] { for child in children { - parseShape(child, into: &newVertices) + parseShape(child, transform: identity, into: &newVertices) } } self.vertices = newVertices } - private func parseShape(_ shape: [String: Any], into vertices: inout [Vertex]) { + private func parseShape(_ shape: [String: Any], transform: matrix_float3x3, into vertices: inout [Vertex]) { + // Calculate local transform + // We expect translation, rotation, scale in the JSON + // Default to identity values if missing + + let tx = Float((shape["translation"] as? [String: Double])?["x"] ?? 0) + let ty = Float((shape["translation"] as? [String: Double])?["y"] ?? 0) + let rotation = Float(shape["rotation"] as? Double ?? 0) + let sx = Float((shape["scale"] as? [String: Double])?["x"] ?? 1) + let sy = Float((shape["scale"] as? [String: Double])?["y"] ?? 1) + + // Construct local matrix + // 2D Affine Transform in 3x3 matrices: + // [ cx -sy tx ] + // [ sx cy ty ] + // [ 0 0 1 ] + // But SIMD matches column-major usually. + // Let's build T * R * S + + var matrix = matrix_identity_float3x3 + + // Translation + let T = matrix_float3x3(columns: ( + SIMD3(1, 0, 0), + SIMD3(0, 1, 0), + SIMD3(tx, ty, 1) + )) + + // Rotation + let c = cos(rotation) + let s = sin(rotation) + let R = matrix_float3x3(columns: ( + SIMD3(c, s, 0), + SIMD3(-s, c, 0), + SIMD3(0, 0, 1) + )) + + // Scale + let S = matrix_float3x3(columns: ( + SIMD3(sx, 0, 0), + SIMD3(0, sy, 0), + SIMD3(0, 0, 1) + )) + + // Combine: Parent * T * R * S + // Note: matrix_float3x3 multiplication order depends on vector convention. + // If we use v * M, then M = S * R * T * Parent? + // Let's assume M * v, so M = Parent * T * R * S + matrix = matrix_multiply(T, matrix_multiply(R, S)) + let globalTransform = matrix_multiply(transform, matrix) + // Check if it's a group if let children = shape["children"] as? [[String: Any]] { for child in children { - parseShape(child, into: &vertices) + parseShape(child, transform: globalTransform, into: &vertices) } return } // It's a shape - guard let shapeVertices = shape["vertices"] as? [[String: Any]], - let translation = shape["translation"] as? [String: Double] else { + guard let shapeVertices = shape["vertices"] as? [[String: Any]] else { return } - let tx = Float(translation["x"] ?? 0) - let ty = Float(translation["y"] ?? 0) + // Parse color + var color = SIMD4(0, 0, 0, 1) // Default black - // Parse color (simplified, assumes hex or basic support needed) - // For now default to red - let color = SIMD4(1, 0, 0, 1) + if let fill = shape["fill"] as? String { + color = parseHexColor(fill) + } // Convert shape vertices to Metal vertices - // This assumes TRIANGLES for now, but Two.js paths might be complex. - // A real implementation needs a tessellator. for v in shapeVertices { if let x = v["x"] as? Double, let y = v["y"] as? Double { + let localPos = SIMD3(Float(x), Float(y), 1) + // Apply transform + let worldPos = matrix_multiply(globalTransform, localPos) + vertices.append(Vertex( - position: SIMD2(Float(x) + tx, Float(y) + ty), + position: SIMD2(worldPos.x, worldPos.y), color: color )) } } } + private func parseHexColor(_ hex: String) -> SIMD4 { + var cString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() + + if cString.hasPrefix("#") { + cString.remove(at: cString.startIndex) + } + + if cString.count != 6 { + return SIMD4(0.5, 0.5, 0.5, 1) // Gray fallback + } + + var rgbValue: UInt64 = 0 + Scanner(string: cString).scanHexInt64(&rgbValue) + + return SIMD4( + Float((rgbValue & 0xFF0000) >> 16) / 255.0, + Float((rgbValue & 0x00FF00) >> 8) / 255.0, + Float(rgbValue & 0x0000FF) / 255.0, + 1.0 + ) + } + func mtkView(_ view: MTKView, drawableSizeWillChange size: CGSize) { viewportSize = SIMD2(Float(size.width), Float(size.height)) } diff --git a/ios/TwoViewManager.m b/ios/TwoViewManager.m new file mode 100644 index 0000000..73bfbbc --- /dev/null +++ b/ios/TwoViewManager.m @@ -0,0 +1,7 @@ +#import + +@interface RCT_EXTERN_MODULE(TwoViewManager, RCTViewManager) + +RCT_EXPORT_VIEW_PROPERTY(drawCommands, NSDictionary) + +@end From bfb0f1c2b7a5e911654573942b013d0fcd6b70e5 Mon Sep 17 00:00:00 2001 From: Jono Brandel Date: Tue, 3 Feb 2026 15:34:52 -0800 Subject: [PATCH 5/5] Add draw call handling and richer scene serialization iOS renderer: introduce DrawCall and drawCalls array, replace parseShape with parseNode to handle groups, transforms, fills (fan triangulation), strokes (lineStrip), and per-shape opacity; expand parseHexColor to support 3-digit hex and opacity; use drawCalls to issue Metal drawPrimitives instead of a single draw. JS provider: replace serializeScene with recursive serializeNode, include more shape properties (cap, join, miter, closed, curved, automatic, beginning, ending, linewidth), serialize vertex control points, and serialize specific shape attributes (radius, width, height) to provide richer data for the native renderer. --- ios/TwoRenderer.swift | 118 +++++++++++++++++++++++++++------------- lib/native/Provider.tsx | 99 +++++++++++++++++++-------------- 2 files changed, 137 insertions(+), 80 deletions(-) diff --git a/ios/TwoRenderer.swift b/ios/TwoRenderer.swift index 78601e4..9b772e5 100644 --- a/ios/TwoRenderer.swift +++ b/ios/TwoRenderer.swift @@ -5,6 +5,12 @@ struct Vertex { var color: SIMD4 } +struct DrawCall { + var type: MTLPrimitiveType + var vertexStart: Int + var vertexCount: Int +} + class TwoRenderer: NSObject, MTKViewDelegate { let device: MTLDevice @@ -14,6 +20,7 @@ class TwoRenderer: NSObject, MTKViewDelegate { // Data to render var vertices: [Vertex] = [] + var drawCalls: [DrawCall] = [] init(device: MTLDevice, view: MTKView) { self.device = device @@ -52,31 +59,32 @@ class TwoRenderer: NSObject, MTKViewDelegate { } func updateDrawCommands(_ commands: [String: Any]) { - // Parse commands and generate vertices var newVertices: [Vertex] = [] + var newDrawCalls: [DrawCall] = [] // Start with identity matrix let identity = matrix_float3x3(diagonal: SIMD3(1, 1, 1)) if let children = commands["children"] as? [[String: Any]] { for child in children { - parseShape(child, transform: identity, into: &newVertices) + parseNode(child, transform: identity, vertices: &newVertices, drawCalls: &newDrawCalls) } } self.vertices = newVertices + self.drawCalls = newDrawCalls } - private func parseShape(_ shape: [String: Any], transform: matrix_float3x3, into vertices: inout [Vertex]) { + private func parseNode(_ node: [String: Any], transform: matrix_float3x3, vertices: inout [Vertex], drawCalls: inout [DrawCall]) { // Calculate local transform // We expect translation, rotation, scale in the JSON // Default to identity values if missing - let tx = Float((shape["translation"] as? [String: Double])?["x"] ?? 0) - let ty = Float((shape["translation"] as? [String: Double])?["y"] ?? 0) - let rotation = Float(shape["rotation"] as? Double ?? 0) - let sx = Float((shape["scale"] as? [String: Double])?["x"] ?? 1) - let sy = Float((shape["scale"] as? [String: Double])?["y"] ?? 1) + let tx = Float((node["translation"] as? [String: Any])?["x"] as? Double ?? 0) + let ty = Float((node["translation"] as? [String: Any])?["y"] as? Double ?? 0) + let rotation = Float(node["rotation"] as? Double ?? 0) + let sx = Float((node["scale"] as? [String: Any])?["x"] as? Double ?? 1) + let sy = Float((node["scale"] as? [String: Any])?["y"] as? Double ?? 1) // Construct local matrix // 2D Affine Transform in 3x3 matrices: @@ -86,8 +94,6 @@ class TwoRenderer: NSObject, MTKViewDelegate { // But SIMD matches column-major usually. // Let's build T * R * S - var matrix = matrix_identity_float3x3 - // Translation let T = matrix_float3x3(columns: ( SIMD3(1, 0, 0), @@ -115,53 +121,89 @@ class TwoRenderer: NSObject, MTKViewDelegate { // Note: matrix_float3x3 multiplication order depends on vector convention. // If we use v * M, then M = S * R * T * Parent? // Let's assume M * v, so M = Parent * T * R * S - matrix = matrix_multiply(T, matrix_multiply(R, S)) - let globalTransform = matrix_multiply(transform, matrix) + let localTransform = matrix_multiply(T, matrix_multiply(R, S)) + let globalTransform = matrix_multiply(transform, localTransform) // Check if it's a group - if let children = shape["children"] as? [[String: Any]] { + if let children = node["children"] as? [[String: Any]] { for child in children { - parseShape(child, transform: globalTransform, into: &vertices) + parseNode(child, transform: globalTransform, vertices: &vertices, drawCalls: &drawCalls) } return } - // It's a shape - guard let shapeVertices = shape["vertices"] as? [[String: Any]] else { + // Handle Shape + guard let rawVertices = node["vertices"] as? [[String: Any]], !rawVertices.isEmpty else { return } - // Parse color - var color = SIMD4(0, 0, 0, 1) // Default black + let worldPoints = rawVertices.compactMap { v -> SIMD2? in + guard let x = v["x"] as? Double, let y = v["y"] as? Double else { return nil } + let localPos = SIMD3(Float(x), Float(y), 1) + let worldPos = matrix_multiply(globalTransform, localPos) + return SIMD2(worldPos.x, worldPos.y) + } - if let fill = shape["fill"] as? String { - color = parseHexColor(fill) + guard worldPoints.count >= 2 else { return } + + // 1. Fill + if let fill = node["fill"] as? String, fill != "none" { + let fillColor = parseHexColor(fill, opacity: Float(node["opacity"] as? Double ?? 1.0)) + let startIdx = vertices.count + + // Simple Fan Triangulation (assumes convex) + for i in 1.. 0 { + drawCalls.append(DrawCall(type: .triangle, vertexStart: startIdx, vertexCount: count)) + } } - // Convert shape vertices to Metal vertices - for v in shapeVertices { - if let x = v["x"] as? Double, let y = v["y"] as? Double { - let localPos = SIMD3(Float(x), Float(y), 1) - // Apply transform - let worldPos = matrix_multiply(globalTransform, localPos) - - vertices.append(Vertex( - position: SIMD2(worldPos.x, worldPos.y), - color: color - )) + // 2. Stroke + if let stroke = node["stroke"] as? String, stroke != "none" { + let strokeColor = parseHexColor(stroke, opacity: Float(node["opacity"] as? Double ?? 1.0)) + let startIdx = vertices.count + + for p in worldPoints { + vertices.append(Vertex(position: p, color: strokeColor)) + } + + if let closed = node["closed"] as? Bool, closed { + vertices.append(Vertex(position: worldPoints[0], color: strokeColor)) + } + + let count = vertices.count - startIdx + if count > 0 { + // Use lineStrip for now. + // For proper thickness, we'd need to generate triangles based on linewidth. + drawCalls.append(DrawCall(type: .lineStrip, vertexStart: startIdx, vertexCount: count)) } } } - private func parseHexColor(_ hex: String) -> SIMD4 { + private func parseHexColor(_ hex: String, opacity: Float = 1.0) -> SIMD4 { var cString = hex.trimmingCharacters(in: .whitespacesAndNewlines).uppercased() if cString.hasPrefix("#") { cString.remove(at: cString.startIndex) } + if cString.count == 3 { + var expanded = "" + for char in cString { + expanded.append(char) + expanded.append(char) + } + cString = expanded + } + if cString.count != 6 { - return SIMD4(0.5, 0.5, 0.5, 1) // Gray fallback + return SIMD4(0.5, 0.5, 0.5, opacity) // Gray fallback } var rgbValue: UInt64 = 0 @@ -171,7 +213,7 @@ class TwoRenderer: NSObject, MTKViewDelegate { Float((rgbValue & 0xFF0000) >> 16) / 255.0, Float((rgbValue & 0x00FF00) >> 8) / 255.0, Float(rgbValue & 0x0000FF) / 255.0, - 1.0 + opacity ) } @@ -200,10 +242,10 @@ class TwoRenderer: NSObject, MTKViewDelegate { // In a real app, use a MTLBuffer for better performance renderEncoder.setVertexBytes(vertices, length: vertices.count * MemoryLayout.stride, index: 0) - // Draw primitives - // Assuming TRIANGLE_STRIP or TRIANGLES based on data - // For this demo, we use POINTS or LINE_STRIP to see something - renderEncoder.drawPrimitives(type: .triangleStrip, vertexStart: 0, vertexCount: vertices.count) + // Execute draw calls + for call in drawCalls { + renderEncoder.drawPrimitives(type: call.type, vertexStart: call.vertexStart, vertexCount: call.vertexCount) + } renderEncoder.endEncoding() commandBuffer.present(drawable) diff --git a/lib/native/Provider.tsx b/lib/native/Provider.tsx index 552ee26..6dfddcb 100644 --- a/lib/native/Provider.tsx +++ b/lib/native/Provider.tsx @@ -156,48 +156,63 @@ export const Provider: React.FC = (props) => { // Helper to serialize the scene graph // eslint-disable-next-line @typescript-eslint/no-explicit-any function serializeScene(scene: Group): any { - // This is a simplified serializer. - // In a real app, you'd want to optimize this to only send diffs or use a binary format. - return { - id: scene.id, - children: scene.children.map((childNode) => { - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const child = childNode as any; - const base = { - id: child.id, - type: child.constructor.name, - translation: { x: child.translation.x, y: child.translation.y }, - rotation: child.rotation, - scale: typeof child.scale === 'number' ? { x: child.scale, y: child.scale } : { x: child.scale.x, y: child.scale.y }, - opacity: child.opacity, - visible: child.visible, - }; + return serializeNode(scene); +} - if (child instanceof Two.Group) { - return { - ...base, - // eslint-disable-next-line @typescript-eslint/no-explicit-any - children: (child as any).children.map((c: any) => serializeScene(c)), // Recursive for groups - }; - } else { - // Shape specific properties - // eslint-disable-next-line @typescript-eslint/no-explicit-any - const shape = child as any; - return { - ...base, - fill: shape.fill, - stroke: shape.stroke, - linewidth: shape.linewidth, - // Add other shape-specific props here (radius, width, height, vertices, etc.) - // For the demo, we assume basic shapes - ...(shape.radius && { radius: shape.radius }), - ...(shape.width && { width: shape.width }), - ...(shape.height && { height: shape.height }), - ...(shape.vertices && { - vertices: shape.vertices.map((v: any) => ({ x: v.x, y: v.y, command: v.command })) - }), - }; - } - }), +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function serializeNode(node: any): any { + if (!node) return null; + + const base: any = { + id: node.id, + type: node.constructor.name, + translation: { x: node.translation.x, y: node.translation.y }, + rotation: node.rotation, + scale: typeof node.scale === 'number' ? { x: node.scale, y: node.scale } : { x: node.scale.x, y: node.scale.y }, + opacity: node.opacity, + visible: node.visible, }; + + if (node instanceof Two.Group) { + return { + ...base, + children: node.children.map((child: any) => serializeNode(child)), + }; + } else { + // Shape specific properties + const shape = node as any; + const serialized: any = { + ...base, + fill: typeof shape.fill === 'string' ? shape.fill : undefined, + stroke: typeof shape.stroke === 'string' ? shape.stroke : undefined, + linewidth: shape.linewidth, + cap: shape.cap, + join: shape.join, + miter: shape.miter, + closed: shape.closed, + curved: shape.curved, + automatic: shape.automatic, + beginning: shape.beginning, + ending: shape.ending, + }; + + if (shape.vertices && Array.isArray(shape.vertices)) { + serialized.vertices = shape.vertices.map((v: any) => ({ + x: v.x, + y: v.y, + command: v.command, + controls: v.controls ? { + left: v.controls.left ? { x: v.controls.left.x, y: v.controls.left.y } : undefined, + right: v.controls.right ? { x: v.controls.right.x, y: v.controls.right.y } : undefined, + } : undefined + })); + } + + // Handle specific shape types if they have extra properties + if (shape.radius) serialized.radius = shape.radius; + if (shape.width) serialized.width = shape.width; + if (shape.height) serialized.height = shape.height; + + return serialized; + } }