├── .eslintrc ├── .github ├── FUNDING.yml ├── dark.svg ├── light.svg └── workflows │ └── CI.yml ├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── examples ├── index.html ├── src │ ├── components │ │ └── Controls.tsx │ ├── cubes.tsx │ ├── draw-modes.tsx │ └── primitives.tsx └── tsconfig.json ├── jest.config.js ├── package.json ├── src ├── Canvas.native.tsx ├── Canvas.tsx ├── constants.ts ├── events.native.ts ├── events.ts ├── hooks.ts ├── index.native.ts ├── index.ts ├── reconciler.ts ├── renderer.tsx ├── types.ts └── utils.ts ├── tests ├── __snapshots__ │ ├── native.test.tsx.snap │ ├── utils.test.tsx.snap │ └── web.test.tsx.snap ├── events.test.tsx ├── hooks.test.tsx ├── index.test.tsx ├── native.test.tsx ├── utils.test.tsx ├── utils │ ├── WebGLRenderingContext.ts │ ├── index.ts │ └── setupTests.ts └── web.test.tsx ├── tsconfig.json ├── vite.config.js └── yarn.lock /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true, 6 | "jest": true 7 | }, 8 | "plugins": ["@typescript-eslint", "react", "react-hooks", "import"], 9 | "extends": [ 10 | "eslint:recommended", 11 | "plugin:react/recommended", 12 | "plugin:react-hooks/recommended", 13 | "plugin:@typescript-eslint/recommended" 14 | ], 15 | "parser": "@typescript-eslint/parser", 16 | "parserOptions": { 17 | "ecmaFeatures": { 18 | "jsx": true 19 | }, 20 | "ecmaVersion": 2020, 21 | "sourceType": "module", 22 | "project": "./tsconfig.json" 23 | }, 24 | "settings": { 25 | "react": { 26 | "version": "detect" 27 | } 28 | }, 29 | "rules": { 30 | "react/display-name": "off", 31 | "react/prop-types": "off", 32 | "no-inner-declarations": "off", 33 | "no-console": "off", 34 | "no-empty-pattern": "warn", 35 | "no-duplicate-imports": "error", 36 | "import/no-unresolved": "off", 37 | "import/export": "error", 38 | "import/named": "off", 39 | "import/namespace": "off", 40 | "import/default": "off", 41 | "no-unused-vars": "off", 42 | "@typescript-eslint/no-unused-vars": ["warn", { "argsIgnorePattern": "^_", "varsIgnorePattern": "^_" }], 43 | "@typescript-eslint/no-use-before-define": "off", 44 | "@typescript-eslint/no-empty-function": "off", 45 | "@typescript-eslint/no-empty-interface": "off", 46 | "@typescript-eslint/no-explicit-any": "off", 47 | "@typescript-eslint/ban-types": "off", 48 | "@typescript-eslint/ban-ts-comment": "off", 49 | "@typescript-eslint/no-non-null-assertion": "off", 50 | "@typescript-eslint/no-namespace": "off", 51 | "@typescript-eslint/no-extra-semi": "off", 52 | "@typescript-eslint/no-inferrable-types": "warn" 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [CodyJasonBennett] 2 | -------------------------------------------------------------------------------- /.github/workflows/CI.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: 3 | push: 4 | branches: [main] 5 | pull_request: 6 | branches: [main] 7 | 8 | jobs: 9 | build-test-lint: 10 | runs-on: ubuntu-latest 11 | steps: 12 | - uses: actions/checkout@v2 13 | - uses: actions/setup-node@v2 14 | 15 | - name: Install dependencies 16 | run: yarn install 17 | 18 | - name: Check build health 19 | run: yarn build 20 | 21 | - name: Run tests 22 | run: yarn test --silent 23 | 24 | - name: Check for regressions 25 | run: yarn lint 26 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | .npm 3 | .eslintcache 4 | node_modules 5 | coverage 6 | dist 7 | *.log 8 | *-lock.json 9 | 10 | # Editor directories and files 11 | .idea 12 | .vscode 13 | *.suo 14 | *.ntvs* 15 | *.njsproj 16 | *.sln 17 | *.sw? -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "semi": false, 3 | "trailingComma": "all", 4 | "arrowParens": "always", 5 | "singleQuote": true, 6 | "tabWidth": 2, 7 | "printWidth": 120 8 | } 9 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2021-2025 Poimandres 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | [![Size](https://img.shields.io/bundlephobia/minzip/react-ogl?label=gzip&style=flat&colorA=000000&colorB=000000)](https://bundlephobia.com/package/react-ogl) 2 | [![Version](https://img.shields.io/npm/v/react-ogl?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-ogl) 3 | [![Downloads](https://img.shields.io/npm/dt/react-ogl.svg?style=flat&colorA=000000&colorB=000000)](https://npmjs.com/package/react-ogl) 4 | [![Twitter](https://img.shields.io/twitter/follow/pmndrs?label=%40pmndrs&style=flat&colorA=000000&colorB=000000&logo=twitter&logoColor=000000)](https://twitter.com/pmndrs) 5 | [![Discord](https://img.shields.io/discord/740090768164651008?style=flat&colorA=000000&colorB=000000&label=discord&logo=discord&logoColor=000000)](https://discord.gg/poimandres) 6 | 7 |

8 | 9 | 10 | 11 | Build OGL scenes declaratively with re-usable, self-contained components that react to state, are readily interactive and can participate in React's ecosystem.

react-ogl is a barebones react renderer for OGL with an emphasis on minimalism and modularity. Its reconciler simply expresses JSX as OGL elements — <mesh /> becomes new OGL.Mesh(). This happens dynamically; there's no wrapper involved. 12 | 13 | 14 |

15 | 16 | ## Table of Contents 17 | 18 | - [Installation](#installation) 19 | - [Getting Started](#getting-started) 20 | - [react-dom](#react-dom) 21 | - [react-native](#react-native) 22 | - [Canvas](#canvas) 23 | - [Canvas Props](#canvas-props) 24 | - [Custom Canvas](#custom-canvas) 25 | - [Creating Elements](#creating-elements) 26 | - [JSX, properties, and shortcuts](#jsx-properties-and-shortcuts) 27 | - [Setting constructor arguments via `args`](#setting-constructor-arguments-via-args) 28 | - [Attaching into element properties via `attach`](#attaching-into-element-properties-via-attach) 29 | - [Creating custom elements via `extend`](#creating-custom-elements-via-extend) 30 | - [Adding third-party objects via ``](#adding-third-party-objects-via-primitive-) 31 | - [Hooks](#hooks) 32 | - [Root State](#root-state) 33 | - [Accessing state via `useOGL`](#accessing-state-via-useogl) 34 | - [Frameloop subscriptions via `useFrame`](#frameloop-subscriptions-via-useframe) 35 | - [Loading assets via `useLoader`](#loading-assets-via-useloader) 36 | - [Object traversal via `useGraph`](#object-traversal-via-usegraph) 37 | - [Transient updates via `useStore`](#transient-updates-via-usestore) 38 | - [Access internals via `useInstanceHandle`](#access-internals-via-useinstancehandle) 39 | - [Events](#events) 40 | - [Custom Events](#custom-events) 41 | - [Portals](#portals) 42 | - [Testing](#testing) 43 | 44 | ## Installation 45 | 46 | ```bash 47 | # NPM 48 | npm install ogl react-ogl 49 | 50 | # Yarn 51 | yarn add ogl react-ogl 52 | 53 | # PNPM 54 | pnpm add ogl react-ogl 55 | ``` 56 | 57 | ## Getting Started 58 | 59 | react-ogl itself is super minimal, but you can use the familiar [@react-three/fiber](https://github.com/pmndrs/react-three-fiber) API with some helpers targeted for different platforms: 60 | 61 | ### react-dom 62 | 63 | This example uses [`create-react-app`](https://reactjs.org/docs/create-a-new-react-app.html#create-react-app) for the sake of simplicity, but you can use your own environment or [create a codesandbox](https://react.new). 64 | 65 |
66 | Show full example 67 | 68 |
69 | 70 | ```bash 71 | # Create app 72 | npx create-react-app my-app 73 | cd my-app 74 | 75 | # Install dependencies 76 | npm install ogl react-ogl 77 | 78 | # Start 79 | npm run start 80 | ``` 81 | 82 | The following creates a re-usable component that has its own state, reacts to events and participates a shared render-loop. 83 | 84 | ```jsx 85 | import * as React from 'react' 86 | import { useFrame, Canvas } from 'react-ogl' 87 | import { createRoot } from 'react-dom/client' 88 | 89 | function Box(props) { 90 | // This reference will give us direct access to the mesh 91 | const mesh = React.useRef() 92 | // Set up state for the hovered and active state 93 | const [hovered, setHover] = React.useState(false) 94 | const [active, setActive] = React.useState(false) 95 | // Subscribe this component to the render-loop, rotate the mesh every frame 96 | useFrame(() => (mesh.current.rotation.x += 0.01)) 97 | // Return view, these are regular OGL elements expressed in JSX 98 | return ( 99 | setActive((value) => !value)} 104 | onPointerOver={() => setHover(true)} 105 | onPointerOut={() => setHover(false)} 106 | > 107 | 108 | 140 | 141 | ) 142 | } 143 | 144 | createRoot(document.getElementById('root')).render( 145 | 146 | 147 | 148 | , 149 | ) 150 | ``` 151 | 152 |
153 | 154 | ### react-native 155 | 156 | This example uses [`expo-cli`](https://docs.expo.dev/get-started/create-a-new-app) but you can create a bare app with `react-native` CLI as well. 157 | 158 |
159 | Show full example 160 | 161 |
162 | 163 | ```bash 164 | # Create app and cd into it 165 | npx expo init my-app # or npx react-native init my-app 166 | cd my-app 167 | 168 | # Automatically install & link expo modules 169 | npx install-expo-modules@latest 170 | expo install expo-gl 171 | 172 | # Install NPM dependencies 173 | npm install ogl react-ogl 174 | 175 | # Start 176 | npm run start 177 | ``` 178 | 179 | We'll also need to configure `metro.config.js` to look for the mjs file extension that OGL uses. 180 | 181 | ```js 182 | module.exports = { 183 | resolver: { 184 | resolverMainFields: ['browser', 'exports', 'main'], // https://github.com/facebook/metro/issues/670 185 | sourceExts: ['json', 'js', 'jsx', 'ts', 'tsx', 'cjs', 'mjs'], 186 | assetExts: ['glb', 'gltf', 'png', 'jpg'], 187 | }, 188 | } 189 | ``` 190 | 191 | Inside of our app, you can use the same API as web while running on native OpenGL ES — no webview needed. 192 | 193 | ```js 194 | import * as React from 'react' 195 | import { useFrame, Canvas } from 'react-ogl' 196 | 197 | function Box(props) { 198 | // This reference will give us direct access to the mesh 199 | const mesh = React.useRef() 200 | // Set up state for the hovered and active state 201 | const [hovered, setHover] = React.useState(false) 202 | const [active, setActive] = React.useState(false) 203 | // Subscribe this component to the render-loop, rotate the mesh every frame 204 | useFrame(() => (mesh.current.rotation.x += 0.01)) 205 | // Return view, these are regular OGL elements expressed in JSX 206 | return ( 207 | setActive((value) => !value)} 212 | onPointerOver={() => setHover(true)} 213 | onPointerOut={() => setHover(false)} 214 | > 215 | 216 | 248 | 249 | ) 250 | } 251 | 252 | export default () => ( 253 | 254 | 255 | 256 | 257 | ) 258 | ``` 259 | 260 |
261 | 262 | ## Canvas 263 | 264 | react-ogl provides an x-platform `` component for web and native that serves as the entrypoint for your OGL scenes. It is a real DOM canvas or native view that accepts OGL elements as children (see [creating elements](#creating-elements)). 265 | 266 | ### Canvas Props 267 | 268 | In addition to its platform props, `` accepts a set of `RenderProps` to configure react-ogl and its rendering behavior. 269 | 270 | ```tsx 271 | new Renderer(canvas) | renderer | { ...params, ...props }} 278 | // Sets the renderer pixel ratio from a clamped range or value. Default is `[1, 2]` 279 | dpr={[min, max] | value} 280 | // Sets or configures the default camera. 281 | // Accepts an external camera, or camera constructor params/properties. 282 | // Defaults to `new OGL.Camera(gl, { fov: 75, near: 1, far: 1000 })` with position-z `5` 283 | camera={camera | { ...params, ...props }} 284 | // Enables orthographic projection when using OGL's built-in camera. Default is `false` 285 | orthographic={true | false} 286 | // Defaults to `always` 287 | frameloop={'always' | 'never'} 288 | // An optional callback invoked after canvas creation and before commit. 289 | onCreated={(state: RootState) => void} 290 | // Optionally configures custom events. Defaults to built-in events exported as `events` 291 | events={EventManager | undefined} 292 | > 293 | {/* Accepts OGL elements as children */} 294 | 295 | 296 | 297 | // e.g. 298 | 299 | void state.gl.clearColor(1, 1, 1, 0)} 303 | > 304 | 305 | 306 | ``` 307 | 308 | ### Custom Canvas 309 | 310 | A react 18 style `createRoot` API creates an imperative `Root` with the same options as ``, but you're responsible for updating it and configuring things like events (see [events](#events)). This root attaches to an `HTMLCanvasElement` and renders OGL elements into a scene. Useful for creating an entrypoint with react-ogl and for headless contexts like a server or testing (see [testing](#testing)). 311 | 312 | ```jsx 313 | import { createRoot, events } from 'react-ogl' 314 | 315 | const canvas = document.querySelector('canvas') 316 | const root = createRoot(canvas, { events }) 317 | root.render( 318 | 319 | 320 | 321 | , 322 | ) 323 | root.unmount() 324 | ``` 325 | 326 | `createRoot` can also be used to create a custom ``. The following constructs a custom canvas that renders its children into react-ogl. 327 | 328 | ```jsx 329 | import * as React from 'react' 330 | import { createRoot, events } from 'react-ogl' 331 | 332 | function CustomCanvas({ children }) { 333 | // Init root from canvas 334 | const [canvas, setCanvas] = React.useState() 335 | const root = React.useMemo(() => canvas && createRoot(canvas, { events }), [canvas]) 336 | // Render children as a render-effect 337 | root?.render(children) 338 | // Cleanup on unmount 339 | React.useEffect(() => () => root?.unmount(), [root]) 340 | // Use callback-style ref to access canvas in render 341 | return 342 | } 343 | ``` 344 | 345 | ## Creating elements 346 | 347 | react-ogl renders React components into an OGL scene-graph, and can be used on top of other renderers like [react-dom](https://npmjs.com/react-dom) and [react-native](https://npmjs.com/react-native) that render for web and native, respectively. react-ogl components are defined by primitives or lower-case elements native to the OGL namespace (for custom elements, see [extend](#creating-custom-elements-via-extend)). 348 | 349 | ```jsx 350 | function Component(props) { 351 | return ( 352 | 353 | 354 | 355 | 356 | ) 357 | } 358 | 359 | ; 360 | 361 | 362 | ``` 363 | 364 | These elements are not exported or implemented internally, but merely expressed as JSX — `` becomes `new OGL.Mesh()`. This happens dynamically; there's no wrapper involved. 365 | 366 | ### JSX, properties, and shortcuts 367 | 368 | react-ogl elements can be modified with JSX attributes or props. These are native to their underlying OGL objects. 369 | 370 | ```jsx 371 | 391 | ``` 392 | 393 | ### Setting constructor arguments via `args` 394 | 395 | An array of constructor arguments (`args`) can be passed to instantiate elements' underlying OGL objects. Changing `args` will reconstruct the object and update any associated refs. 396 | 397 | ```jsx 398 | // new OGL.Text({ font, text: 'Text' }) 399 | 400 | ``` 401 | 402 | Built-in elements that require a `gl` context such as ``, ``, or `` are marked as effectful (see [extend](#creating-custom-elements-via-extend)) and do not require an `OGLRenderingContext` to be passed via `args`. They can be constructed mutably and manipulated via props: 403 | 404 | ```jsx 405 | 406 | 407 | 408 | 409 | ``` 410 | 411 | `` and `` also accept attributes and shader sources as props, which are passed to their respective constructors. This does not affect other properties like `drawRange` or `uniforms`. 412 | 413 | ```jsx 414 | 415 | 420 | {/* prettier-ignore */} 421 | 426 | 427 | ``` 428 | 429 | ### Attaching into element properties via `attach` 430 | 431 | Some elements do not follow the traditional scene-graph and need to be added by other means. For this, the `attach` prop can describe where an element is added via a property or a callback to add & remove the element. 432 | 433 | ```jsx 434 | // Attaches into parent.property, parent.sub.property, and parent.array[0] 435 | 436 | 437 | 438 | 439 | 440 | 441 | // Attaches via parent#setProperty and parent#removeProperty 442 | 443 | { 445 | parent.setProperty(self) 446 | return () => parent.removeProperty(self) 447 | }} 448 | // lambda version 449 | attach={(parent, self) => (parent.setProperty(self), () => parent.removeProperty(self))} 450 | /> 451 | 452 | ``` 453 | 454 | Elements who extend `OGL.Geometry` or `OGL.Program` will automatically attach via `attach="geometry"` and `attach="program"`, respectively. 455 | 456 | ```jsx 457 | 458 | 459 | 460 | 461 | ``` 462 | 463 | ### Creating custom elements via `extend` 464 | 465 | react-ogl tracks an internal catalog of constructable elements, defaulting to the OGL namespace. This catalog can be expanded via `extend` to declaratively use custom elements as native elements. 466 | 467 | ```jsx 468 | import { extend } from 'react-ogl' 469 | 470 | class CustomElement {} 471 | extend({ CustomElement }) 472 | 473 | 474 | ``` 475 | 476 | TypeScript users will need to extend the `OGLElements` interface to describe custom elements and their properties. 477 | 478 | ```tsx 479 | import { OGLElement, extend } from 'react-ogl' 480 | 481 | class CustomElement {} 482 | 483 | declare module 'react-ogl' { 484 | interface OGLElements { 485 | customElement: OGLElement 486 | } 487 | } 488 | 489 | extend({ CustomElement }) 490 | ``` 491 | 492 | Effectful elements that require a `gl` context can mark themselves as effectful and receive a `OGLRenderingContext` when constructed, making args mutable and enabling the use of props. This is done for OGL built-in elements like ``, ``, and ``. 493 | 494 | ```jsx 495 | import { extend } from 'react-ogl' 496 | 497 | class CustomElement { 498 | constructor(gl) { 499 | this.gl = gl 500 | } 501 | } 502 | extend({ CustomElement }, true) 503 | 504 | 505 | ``` 506 | 507 | ### Adding third-party objects via `` 508 | 509 | Objects created outside of React (e.g. globally or from a loader) can be added to the scene-graph with the `` element via its `object` prop. Primitives can be interacted with like any other element, but will modify `object` and cannot make use of `args`. 510 | 511 | ```jsx 512 | import * as OGL from 'ogl' 513 | 514 | const object = new OGL.Transform() 515 | 516 | 517 | ``` 518 | 519 | ## Hooks 520 | 521 | react-ogl ships with hooks that allow you to tie or request information to your components. These are called within the body of `` and contain imperative and possibly stateful code. 522 | 523 | ### Root State 524 | 525 | Each `` or `Root` encapsulates its own OGL state via [React context](https://reactjs.org/docs/context.html) and a [Zustand](https://github.com/pmndrs/zustand) store, as defined by `RootState`. This can be accessed and modified with the `onCreated` canvas prop, and with hooks like `useOGL`. 526 | 527 | ```tsx 528 | interface RootState { 529 | // Zustand setter and getter for live state manipulation. 530 | // See https://github.com/pmndrs/zustand 531 | get(): RootState 532 | set(fn: (previous: RootState) => (next: Partial)): void 533 | // Canvas layout information 534 | size: { width: number; height: number } 535 | // OGL scene internals 536 | renderer: OGL.Renderer 537 | gl: OGL.OGLRenderingContext 538 | scene: OGL.Transform 539 | camera: OGL.Camera 540 | // OGL perspective and frameloop preferences 541 | orthographic: boolean 542 | frameloop: 'always' | 'never' 543 | // Internal XR manager to enable WebXR features 544 | xr: XRManager 545 | // Frameloop internals for custom render loops 546 | priority: number 547 | subscribed: React.RefObject[] 548 | subscribe: (refCallback: React.RefObject, renderPriority?: number) => void 549 | unsubscribe: (refCallback: React.RefObject, renderPriority?: number) => void 550 | // Optional canvas event manager and its state 551 | events?: EventManager 552 | mouse: OGL.Vec2 553 | raycaster: OGL.Raycast 554 | hovered: Map['object']> 555 | } 556 | ``` 557 | 558 | ### Accessing state via `useOGL` 559 | 560 | Returns the current canvas' `RootState`, describing react-ogl state and OGL rendering internals (see [root state](#root-state)). 561 | 562 | ```tsx 563 | const { renderer, gl, scene, camera, ... } = useOGL() 564 | ``` 565 | 566 | To subscribe to a specific key, `useOGL` accepts a [Zustand](https://github.com/pmndrs/zustand) selector: 567 | 568 | ```tsx 569 | const renderer = useOGL((state) => state.renderer) 570 | ``` 571 | 572 | ### Frameloop subscriptions via `useFrame` 573 | 574 | Subscribes an element into a shared render loop outside of React. `useFrame` subscriptions are provided a live `RootState`, the current RaF time in seconds, and a `XRFrame` when in a WebXR session. Note: `useFrame` subscriptions should never update React state but prefer external mechanisms like refs. 575 | 576 | ```tsx 577 | const object = React.useRef(null!) 578 | 579 | useFrame((state: RootState, time: number, frame?: XRFrame) => { 580 | object.current.rotation.x = time / 2000 581 | object.current.rotation.y = time / 1000 582 | }) 583 | 584 | return 585 | ``` 586 | 587 | ### Loading assets via `useLoader` 588 | 589 | Synchronously loads and caches assets with a loader via suspense. Note: the caller component must be wrapped in `React.Suspense`. 590 | 591 | ```jsx 592 | const texture = useLoader(OGL.TextureLoader, '/path/to/image.jpg') 593 | ``` 594 | 595 | Multiple assets can be requested in parallel by passing an array: 596 | 597 | ```jsx 598 | const [texture1, texture2] = useLoader(OGL.TextureLoader, ['/path/to/image1.jpg', '/path/to/image2.jpg']) 599 | ``` 600 | 601 | Custom loaders can be implemented via the `LoaderRepresentation` signature: 602 | 603 | ```tsx 604 | class CustomLoader { 605 | async load(gl: OGLRenderingContext, url: string): Promise {} 606 | } 607 | 608 | const result = useLoader(CustomLoader, '/path/to/resource') 609 | ``` 610 | 611 | ### Object traversal via `useGraph` 612 | 613 | Traverses an `OGL.Transform` for unique meshes and programs, returning an `ObjectMap`. 614 | 615 | ```tsx 616 | const { nodes, programs } = useGraph(object) 617 | 618 | 619 | ``` 620 | 621 | ### Transient updates via `useStore` 622 | 623 | Returns the internal [Zustand](https://github.com/pmndrs/zustand) store. Useful for transient updates outside of React (e.g. multiplayer/networking). 624 | 625 | ```tsx 626 | const store = useStore() 627 | React.useLayoutEffect(() => store.subscribe(state => ...), [store]) 628 | ``` 629 | 630 | ### Access internals via `useInstanceHandle` 631 | 632 | Exposes an object's react-internal `Instance` state from a ref. 633 | 634 | > **Note**: this is an escape hatch to react-internal fields. Expect this to change significantly between versions. 635 | 636 | ```tsx 637 | const ref = React.useRef() 638 | const instance = useInstanceHandle(ref) 639 | 640 | React.useLayoutEffect(() => { 641 | instance.parent.object.foo() 642 | }, []) 643 | 644 | 645 | ``` 646 | 647 | ## Events 648 | 649 | react-ogl implements mesh pointer-events with `OGL.Raycast` that can be tapped into via the following props: 650 | 651 | ```tsx 652 | ) => ...} 655 | // Fired when a pointer becomes inactive over the mesh. 656 | onPointerUp={(event: OGLEvent) => ...} 657 | // Fired when a pointer becomes active over the mesh. 658 | onPointerDown={(event: OGLEvent) => ...} 659 | // Fired when a pointer moves over the mesh. 660 | onPointerMove={(event: OGLEvent) => ...} 661 | // Fired when a pointer enters the mesh's bounds. 662 | onPointerOver={(event: OGLEvent) => ...} 663 | // Fired when a pointer leaves the mesh's bounds. 664 | onPointerOut={(event: OGLEvent) => ...} 665 | /> 666 | ``` 667 | 668 | Events contain the original event as `nativeEvent` and properties from `OGL.RaycastHit`. 669 | 670 | ```tsx 671 | { 672 | nativeEvent: PointerEvent | MouseEvent, 673 | localPoint: Vec3, 674 | distance: number, 675 | point: Vec3, 676 | faceNormal: Vec3, 677 | localFaceNormal: Vec3, 678 | uv: Vec2, 679 | localNormal: Vec3, 680 | normal: Vec3, 681 | } 682 | ``` 683 | 684 | ### Custom events 685 | 686 | Custom events can be implemented per the `EventManager` interface and passed via the `events` Canvas prop. 687 | 688 | ```tsx 689 | const events: EventManager = { 690 | connected: false, 691 | connect(canvas: HTMLCanvasElement, state: RootState) { 692 | // Bind handlers 693 | }, 694 | disconnect(canvas: HTMLCanvasElement, state: RootState) { 695 | // Cleanup 696 | }, 697 | } 698 | 699 | 700 | ) => console.log(event)}> 701 | 702 | 703 | 704 | 705 | ``` 706 | 707 |
708 | Full example 709 | 710 | ```tsx 711 | const events = { 712 | connected: false, 713 | connect(canvas: HTMLCanvasElement, state: RootState) { 714 | state.events.handlers = { 715 | pointermove(event: PointerEvent) { 716 | // Convert mouse coordinates 717 | state.mouse.x = (event.offsetX / state.size.width) * 2 - 1 718 | state.mouse.y = -(event.offsetY / state.size.height) * 2 + 1 719 | 720 | // Filter to interactive meshes 721 | const interactive: OGL.Mesh[] = [] 722 | state.scene.traverse((node: OGL.Transform) => { 723 | // Mesh has registered events and a defined volume 724 | if ( 725 | node instanceof OGL.Mesh && 726 | (node as Instance['object']).__handlers && 727 | node.geometry?.attributes?.position 728 | ) 729 | interactive.push(node) 730 | }) 731 | 732 | // Get elements that intersect with our pointer 733 | state.raycaster!.castMouse(state.camera, state.mouse) 734 | const intersects: OGL.Mesh[] = state.raycaster!.intersectMeshes(interactive) 735 | 736 | // Call mesh handlers 737 | for (const entry of intersects) { 738 | if ((entry as unknown as any).__handlers) { 739 | const object = entry as Instance['object'] 740 | const handlers = object.__handlers 741 | 742 | const handlers = object.__handlers 743 | handlers?.onPointerMove?.({ ...object.hit, nativeEvent: event }) 744 | } 745 | } 746 | }, 747 | } 748 | 749 | // Bind 750 | state.events.connected = true 751 | for (const [name, handler] of Object.entries(state.events.handlers)) { 752 | canvas.addEventListener(name, handler) 753 | } 754 | }, 755 | disconnect(canvas: HTMLCanvasElement, state: RootState) { 756 | // Unbind 757 | state.events.connected = false 758 | for (const [name, handler] of Object.entries(state.events.handlers)) { 759 | canvas.removeEventListener(name, handler) 760 | } 761 | }, 762 | } 763 | 764 | 765 | ) => console.log(event)}> 766 | 767 | 768 | 769 | 770 | ``` 771 | 772 |
773 | 774 | ## Portals 775 | 776 | Portal children into a foreign OGL element via `createPortal`, which can modify children's `RootState`. This is particularly useful for postprocessing and complex render effects. 777 | 778 | ```tsx 779 | function Component { 780 | // scene & camera are inherited from portal parameters 781 | const { scene, camera, ... } = useOGL() 782 | } 783 | 784 | const scene = new OGL.Transform() 785 | const camera = new OGL.Camera() 786 | 787 | 788 | {createPortal(, scene, { camera }) 789 | 790 | ``` 791 | 792 | ## Testing 793 | 794 | In addition to `createRoot` (see [custom canvas](#custom-canvas)), react-ogl exports an `act` method which can be used to safely flush async effects in tests. The following emulates a legacy root and asserts against `RootState` (see [root state](#root-state)). 795 | 796 | ```tsx 797 | import * as React from 'react' 798 | import * as OGL from 'ogl' 799 | import { type Root, type RootStore, type RootState, createRoot } from 'react-ogl' 800 | 801 | it('tests against a react-ogl component or scene', async () => { 802 | const transform = React.createRef() 803 | 804 | const root: Root = createRoot(document.createElement('canvas')) 805 | const store: RootStore = await React.act(async () => root.render()) 806 | const state: RootState = store.getState() 807 | 808 | expect(transform.current).toBeInstanceOf(OGL.Transform) 809 | expect(state.scene.children).toStrictEqual([transform.current]) 810 | }) 811 | ``` 812 | -------------------------------------------------------------------------------- /examples/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | react-ogl examples 7 | 41 | 42 | 43 | 87 | 88 | 89 | -------------------------------------------------------------------------------- /examples/src/components/Controls.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as OGL from 'ogl' 3 | import { useFrame, useOGL } from 'react-ogl' 4 | 5 | function Controls() { 6 | const controls = React.useRef(null!) 7 | const gl = useOGL((state) => state.gl) 8 | const camera = useOGL((state) => state.camera) 9 | 10 | React.useEffect(() => { 11 | gl.canvas.classList.add('controls') 12 | return () => void gl.canvas.classList.remove('controls') 13 | }, []) 14 | 15 | useFrame(() => controls.current.update()) 16 | 17 | return 18 | } 19 | 20 | export default Controls 21 | -------------------------------------------------------------------------------- /examples/src/cubes.tsx: -------------------------------------------------------------------------------- 1 | import * as OGL from 'ogl' 2 | import * as React from 'react' 3 | import { type OGLElements, useFrame, Canvas } from 'react-ogl' 4 | 5 | const hotpink = new OGL.Color(0xfba2d4) 6 | const orange = new OGL.Color(0xf5ce54) 7 | 8 | const Box = (props: OGLElements['mesh']) => { 9 | const mesh = React.useRef(null!) 10 | const [hovered, setHover] = React.useState(false) 11 | const [active, setActive] = React.useState(false) 12 | 13 | useFrame(() => (mesh.current.rotation.x += 0.01)) 14 | 15 | return ( 16 | setActive((value) => !value)} 21 | onPointerOver={() => setHover(true)} 22 | onPointerOut={() => setHover(false)} 23 | > 24 | 25 | 57 | 58 | ) 59 | } 60 | 61 | export default ( 62 | 63 | 64 | 65 | 66 | ) 67 | -------------------------------------------------------------------------------- /examples/src/draw-modes.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { useOGL, useFrame, Canvas } from 'react-ogl' 3 | import Controls from './components/Controls' 4 | 5 | const Geometry = () => ( 6 | 11 | ) 12 | 13 | const Program = () => { 14 | const uTime = React.useRef({ value: 0 }) 15 | useFrame((_, t) => (uTime.current.value = t * 0.001)) 16 | 17 | return ( 18 | 48 | ) 49 | } 50 | 51 | const Modes = () => { 52 | const gl = useOGL((state) => state.gl) 53 | 54 | return ( 55 | <> 56 | 57 | 58 | 59 | 60 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 71 | 72 | 73 | ) 74 | } 75 | 76 | export default ( 77 | 78 | 79 | 80 | 81 | ) 82 | -------------------------------------------------------------------------------- /examples/src/primitives.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { Canvas } from 'react-ogl' 3 | import Controls from './components/Controls' 4 | 5 | const Program = () => ( 6 | 34 | ) 35 | 36 | export default ( 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | ) 57 | -------------------------------------------------------------------------------- /examples/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "extends": "../tsconfig", 3 | "include": ["src/**/*"] 4 | } 5 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | transform: { 3 | '^.+\\.(mjs|cjs|jsx?|tsx?)$': '@swc/jest', 4 | }, 5 | transformIgnorePatterns: ['node_modules/(?!ogl)'], 6 | testMatch: ['/tests/**/*.test.{js,jsx,ts,tsx}'], 7 | testEnvironment: 'jsdom', 8 | moduleFileExtensions: ['js', 'jsx', 'ts', 'tsx'], 9 | verbose: true, 10 | testTimeout: 30000, 11 | setupFilesAfterEnv: ['/tests/utils/setupTests.ts'], 12 | } 13 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "react-ogl", 3 | "version": "0.15.0", 4 | "description": "A barebones react renderer for OGL.", 5 | "bugs": { 6 | "url": "https://github.com/pmndrs/react-ogl/issues" 7 | }, 8 | "homepage": "https://github.com/pmndrs/react-ogl#readme", 9 | "repository": "pmndrs/react-ogl", 10 | "keywords": [ 11 | "react", 12 | "renderer", 13 | "webgl", 14 | "ogl", 15 | "shaders" 16 | ], 17 | "author": "Cody Bennett (https://github.com/codyjasonbennett)", 18 | "license": "MIT", 19 | "types": "./dist/index.d.ts", 20 | "module": "./dist/index.mjs", 21 | "exports": "./dist/index.mjs", 22 | "react-native": "./dist/index.native.mjs", 23 | "sideEffects": false, 24 | "files": [ 25 | "dist/*", 26 | "src/*" 27 | ], 28 | "devDependencies": { 29 | "@swc/cli": "^0.1.57", 30 | "@swc/core": "^1.2.242", 31 | "@swc/jest": "^0.2.22", 32 | "@testing-library/react": "^13.3.0", 33 | "@types/jest": "^28.1.8", 34 | "@types/react": "^18.0.17", 35 | "@types/react-dom": "^18.0.6", 36 | "@types/react-native": "^0.69.5", 37 | "@typescript-eslint/eslint-plugin": "^5.34.0", 38 | "@typescript-eslint/parser": "^5.34.0", 39 | "@vitejs/plugin-react": "^2.0.1", 40 | "eslint": "^8.22.0", 41 | "eslint-plugin-import": "^2.26.0", 42 | "eslint-plugin-react": "^7.30.1", 43 | "eslint-plugin-react-hooks": "^4.6.0", 44 | "expo-gl": "~11.4.0", 45 | "jest": "^28.1.3", 46 | "jest-environment-jsdom": "^28.1.3", 47 | "ogl": "^1.0.3", 48 | "prettier": "^2.7.1", 49 | "react": "^19.0.0", 50 | "react-dom": "^19.0.0", 51 | "react-native": "^0.69.4", 52 | "react-test-renderer": "^19.0.0", 53 | "rimraf": "^5.0.0", 54 | "typescript": "^5.7.3", 55 | "vite": "^6.0.11" 56 | }, 57 | "dependencies": { 58 | "@types/react-reconciler": "^0.28.9", 59 | "@types/webxr": "*", 60 | "its-fine": "^1.2.5", 61 | "react-reconciler": "^0.31.0", 62 | "react-use-measure": "^2.1.1", 63 | "scheduler": "^0.25.0", 64 | "suspend-react": "^0.1.3", 65 | "zustand": "^4.5.2" 66 | }, 67 | "peerDependencies": { 68 | "expo-gl": ">=11.4", 69 | "ogl": ">=1", 70 | "react": "^19.0", 71 | "react-dom": "^19.0", 72 | "react-native": ">=0.78" 73 | }, 74 | "peerDependenciesMeta": { 75 | "react-dom": { 76 | "optional": true 77 | }, 78 | "react-native": { 79 | "optional": true 80 | }, 81 | "expo-gl": { 82 | "optional": true 83 | } 84 | }, 85 | "scripts": { 86 | "dev": "vite", 87 | "build": "rimraf dist && vite build && vite build && tsc", 88 | "test": "jest", 89 | "lint": "eslint src/**/*.{ts,tsx}", 90 | "lint-fix": "prettier . --write && eslint --fix src/**/*.{ts,tsx}" 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /src/Canvas.native.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { PixelRatio, ViewProps, ViewStyle, View, StyleSheet, LayoutChangeEvent } from 'react-native' 3 | import { ExpoWebGLRenderingContext, GLView } from 'expo-gl' 4 | import { useContextBridge, FiberProvider } from 'its-fine' 5 | import { Block, SetBlock, ErrorBoundary } from './utils' 6 | import { events as createTouchEvents } from './events.native' // explicitly require native module 7 | import { RenderProps } from './types' 8 | import { render, unmountComponentAtNode } from './renderer' 9 | 10 | // TODO: React 19 needs support from react-native 11 | const _View = View as any 12 | 13 | export interface CanvasProps extends Omit, Omit { 14 | children: React.ReactNode 15 | style?: ViewStyle 16 | } 17 | 18 | const CanvasImpl = React.forwardRef(function Canvas( 19 | { children, style, renderer, camera, orthographic, frameloop, events = createTouchEvents, onCreated, ...props }, 20 | forwardedRef, 21 | ) { 22 | const Bridge = useContextBridge() 23 | const [{ width, height }, setSize] = React.useState({ width: 0, height: 0 }) 24 | const [canvas, setCanvas] = React.useState(null) 25 | const [bind, setBind] = React.useState() 26 | const [block, setBlock] = React.useState(false) 27 | const [error, setError] = React.useState(false) 28 | 29 | // Suspend this component if block is a promise (2nd run) 30 | if (block) throw block 31 | // Throw exception outwards if anything within Canvas throws 32 | if (error) throw error 33 | 34 | const onLayout = React.useCallback((e: LayoutChangeEvent) => { 35 | const { width, height } = e.nativeEvent.layout 36 | setSize({ width, height }) 37 | }, []) 38 | 39 | const onContextCreate = React.useCallback((context: ExpoWebGLRenderingContext) => { 40 | const canvasShim = { 41 | width: context.drawingBufferWidth, 42 | height: context.drawingBufferHeight, 43 | style: {}, 44 | addEventListener: (() => {}) as any, 45 | removeEventListener: (() => {}) as any, 46 | clientHeight: context.drawingBufferHeight, 47 | getContext: (() => context) as any, 48 | } as HTMLCanvasElement 49 | ;(context as any).canvas = canvasShim 50 | 51 | setCanvas(canvasShim) 52 | }, []) 53 | 54 | // Render to screen 55 | if (canvas && width > 0 && height > 0) { 56 | render( 57 | // @ts-expect-error 58 | 59 | 60 | }>{children} 61 | 62 | , 63 | canvas, 64 | { 65 | size: { width, height }, 66 | orthographic, 67 | frameloop, 68 | renderer, 69 | // expo-gl can only render at native dpr/resolution 70 | // https://github.com/expo/expo-three/issues/39 71 | dpr: PixelRatio.get(), 72 | camera, 73 | events, 74 | onCreated(state) { 75 | // Flush frame for native 76 | const gl = state.gl as unknown as ExpoWebGLRenderingContext | WebGL2RenderingContext 77 | if ('endFrameEXP' in gl) { 78 | const renderFrame = state.renderer.render.bind(state.renderer) 79 | state.renderer.render = (...args) => { 80 | renderFrame(...args) 81 | gl.endFrameEXP() 82 | } 83 | } 84 | 85 | // Bind events 86 | setBind((state.events?.connected as any)?.getEventHandlers()) 87 | 88 | return onCreated?.(state) 89 | }, 90 | }, 91 | ) 92 | } 93 | 94 | // Cleanup on unmount 95 | React.useEffect(() => { 96 | if (canvas) { 97 | return () => unmountComponentAtNode(canvas) 98 | } 99 | }, [canvas]) 100 | 101 | return ( 102 | <_View {...props} ref={forwardedRef} onLayout={onLayout} style={{ flex: 1, ...style }} {...bind}> 103 | {width > 0 && } 104 | 105 | ) 106 | }) 107 | 108 | /** 109 | * A resizeable canvas whose children are declarative OGL elements. 110 | */ 111 | export const Canvas = React.forwardRef(function CanvasWrapper(props, ref) { 112 | return ( 113 | // @ts-expect-error 114 | 115 | 116 | 117 | ) 118 | }) 119 | -------------------------------------------------------------------------------- /src/Canvas.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | // eslint-disable-next-line import/named 3 | import useMeasure, { Options as ResizeOptions } from 'react-use-measure' 4 | import { useContextBridge, FiberProvider } from 'its-fine' 5 | import { useIsomorphicLayoutEffect } from './hooks' 6 | import { Block, SetBlock, ErrorBoundary } from './utils' 7 | import { events as createPointerEvents } from './events' 8 | import { RenderProps } from './types' 9 | import { render, unmountComponentAtNode } from './renderer' 10 | 11 | export interface CanvasProps extends Omit, React.HTMLAttributes { 12 | children: React.ReactNode 13 | resize?: ResizeOptions 14 | } 15 | 16 | const CanvasImpl = React.forwardRef(function Canvas( 17 | { 18 | resize, 19 | children, 20 | style, 21 | renderer, 22 | dpr, 23 | camera, 24 | orthographic, 25 | frameloop, 26 | events = createPointerEvents, 27 | onCreated, 28 | ...props 29 | }, 30 | forwardedRef, 31 | ) { 32 | const Bridge = useContextBridge() 33 | const canvasRef = React.useRef(null!) 34 | const [div, { width, height }] = useMeasure({ 35 | scroll: true, 36 | debounce: { scroll: 50, resize: 0 }, 37 | ...resize, 38 | }) 39 | const [canvas, setCanvas] = React.useState(null) 40 | const [block, setBlock] = React.useState(false) 41 | const [error, setError] = React.useState(false) 42 | React.useImperativeHandle(forwardedRef, () => canvasRef.current) 43 | 44 | // Suspend this component if block is a promise (2nd run) 45 | if (block) throw block 46 | // Throw exception outwards if anything within Canvas throws 47 | if (error) throw error 48 | 49 | // Render to screen 50 | if (canvas && width > 0 && height > 0) { 51 | render( 52 | // @ts-expect-error 53 | 54 | 55 | }>{children} 56 | 57 | , 58 | canvas, 59 | { 60 | size: { width, height }, 61 | orthographic, 62 | frameloop, 63 | renderer, 64 | dpr, 65 | camera, 66 | events, 67 | onCreated, 68 | }, 69 | ) 70 | } 71 | 72 | useIsomorphicLayoutEffect(() => { 73 | setCanvas(canvasRef.current) 74 | }, []) 75 | 76 | // Cleanup on unmount 77 | React.useEffect(() => { 78 | if (canvas) return () => unmountComponentAtNode(canvas) 79 | }, [canvas]) 80 | 81 | return ( 82 |
93 | 94 |
95 | ) 96 | }) 97 | 98 | /** 99 | * A resizeable canvas whose children are declarative OGL elements. 100 | */ 101 | export const Canvas = React.forwardRef(function CanvasWrapper(props, ref) { 102 | return ( 103 | // @ts-expect-error 104 | 105 | 106 | 107 | ) 108 | }) 109 | -------------------------------------------------------------------------------- /src/constants.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * react-ogl's virtual pointer events. 3 | */ 4 | export const POINTER_EVENTS = [ 5 | 'onClick', 6 | 'onPointerUp', 7 | 'onPointerDown', 8 | 'onPointerMove', 9 | 'onPointerOver', 10 | 'onPointerOut', 11 | ] as const 12 | 13 | /** 14 | * React internal props. 15 | */ 16 | export const RESERVED_PROPS = ['children', 'key', 'ref', '__self', '__source'] 17 | 18 | /** 19 | * react-ogl instance-specific props. 20 | */ 21 | export const INSTANCE_PROPS = ['args', 'object', 'dispose', 'attach'] 22 | -------------------------------------------------------------------------------- /src/events.native.ts: -------------------------------------------------------------------------------- 1 | import { GestureResponderEvent } from 'react-native' 2 | // @ts-ignore 3 | import Pressability from 'react-native/Libraries/Pressability/Pressability.js' 4 | import { createEvents } from './utils' 5 | import { EventHandlers, EventManager } from './types' 6 | 7 | /** 8 | * Base Pressability events and their JSX keys for native & web. 9 | */ 10 | export const EVENTS = { 11 | onPress: 'onClick', 12 | onPressIn: 'onPointerDown', 13 | onPressOut: 'onPointerUp', 14 | onPressMove: 'onPointerMove', 15 | } as const 16 | 17 | /** 18 | * DOM OGL events manager. 19 | */ 20 | export const events: EventManager = { 21 | connected: false, 22 | /** 23 | * Creates and registers event listeners on our canvas. 24 | */ 25 | connect(canvas, state) { 26 | // Cleanup old handlers 27 | state.events!.disconnect?.(canvas, state) 28 | 29 | // Event handlers 30 | const { handleEvent } = createEvents(state) 31 | 32 | // Emulate DOM events 33 | const handleTouch = (event: GestureResponderEvent, type: keyof EventHandlers) => { 34 | event.persist() 35 | 36 | // Apply offset 37 | ;(event.nativeEvent as any).offsetX = event.nativeEvent.locationX 38 | ;(event.nativeEvent as any).offsetY = event.nativeEvent.locationY 39 | 40 | // Handle event 41 | return handleEvent(event.nativeEvent as unknown as PointerEvent, type) 42 | } 43 | 44 | // Init handlers 45 | state.events!.handlers = Object.entries(EVENTS).reduce( 46 | (acc, [name, type]) => ({ 47 | ...acc, 48 | [name]: (event: GestureResponderEvent) => handleTouch(event, type), 49 | }), 50 | {}, 51 | ) 52 | 53 | // Create event manager 54 | state.events!.connected = new Pressability(state.events!.handlers) 55 | }, 56 | /** 57 | * Deletes and disconnects event listeners from canvas. 58 | */ 59 | disconnect(_, state) { 60 | // Disconnect handlers 61 | ;(state.events!.connected as any)?.reset?.() 62 | }, 63 | } 64 | -------------------------------------------------------------------------------- /src/events.ts: -------------------------------------------------------------------------------- 1 | import { createEvents } from './utils' 2 | import { EventManager } from './types' 3 | 4 | /** 5 | * Base DOM events and their JSX keys with passive args. 6 | */ 7 | export const EVENTS = { 8 | click: ['onClick', false], 9 | pointerup: ['onPointerUp', true], 10 | pointerdown: ['onPointerDown', true], 11 | pointermove: ['onPointerMove', true], 12 | } as const 13 | 14 | /** 15 | * DOM OGL events manager. 16 | */ 17 | export const events: EventManager = { 18 | connected: false, 19 | /** 20 | * Creates and registers event listeners on our canvas. 21 | */ 22 | connect(canvas, state) { 23 | // Cleanup old handlers 24 | state.events!.disconnect?.(canvas, state) 25 | 26 | // Get event handler 27 | const { handleEvent } = createEvents(state) 28 | 29 | // Create handlers 30 | state.events!.handlers = Object.entries(EVENTS).reduce( 31 | (acc, [name, [type]]) => ({ 32 | ...acc, 33 | [name]: (event: PointerEvent) => handleEvent(event, type), 34 | }), 35 | {} as any, 36 | ) 37 | 38 | // Register handlers 39 | for (const key in EVENTS) { 40 | const handler = state.events!.handlers?.[key] 41 | if (handler) { 42 | const [, passive] = EVENTS[key as keyof typeof EVENTS] 43 | canvas.addEventListener(key, handler, { passive }) 44 | } 45 | } 46 | 47 | // Mark events as connected 48 | state.events!.connected = true 49 | }, 50 | /** 51 | * Deletes and disconnects event listeners from canvas. 52 | */ 53 | disconnect(canvas, state) { 54 | // Disconnect handlers 55 | for (const key in EVENTS) { 56 | const handler = state.events!.handlers?.[key] 57 | if (handler) { 58 | canvas.removeEventListener(key, handler as any) 59 | } 60 | } 61 | 62 | // Mark events as disconnected 63 | state.events!.connected = false 64 | }, 65 | } 66 | -------------------------------------------------------------------------------- /src/hooks.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as OGL from 'ogl' 3 | import { suspend } from 'suspend-react' 4 | import type { Instance, RootState, RootStore, Subscription } from './types' 5 | import { classExtends } from './utils' 6 | 7 | /** 8 | * An SSR-friendly useLayoutEffect. 9 | * 10 | * React currently throws a warning when using useLayoutEffect on the server. 11 | * To get around it, we can conditionally useEffect on the server (no-op) and 12 | * useLayoutEffect elsewhere. 13 | * 14 | * @see https://github.com/facebook/react/issues/14927 15 | */ 16 | export const useIsomorphicLayoutEffect = 17 | typeof window !== 'undefined' && (window.document?.createElement || window.navigator?.product === 'ReactNative') 18 | ? React.useLayoutEffect 19 | : React.useEffect 20 | 21 | /** 22 | * Exposes an object's {@link Instance}. 23 | * 24 | * **Note**: this is an escape hatch to react-internal fields. Expect this to change significantly between versions. 25 | */ 26 | export function useInstanceHandle(ref: React.RefObject): React.RefObject { 27 | const instance = React.useRef(null!) 28 | useIsomorphicLayoutEffect( 29 | () => void (instance.current = (ref.current as unknown as Instance['object']).__ogl!), 30 | [ref], 31 | ) 32 | return instance 33 | } 34 | 35 | /** 36 | * Internal OGL context. 37 | */ 38 | export const OGLContext = React.createContext(null!) 39 | 40 | /** 41 | * Returns the internal OGL store. 42 | */ 43 | export function useStore() { 44 | const store = React.useContext(OGLContext) 45 | if (!store) throw `react-ogl hooks can only used inside a canvas or OGLContext provider!` 46 | return store 47 | } 48 | 49 | /** 50 | * Returns the internal OGL state. 51 | */ 52 | export function useOGL( 53 | selector: (state: RootState) => T = (state) => state as unknown as T, 54 | equalityFn?: (state: T, newState: T) => boolean, 55 | ) { 56 | return useStore()(selector, equalityFn!) 57 | } 58 | 59 | export interface ObjectMap { 60 | nodes: Record 61 | programs: Record 62 | } 63 | 64 | /** 65 | * Creates an `ObjectMap` from an object. 66 | */ 67 | export function useGraph(object: OGL.Transform) { 68 | return React.useMemo(() => { 69 | const data: ObjectMap = { nodes: {}, programs: {} } 70 | 71 | object.traverse((obj: OGL.Transform | OGL.Mesh) => { 72 | if (!(obj instanceof OGL.Mesh)) return 73 | 74 | // @ts-ignore 75 | if (obj.name) data.nodes[obj.name] = obj 76 | 77 | // @ts-ignore 78 | if (obj.program.gltfMaterial && !data.programs[obj.program.gltfMaterial.name]) { 79 | // @ts-ignore 80 | data.programs[obj.program.gltfMaterial.name] = obj.program 81 | } 82 | }) 83 | 84 | return data 85 | }, [object]) 86 | } 87 | 88 | /** 89 | * Subscribe an element into a shared render loop. 90 | */ 91 | export function useFrame(callback: Subscription, renderPriority = 0) { 92 | const subscribe = useOGL((state) => state.subscribe) 93 | const unsubscribe = useOGL((state) => state.unsubscribe) 94 | // Store frame callback in a ref so we can pass a mutable reference. 95 | // This allows the callback to dynamically update without blocking 96 | // the render loop. 97 | const ref = React.useRef(callback) 98 | useIsomorphicLayoutEffect(() => void (ref.current = callback), [callback]) 99 | // Subscribe on mount and unsubscribe on unmount 100 | useIsomorphicLayoutEffect(() => { 101 | subscribe(ref, renderPriority) 102 | return () => void unsubscribe(ref, renderPriority) 103 | }, [subscribe, unsubscribe, renderPriority]) 104 | } 105 | 106 | export type LoaderRepresentation = 107 | | { load(gl: OGL.OGLRenderingContext, url: string): Promise } 108 | | Pick 109 | 110 | export type LoaderResult = Awaited> 111 | 112 | /** 113 | * Loads assets suspensefully. 114 | */ 115 | export function useLoader>( 116 | loader: L, 117 | input: I, 118 | extensions?: (loader: L) => void, 119 | ): I extends any[] ? R[] : R { 120 | const gl = useOGL((state) => state.gl) 121 | 122 | // Put keys into an array so their contents are spread and cached with suspend 123 | const keys = Array.isArray(input) ? input : [input] 124 | 125 | return suspend( 126 | async (gl, loader, ...urls) => { 127 | // Call extensions 128 | extensions?.(loader) 129 | 130 | const result = await Promise.all( 131 | urls.map(async (url: string) => { 132 | // @ts-ignore OGL's loaders don't have a consistent signature 133 | if (classExtends(loader, OGL.TextureLoader)) return loader.load(gl, { src: url }) 134 | // @ts-ignore 135 | return await loader.load(gl, url) 136 | }), 137 | ) 138 | 139 | // Return result | result[], mirroring input | input[] 140 | return Array.isArray(input) ? result : result[0] 141 | }, 142 | [gl, loader, ...keys], 143 | ) 144 | } 145 | -------------------------------------------------------------------------------- /src/index.native.ts: -------------------------------------------------------------------------------- 1 | export * from './Canvas.native' 2 | export * from './constants' 3 | export * from './events.native' 4 | export * from './hooks' 5 | export * from './reconciler' 6 | export * from './renderer' 7 | export * from './types' 8 | export * from './utils' 9 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | export * from './Canvas' 2 | export * from './constants' 3 | export * from './events' 4 | export * from './hooks' 5 | export * from './reconciler' 6 | export * from './renderer' 7 | export * from './types' 8 | export * from './utils' 9 | -------------------------------------------------------------------------------- /src/reconciler.ts: -------------------------------------------------------------------------------- 1 | import Reconciler from 'react-reconciler' 2 | import { 3 | // NoEventPriority, 4 | ContinuousEventPriority, 5 | DiscreteEventPriority, 6 | DefaultEventPriority, 7 | } from 'react-reconciler/constants.js' 8 | import { unstable_IdlePriority as idlePriority, unstable_scheduleCallback as scheduleCallback } from 'scheduler' 9 | import * as OGL from 'ogl' 10 | import * as React from 'react' 11 | import { toPascalCase, applyProps, attach, detach, classExtends, prepare } from './utils' 12 | import { RESERVED_PROPS } from './constants' 13 | import { Act, Catalogue, ConstructorRepresentation, Instance, OGLElements, RootStore } from './types' 14 | 15 | // @ts-ignore 16 | const __DEV__ = /* @__PURE__ */ (() => typeof process !== 'undefined' && process.env.NODE_ENV !== 'production')() 17 | 18 | // TODO: upstream to DefinitelyTyped for React 19 19 | // https://github.com/facebook/react/issues/28956 20 | type EventPriority = number 21 | 22 | function createReconciler< 23 | Type, 24 | Props, 25 | Container, 26 | Instance, 27 | TextInstance, 28 | SuspenseInstance, 29 | HydratableInstance, 30 | FormInstance, 31 | PublicInstance, 32 | HostContext, 33 | ChildSet, 34 | TimeoutHandle, 35 | NoTimeout, 36 | TransitionStatus, 37 | >( 38 | config: Omit< 39 | Reconciler.HostConfig< 40 | Type, 41 | Props, 42 | Container, 43 | Instance, 44 | TextInstance, 45 | SuspenseInstance, 46 | HydratableInstance, 47 | PublicInstance, 48 | HostContext, 49 | null, // updatePayload 50 | ChildSet, 51 | TimeoutHandle, 52 | NoTimeout 53 | >, 54 | 'getCurrentEventPriority' | 'prepareUpdate' | 'commitUpdate' 55 | > & { 56 | /** 57 | * This method should mutate the `instance` and perform prop diffing if needed. 58 | * 59 | * The `internalHandle` data structure is meant to be opaque. If you bend the rules and rely on its internal fields, be aware that it may change significantly between versions. You're taking on additional maintenance risk by reading from it, and giving up all guarantees if you write something to it. 60 | */ 61 | commitUpdate?( 62 | instance: Instance, 63 | type: Type, 64 | prevProps: Props, 65 | nextProps: Props, 66 | internalHandle: Reconciler.OpaqueHandle, 67 | ): void 68 | 69 | // Undocumented 70 | // https://github.com/facebook/react/pull/26722 71 | NotPendingTransition: TransitionStatus | null 72 | HostTransitionContext: React.Context 73 | // https://github.com/facebook/react/pull/28751 74 | setCurrentUpdatePriority(newPriority: EventPriority): void 75 | getCurrentUpdatePriority(): EventPriority 76 | resolveUpdatePriority(): EventPriority 77 | // https://github.com/facebook/react/pull/28804 78 | resetFormInstance(form: FormInstance): void 79 | // https://github.com/facebook/react/pull/25105 80 | requestPostPaintCallback(callback: (time: number) => void): void 81 | // https://github.com/facebook/react/pull/26025 82 | shouldAttemptEagerTransition(): boolean 83 | // https://github.com/facebook/react/pull/31528 84 | trackSchedulerEvent(): void 85 | // https://github.com/facebook/react/pull/31008 86 | resolveEventType(): null | string 87 | resolveEventTimeStamp(): number 88 | 89 | /** 90 | * This method is called during render to determine if the Host Component type and props require some kind of loading process to complete before committing an update. 91 | */ 92 | maySuspendCommit(type: Type, props: Props): boolean 93 | /** 94 | * This method may be called during render if the Host Component type and props might suspend a commit. It can be used to initiate any work that might shorten the duration of a suspended commit. 95 | */ 96 | preloadInstance(type: Type, props: Props): boolean 97 | /** 98 | * This method is called just before the commit phase. Use it to set up any necessary state while any Host Components that might suspend this commit are evaluated to determine if the commit must be suspended. 99 | */ 100 | startSuspendingCommit(): void 101 | /** 102 | * This method is called after `startSuspendingCommit` for each Host Component that indicated it might suspend a commit. 103 | */ 104 | suspendInstance(type: Type, props: Props): void 105 | /** 106 | * This method is called after all `suspendInstance` calls are complete. 107 | * 108 | * Return `null` if the commit can happen immediately. 109 | * 110 | * Return `(initiateCommit: Function) => Function` if the commit must be suspended. The argument to this callback will initiate the commit when called. The return value is a cancellation function that the Reconciler can use to abort the commit. 111 | * 112 | */ 113 | waitForCommitToBeReady(): ((initiateCommit: Function) => Function) | null 114 | }, 115 | ): Reconciler.Reconciler { 116 | const reconciler = Reconciler(config as any) 117 | 118 | reconciler.injectIntoDevTools({ 119 | bundleType: __DEV__ ? 1 : 0, 120 | rendererPackageName: 'react-ogl', 121 | version: React.version, 122 | }) 123 | 124 | return reconciler as any 125 | } 126 | 127 | const NoEventPriority = 0 128 | 129 | // Custom objects that extend the OGL namespace 130 | const catalogue = { ...OGL } as unknown as Catalogue 131 | 132 | // Effectful catalogue elements that require a `WebGLRenderingContext`. 133 | const catalogueGL: ConstructorRepresentation[] = [ 134 | // Core 135 | OGL.Camera, 136 | OGL.Geometry, 137 | OGL.Mesh, 138 | OGL.Program, 139 | OGL.RenderTarget, 140 | OGL.Texture, 141 | 142 | // Extras 143 | OGL.Flowmap, 144 | OGL.GPGPU, 145 | OGL.NormalProgram, 146 | OGL.Polyline, 147 | OGL.Post, 148 | OGL.Shadow, 149 | OGL.AxesHelper, 150 | OGL.GridHelper, 151 | OGL.WireMesh, 152 | ] 153 | 154 | /** 155 | * Extends the OGL catalogue, accepting an object of keys pointing to external classes. 156 | * `gl` will flag `objects` to receive a `WebGLRenderingContext` on creation. 157 | */ 158 | export function extend(objects: Partial, gl = false) { 159 | for (const key in objects) { 160 | const value = objects[key as keyof Catalogue]! 161 | catalogue[key as keyof Catalogue] = value 162 | if (gl) catalogueGL.push(value) 163 | } 164 | } 165 | 166 | // https://github.com/facebook/react/issues/20271 167 | // This will make sure events and attach are only handled once when trees are complete 168 | function handleContainerEffects(parent: Instance, child: Instance) { 169 | // Bail if tree isn't mounted or parent is not a container. 170 | // This ensures that the tree is finalized and React won't discard results to Suspense 171 | const state = child.root.getState() 172 | if (!parent.parent && parent.object !== state.scene) return 173 | 174 | // Create instance object 175 | if (child.type !== 'primitive') { 176 | const name = toPascalCase(child.type) as keyof Catalogue 177 | const target = catalogue[name] 178 | const { args = [], ...props } = child.props 179 | 180 | // Pass internal state to elements which depend on it. 181 | // This lets them be immutable upon creation and use props 182 | const isGLInstance = Object.values(catalogueGL).some((elem) => classExtends(elem, target)) 183 | if (isGLInstance) { 184 | const { gl } = child.root.getState() 185 | const filtered = args.filter((arg) => arg !== gl) 186 | 187 | // Accept props as args for programs & geometry 188 | if (child.type === 'program' || child.type === 'geometry') { 189 | const attrs = Object.entries(props).reduce((acc, [key, value]) => { 190 | // Don't include non-attributes for geometry 191 | if (child.type === 'geometry' && !(value as OGL.Attribute)?.data) return acc 192 | // Include non-pierced props 193 | if (!key.includes('-')) acc[key] = value 194 | return acc 195 | }, filtered[0] ?? {}) 196 | 197 | child.object = new target(gl, attrs) 198 | } else { 199 | child.object = new target(gl, ...filtered) 200 | } 201 | } else { 202 | child.object = new target(...args) 203 | } 204 | } 205 | 206 | // Link instance handle 207 | child.object.__ogl = child 208 | 209 | // Auto-attach geometry and programs to meshes 210 | if (!child.props.attach) { 211 | if (child.object instanceof OGL.Geometry) child.props.attach = 'geometry' 212 | else if (child.object instanceof OGL.Program) child.props.attach = 'program' 213 | } 214 | 215 | // Apply props to OGL object 216 | applyProps(child.object, child.props) 217 | 218 | // Handle attach 219 | if (child.props.attach) { 220 | attach(parent, child) 221 | } else if (child.object instanceof OGL.Transform && parent.object instanceof OGL.Transform) { 222 | child.object.setParent(parent.object) 223 | } 224 | 225 | // Link subtree 226 | for (const childInstance of child.children) handleContainerEffects(child, childInstance) 227 | } 228 | 229 | /** 230 | * Creates an OGL element from a React node. 231 | */ 232 | function createInstance(type: keyof OGLElements, props: Instance['props'], root: RootStore) { 233 | // Convert lowercase primitive to PascalCase 234 | const name = toPascalCase(type) as keyof Catalogue 235 | 236 | // Get class from extended OGL catalogue 237 | const target = catalogue[name] 238 | 239 | // Validate OGL elements 240 | if (type !== 'primitive' && !target) throw `${type} is not a part of the OGL catalogue! Did you forget to extend?` 241 | 242 | // Validate primitives 243 | if (type === 'primitive' && !props.object) throw `"object" must be set when using primitives.` 244 | 245 | // Create instance 246 | const instance = prepare(props.object, root, type, props) 247 | 248 | return instance 249 | } 250 | 251 | /** 252 | * Adds elements to our scene and attaches children to their parents. 253 | */ 254 | const appendChild = (parent: Instance, child: Instance) => { 255 | // Link instances 256 | child.parent = parent 257 | parent.children.push(child) 258 | 259 | // Attach tree once complete 260 | handleContainerEffects(parent, child) 261 | } 262 | 263 | /** 264 | * Removes elements from scene and disposes of them. 265 | */ 266 | function removeChild(parent: Instance, child: Instance, dispose?: boolean, recursive?: boolean) { 267 | // Unlink instances 268 | child.parent = null 269 | if (recursive === undefined) { 270 | const childIndex = parent.children.indexOf(child) 271 | if (childIndex !== -1) parent.children.splice(childIndex, 1) 272 | } 273 | 274 | // Remove instance objects 275 | if (child.props.attach) { 276 | detach(parent, child) 277 | } else if (parent.object instanceof OGL.Transform && child.object instanceof OGL.Transform) { 278 | parent.object.removeChild(child.object) 279 | } 280 | 281 | // Allow objects to bail out of unmount disposal with dispose={null} 282 | const shouldDispose = child.props.dispose !== null && dispose !== false 283 | 284 | // Recursively remove instance children 285 | if (recursive !== false) { 286 | for (const node of child.children) removeChild(child, node, shouldDispose, true) 287 | child.children = [] 288 | } 289 | 290 | // Dispose if able 291 | if (shouldDispose) { 292 | const object = child.object 293 | scheduleCallback(idlePriority, () => object.dispose?.()) 294 | delete child.object.__ogl 295 | child.object = null 296 | } 297 | } 298 | 299 | /** 300 | * Inserts an instance between instances of a ReactNode. 301 | */ 302 | function insertBefore(parent: Instance, child: Instance, beforeChild: Instance, replace = false) { 303 | if (!child) return 304 | 305 | // Link instances 306 | child.parent = parent 307 | const childIndex = parent.children.indexOf(beforeChild) 308 | if (childIndex !== -1) parent.children.splice(childIndex, replace ? 1 : 0, child) 309 | if (replace) beforeChild.parent = null 310 | 311 | // Attach tree once complete 312 | handleContainerEffects(parent, child) 313 | } 314 | 315 | /** 316 | * Switches instance to a new one, moving over children. 317 | */ 318 | function switchInstance( 319 | oldInstance: Instance, 320 | type: keyof OGLElements, 321 | props: Instance['props'], 322 | fiber: Reconciler.Fiber, 323 | ) { 324 | // React 19 regression from (un)hide hooks 325 | oldInstance.object.visible = true 326 | 327 | // Create a new instance 328 | const newInstance = createInstance(type, props, oldInstance.root) 329 | 330 | // Move children to new instance 331 | for (const child of oldInstance.children) { 332 | removeChild(oldInstance, child, false, false) 333 | appendChild(newInstance, child) 334 | } 335 | oldInstance.children = [] 336 | 337 | // Link up new instance 338 | const parent = oldInstance.parent 339 | if (parent) { 340 | insertBefore(parent, newInstance, oldInstance, true) 341 | } 342 | 343 | // Switches the react-internal fiber node 344 | // https://github.com/facebook/react/issues/14983 345 | ;[fiber, fiber.alternate].forEach((fiber) => { 346 | if (fiber !== null) { 347 | fiber.stateNode = newInstance 348 | if (fiber.ref) { 349 | if (typeof fiber.ref === 'function') fiber.ref(newInstance.object) 350 | else fiber.ref.current = newInstance.object 351 | } 352 | } 353 | }) 354 | 355 | return newInstance 356 | } 357 | 358 | /** 359 | * Shallow checks objects. 360 | */ 361 | function checkShallow(a: any, b: any) { 362 | // If comparing arrays, shallow compare 363 | if (Array.isArray(a)) { 364 | // Check if types match 365 | if (!Array.isArray(b)) return false 366 | 367 | // Shallow compare for match 368 | if (a == b) return true 369 | 370 | // Sort through keys 371 | if (a.every((v, i) => v === b[i])) return true 372 | } 373 | 374 | // Atomically compare 375 | if (a === b) return true 376 | 377 | return false 378 | } 379 | 380 | /** 381 | * Prepares a set of changes to be applied to the instance. 382 | */ 383 | function diffProps( 384 | instance: Instance, 385 | newProps: Instance['props'], 386 | oldProps: Instance['props'], 387 | ): Instance['props'] { 388 | const changedProps: Instance['props'] = {} 389 | 390 | // Sort through props 391 | for (const key in newProps) { 392 | // Skip reserved keys 393 | if (RESERVED_PROPS.includes(key as typeof RESERVED_PROPS[number])) continue 394 | // Skip primitives 395 | if (instance.type === 'primitive' && key === 'object') continue 396 | // Skip if props match 397 | if (checkShallow(newProps[key], oldProps[key])) continue 398 | 399 | // Props changed, add them 400 | changedProps[key] = newProps[key] 401 | } 402 | 403 | return changedProps 404 | } 405 | 406 | const NO_CONTEXT = {} 407 | 408 | let currentUpdatePriority: number = NoEventPriority 409 | 410 | /** 411 | * Centralizes and handles mutations through an OGL scene-graph. 412 | */ 413 | export const reconciler = /* @__PURE__ */ createReconciler< 414 | keyof OGLElements, // type 415 | Instance['props'], // props 416 | RootStore, // container 417 | Instance, // instance 418 | never, // text instance 419 | Instance, // suspense instance 420 | never, // hydratable instance 421 | never, // form instance 422 | Instance['object'], // public instance 423 | {}, // host context 424 | never, // child set 425 | typeof setTimeout | undefined, // timeout handle 426 | -1, // no timeout 427 | null // transition status 428 | >({ 429 | // Configure renderer for tree-like mutation and interop w/react-dom 430 | isPrimaryRenderer: false, 431 | supportsMutation: true, 432 | supportsHydration: false, 433 | supportsPersistence: false, 434 | // Add SSR time fallbacks 435 | scheduleTimeout: () => (typeof setTimeout !== 'undefined' ? setTimeout : undefined), 436 | cancelTimeout: () => (typeof clearTimeout !== 'undefined' ? clearTimeout : undefined), 437 | noTimeout: -1, 438 | // Text isn't supported so we skip it 439 | shouldSetTextContent: () => false, 440 | resetTextContent: () => {}, 441 | createTextInstance() { 442 | throw new Error('Text is not allowed in the OGL scene-graph!') 443 | }, 444 | hideTextInstance() { 445 | throw new Error('Text is not allowed in the OGL scene-graph!') 446 | }, 447 | unhideTextInstance: () => {}, 448 | // Modifies the ref to return the instance object itself. 449 | getPublicInstance: (instance) => instance.object, 450 | // We can optionally access different host contexts on instance creation/update. 451 | // Instances' data structures are self-sufficient, so we don't make use of this 452 | getRootHostContext: () => NO_CONTEXT, 453 | getChildHostContext: () => NO_CONTEXT, 454 | // We can optionally mutate portal containers here, but we do that in createPortal instead from state 455 | preparePortalMount: (container) => prepare(container.getState().scene, container, '', {}), 456 | // This lets us store stuff at the container-level before/after React mutates our OGL elements. 457 | // Elements are mutated in isolation, so we don't do anything here. 458 | prepareForCommit: () => null, 459 | resetAfterCommit: () => {}, 460 | // This can modify the container and clear children. 461 | // Might be useful for disposing on demand later 462 | clearContainer: () => false, 463 | // This creates a OGL element from a React element 464 | createInstance, 465 | // These methods add elements to the scene 466 | appendChild, 467 | appendInitialChild: appendChild, 468 | appendChildToContainer(container, child) { 469 | const scene = (container.getState().scene as unknown as Instance['object']).__ogl 470 | if (!child || !scene) return 471 | 472 | appendChild(scene, child) 473 | }, 474 | // We can specify an order for children to be inserted here. 475 | // This is useful if you want to override stuff like materials 476 | insertBefore, 477 | insertInContainerBefore(container, child, beforeChild) { 478 | const scene = (container.getState().scene as unknown as Instance['object']).__ogl 479 | if (!child || !beforeChild || !scene) return 480 | 481 | insertBefore(scene, child, beforeChild) 482 | }, 483 | // These methods remove elements from the scene 484 | removeChild, 485 | removeChildFromContainer(container, child) { 486 | const scene = (container.getState().scene as unknown as Instance['object']).__ogl 487 | if (!child || !scene) return 488 | 489 | removeChild(scene, child) 490 | }, 491 | // This is where we mutate OGL elements in the render phase 492 | // @ts-ignore 493 | commitUpdate(instance: Instance, type: Type, oldProps: Instance['props'], newProps: Instance['props'], fiber: any) { 494 | let reconstruct = false 495 | 496 | // Element is a primitive. We must recreate it when its object prop is changed 497 | if (instance.type === 'primitive' && oldProps.object !== newProps.object) reconstruct = true 498 | // Element is a program. Check whether its vertex or fragment props changed to recreate 499 | else if (type === 'program') { 500 | if (oldProps.vertex !== newProps.vertex) reconstruct = true 501 | if (oldProps.fragment !== newProps.fragment) reconstruct = true 502 | } 503 | // Element is a geometry. Check whether its attribute props changed to recreate. 504 | else if (type === 'geometry') { 505 | for (const key in oldProps) { 506 | const isAttribute = (oldProps[key] as OGL.Attribute)?.data || (newProps[key] as OGL.Attribute)?.data 507 | if (isAttribute && oldProps[key] !== newProps[key]) { 508 | reconstruct = true 509 | break 510 | } 511 | } 512 | } 513 | // If the instance has new args, recreate it 514 | else if (newProps.args?.length !== oldProps.args?.length) reconstruct = true 515 | else if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) reconstruct = true 516 | 517 | // If flagged for recreation, swap to a new instance. 518 | if (reconstruct) return switchInstance(instance, type, newProps, fiber) 519 | 520 | // Diff through props and flag with changes 521 | const changedProps = diffProps(instance, newProps, oldProps) 522 | if (Object.keys(changedProps).length) { 523 | // Handle attach update 524 | if (changedProps?.attach) { 525 | if (oldProps.attach) detach(instance.parent!, instance) 526 | instance.props.attach = newProps.attach 527 | if (newProps.attach) attach(instance.parent!, instance) 528 | } 529 | 530 | // Update instance props 531 | Object.assign(instance.props, changedProps) 532 | // Apply changed props 533 | applyProps(instance.object, changedProps) 534 | } 535 | }, 536 | // Methods to toggle instance visibility on demand. 537 | // React uses this with React.Suspense to display fallback content 538 | hideInstance(instance) { 539 | if (instance.object instanceof OGL.Transform) { 540 | instance.object.visible = false 541 | } 542 | 543 | instance.isHidden = true 544 | }, 545 | unhideInstance(instance) { 546 | if (instance.isHidden && instance.object instanceof OGL.Transform && instance.props.visible !== false) { 547 | instance.object.visible = true 548 | } 549 | 550 | instance.isHidden = false 551 | }, 552 | // Configures a callback once the tree is finalized after commit-effects are fired 553 | finalizeInitialChildren: () => false, 554 | commitMount() {}, 555 | // Undocumented 556 | getInstanceFromNode: () => null, 557 | beforeActiveInstanceBlur() {}, 558 | afterActiveInstanceBlur() {}, 559 | detachDeletedInstance() {}, 560 | prepareScopeUpdate() {}, 561 | getInstanceFromScope: () => null, 562 | shouldAttemptEagerTransition: () => false, 563 | trackSchedulerEvent: () => {}, 564 | resolveEventType: () => null, 565 | resolveEventTimeStamp: () => -1.1, 566 | requestPostPaintCallback() {}, 567 | maySuspendCommit: () => false, 568 | preloadInstance: () => true, // true indicates already loaded 569 | startSuspendingCommit() {}, 570 | suspendInstance() {}, 571 | waitForCommitToBeReady: () => null, 572 | NotPendingTransition: null, 573 | HostTransitionContext: /* @__PURE__ */ React.createContext(null), 574 | setCurrentUpdatePriority(newPriority: number) { 575 | currentUpdatePriority = newPriority 576 | }, 577 | getCurrentUpdatePriority() { 578 | return currentUpdatePriority 579 | }, 580 | resolveUpdatePriority() { 581 | if (currentUpdatePriority !== NoEventPriority) return currentUpdatePriority 582 | 583 | switch (typeof window !== 'undefined' && window.event?.type) { 584 | case 'click': 585 | case 'contextmenu': 586 | case 'dblclick': 587 | case 'pointercancel': 588 | case 'pointerdown': 589 | case 'pointerup': 590 | return DiscreteEventPriority 591 | case 'pointermove': 592 | case 'pointerout': 593 | case 'pointerover': 594 | case 'pointerenter': 595 | case 'pointerleave': 596 | case 'wheel': 597 | return ContinuousEventPriority 598 | default: 599 | return DefaultEventPriority 600 | } 601 | }, 602 | resetFormInstance() {}, 603 | }) 604 | -------------------------------------------------------------------------------- /src/renderer.tsx: -------------------------------------------------------------------------------- 1 | import * as OGL from 'ogl' 2 | import * as React from 'react' 3 | import { ConcurrentRoot } from 'react-reconciler/constants.js' 4 | import { create } from 'zustand' 5 | import { reconciler } from './reconciler' 6 | import { OGLContext, useStore, useIsomorphicLayoutEffect } from './hooks' 7 | import { RenderProps, Root, RootState, RootStore, Subscription } from './types' 8 | import { applyProps, calculateDpr, prepare } from './utils' 9 | 10 | // Store roots here since we can render to multiple targets 11 | const roots = new Map() 12 | 13 | /** 14 | * Renders React elements into OGL elements. 15 | */ 16 | export function render( 17 | element: React.ReactNode, 18 | target: HTMLCanvasElement, 19 | { 20 | dpr = [1, 2], 21 | size = { width: target.parentElement?.clientWidth ?? 0, height: target.parentElement?.clientHeight ?? 0 }, 22 | frameloop = 'always', 23 | orthographic = false, 24 | events, 25 | ...config 26 | }: RenderProps = {}, 27 | ) { 28 | // Check for existing root, create on first run 29 | let root = roots.get(target) 30 | if (!root) { 31 | // Create root store 32 | const store = create((set, get) => { 33 | // Create renderer 34 | const renderer = 35 | config.renderer instanceof OGL.Renderer 36 | ? config.renderer 37 | : typeof config.renderer === 'function' 38 | ? config.renderer(target) 39 | : new OGL.Renderer({ 40 | alpha: true, 41 | antialias: true, 42 | powerPreference: 'high-performance', 43 | ...(config.renderer as any), 44 | canvas: target, 45 | }) 46 | if (config.renderer && typeof config.renderer !== 'function') applyProps(renderer as any, config.renderer as any) 47 | 48 | renderer.dpr = calculateDpr(dpr) 49 | 50 | const gl = renderer.gl 51 | gl.clearColor(1, 1, 1, 0) 52 | 53 | // Create or accept camera, apply props 54 | const camera = 55 | config.camera instanceof OGL.Camera 56 | ? config.camera 57 | : new OGL.Camera(gl, { fov: 75, near: 1, far: 1000, ...(config.camera as any) }) 58 | camera.position.z = 5 59 | if (config.camera) applyProps(camera as any, config.camera as any) 60 | 61 | return { 62 | size, 63 | xr: { 64 | session: null, 65 | setSession(session) { 66 | set((state) => ({ xr: { ...state.xr, session } })) 67 | }, 68 | connect(session) { 69 | get().xr.setSession(session) 70 | }, 71 | disconnect() { 72 | get().xr.setSession(null) 73 | }, 74 | }, 75 | renderer, 76 | frameloop, 77 | orthographic, 78 | gl, 79 | camera, 80 | scene: config.scene ?? new OGL.Transform(), 81 | priority: 0, 82 | subscribed: [], 83 | // Subscribe/unsubscribe elements to the render loop 84 | subscribe(refCallback: React.RefObject, renderPriority = 0) { 85 | // Subscribe callback 86 | const { subscribed } = get() 87 | subscribed.push(refCallback) 88 | 89 | // Enable manual rendering if renderPriority is positive 90 | set((state) => ({ priority: state.priority + renderPriority })) 91 | }, 92 | unsubscribe(refCallback: React.RefObject, renderPriority = 0) { 93 | // Unsubscribe callback 94 | const { subscribed } = get() 95 | const index = subscribed.indexOf(refCallback) 96 | if (index !== -1) subscribed.splice(index, 1) 97 | 98 | // Disable manual rendering if renderPriority is positive 99 | set((state) => ({ priority: state.priority - renderPriority })) 100 | }, 101 | events, 102 | mouse: new OGL.Vec2(), 103 | raycaster: new OGL.Raycast(), 104 | hovered: new Map(), 105 | set, 106 | get, 107 | } as RootState 108 | }) as RootStore 109 | 110 | // Prepare scene 111 | const state = store.getState() 112 | prepare(state.scene, store, '', {}) 113 | 114 | // Bind events 115 | if (state.events?.connect && !state.events?.connected) state.events.connect(target, state) 116 | 117 | // Handle callback 118 | config.onCreated?.(state) 119 | 120 | // Toggle rendering modes 121 | let nextFrame: number 122 | function animate(time = 0, frame?: XRFrame) { 123 | // Toggle XR rendering 124 | const state = store.getState() 125 | const mode = state.xr.session ?? window 126 | 127 | // Cancel animation if frameloop is set, otherwise keep looping 128 | if (state.frameloop === 'never') return mode.cancelAnimationFrame(nextFrame) 129 | nextFrame = mode.requestAnimationFrame(animate) 130 | 131 | // Call subscribed elements 132 | for (const ref of state.subscribed) ref.current?.(state, time, frame) 133 | 134 | // If rendering manually, skip render 135 | if (state.priority) return 136 | 137 | // Render to screen 138 | state.renderer.render({ scene: state.scene, camera: state.camera }) 139 | } 140 | if (state.frameloop !== 'never') animate() 141 | 142 | // Handle resize 143 | function onResize(state: RootState) { 144 | const { width, height } = state.size 145 | const projection = state.orthographic ? 'orthographic' : 'perspective' 146 | 147 | if (state.renderer.width !== width || state.renderer.height !== height || state.camera.type !== projection) { 148 | state.renderer.setSize(width, height) 149 | state.camera[projection]({ aspect: width / height }) 150 | } 151 | } 152 | store.subscribe(onResize) 153 | onResize(state) 154 | 155 | // Report when an error was detected in a previous render 156 | const logRecoverableError = typeof reportError === 'function' ? reportError : console.error 157 | 158 | // Create root container 159 | const container = (reconciler as any).createContainer( 160 | store, // containerInfo 161 | ConcurrentRoot, // tag 162 | null, // hydrationCallbacks 163 | false, // isStrictMode 164 | null, // concurrentUpdatesByDefaultOverride 165 | '', // identifierPrefix 166 | logRecoverableError, // onUncaughtError 167 | logRecoverableError, // onCaughtError 168 | logRecoverableError, // onRecoverableError 169 | null, // transitionCallbacks 170 | ) 171 | 172 | // Set root 173 | root = { container, store } 174 | roots.set(target, root) 175 | } 176 | 177 | // Update reactive props 178 | const state = root.store.getState() 179 | if (state.size.width !== size.width || state.size.height !== size.height) state.set(() => ({ size })) 180 | if (state.frameloop !== frameloop) state.set(() => ({ frameloop })) 181 | if (state.orthographic !== orthographic) state.set(() => ({ orthographic })) 182 | 183 | // Update contanier 184 | reconciler.updateContainer( 185 | {element}, 186 | root.container, 187 | null, 188 | () => undefined, 189 | ) 190 | 191 | return root.store 192 | } 193 | 194 | /** 195 | * Removes and cleans up internals on unmount. 196 | */ 197 | export function unmountComponentAtNode(target: HTMLCanvasElement) { 198 | const root = roots.get(target) 199 | if (!root) return 200 | 201 | // Clear container 202 | reconciler.updateContainer(null, root.container, null, () => { 203 | // Delete root 204 | roots.delete(target) 205 | 206 | const state = root.store.getState() 207 | 208 | // Cancel animation 209 | state.set(() => ({ frameloop: 'never' })) 210 | 211 | // Unbind events 212 | if (state.events?.disconnect) state.events.disconnect(target, state) 213 | }) 214 | } 215 | 216 | /** 217 | * Creates a root to safely render/unmount. 218 | */ 219 | export const createRoot = (target: HTMLCanvasElement, config?: RenderProps): Root => ({ 220 | render: (element) => render(element, target, config), 221 | unmount: () => unmountComponentAtNode(target), 222 | }) 223 | 224 | interface PortalRootProps { 225 | children: React.ReactElement 226 | target: OGL.Transform 227 | state?: Partial 228 | } 229 | function PortalRoot({ children, target, state }: PortalRootProps): React.JSX.Element { 230 | const store = useStore() 231 | const container = React.useMemo( 232 | () => 233 | create((set, get) => ({ 234 | ...store.getState(), 235 | set, 236 | get, 237 | scene: target, 238 | })), 239 | [store, target], 240 | ) 241 | 242 | useIsomorphicLayoutEffect(() => { 243 | const { set, get, scene } = container.getState() 244 | return store.subscribe((parentState) => container.setState({ ...parentState, ...state, set, get, scene })) 245 | }, [container, store, state]) 246 | 247 | return ( 248 | // @ts-expect-error 249 | <> 250 | {reconciler.createPortal( 251 | {children}, 252 | container, 253 | null, 254 | null, 255 | )} 256 | 257 | ) 258 | } 259 | 260 | /** 261 | * Portals into a remote OGL element. 262 | */ 263 | export function createPortal( 264 | children: React.ReactElement, 265 | target: OGL.Transform, 266 | state?: Partial, 267 | ): React.JSX.Element { 268 | return ( 269 | 270 | {children} 271 | 272 | ) 273 | } 274 | 275 | /** 276 | * Force React to flush any updates inside the provided callback synchronously and immediately. 277 | */ 278 | export function flushSync(fn: () => R): R { 279 | return reconciler.flushSync(fn) 280 | } 281 | -------------------------------------------------------------------------------- /src/types.ts: -------------------------------------------------------------------------------- 1 | /// 2 | import type * as OGL from 'ogl' 3 | import type * as React from 'react' 4 | import type {} from 'react/jsx-runtime' 5 | import type {} from 'react/jsx-dev-runtime' 6 | import type { UseBoundStore, StoreApi } from 'zustand' 7 | 8 | type Mutable

= { [K in keyof P]: P[K] | Readonly } 9 | type NonFunctionKeys

= { [K in keyof P]-?: P[K] extends Function ? never : K }[keyof P] 10 | type Overwrite = Omit> & O 11 | type Filter = T extends [] 12 | ? [] 13 | : T extends [infer H, ...infer R] 14 | ? H extends O 15 | ? Filter 16 | : [H, ...Filter] 17 | : T 18 | 19 | export interface OGLEvent extends Partial { 20 | nativeEvent: TEvent 21 | } 22 | 23 | export interface EventHandlers { 24 | /** Fired when the mesh is clicked or tapped. */ 25 | onClick?: (event: OGLEvent) => void 26 | /** Fired when a pointer becomes inactive over the mesh. */ 27 | onPointerUp?: (event: OGLEvent) => void 28 | /** Fired when a pointer becomes active over the mesh. */ 29 | onPointerDown?: (event: OGLEvent) => void 30 | /** Fired when a pointer moves over the mesh. */ 31 | onPointerMove?: (event: OGLEvent) => void 32 | /** Fired when a pointer enters the mesh's bounds. */ 33 | onPointerOver?: (event: OGLEvent) => void 34 | /** Fired when a pointer leaves the mesh's bounds. */ 35 | onPointerOut?: (event: OGLEvent) => void 36 | } 37 | 38 | export interface XRManager { 39 | session: XRSession | null 40 | setSession(session: XRSession | null): void 41 | connect(session: XRSession): void 42 | disconnect(): void 43 | } 44 | 45 | export interface EventManager { 46 | connected: boolean 47 | connect: (target: HTMLCanvasElement, state: RootState) => void 48 | disconnect: (target: HTMLCanvasElement, state: RootState) => void 49 | [name: string]: any 50 | } 51 | 52 | export interface Size { 53 | width: number 54 | height: number 55 | } 56 | 57 | export type Frameloop = 'always' | 'never' 58 | 59 | export type Subscription = (state: RootState, time: number, frame?: XRFrame) => any 60 | 61 | export interface RootState { 62 | set: StoreApi['setState'] 63 | get: StoreApi['getState'] 64 | size: Size 65 | xr: XRManager 66 | orthographic: boolean 67 | frameloop: Frameloop 68 | renderer: OGL.Renderer 69 | gl: OGL.OGLRenderingContext 70 | scene: OGL.Transform 71 | camera: OGL.Camera 72 | priority: number 73 | subscribed: React.RefObject[] 74 | subscribe: (refCallback: React.RefObject, renderPriority?: number) => void 75 | unsubscribe: (refCallback: React.RefObject, renderPriority?: number) => void 76 | events?: EventManager 77 | mouse?: OGL.Vec2 78 | raycaster?: OGL.Raycast 79 | hovered?: Map['object']> 80 | [key: string]: any 81 | } 82 | 83 | export type Act = (cb: () => Promise) => Promise 84 | 85 | export type RootStore = UseBoundStore> 86 | 87 | export interface Root { 88 | render: (element: React.ReactNode) => RootStore 89 | unmount: () => void 90 | } 91 | 92 | export type DPR = [number, number] | number 93 | 94 | export interface RenderProps { 95 | size?: Size 96 | orthographic?: boolean 97 | frameloop?: Frameloop 98 | renderer?: 99 | | ((canvas: HTMLCanvasElement) => OGL.Renderer) 100 | | OGL.Renderer 101 | | OGLElement 102 | | Partial 103 | gl?: OGL.OGLRenderingContext 104 | dpr?: DPR 105 | camera?: OGL.Camera | OGLElement | Partial 106 | scene?: OGL.Transform 107 | events?: EventManager 108 | onCreated?: (state: RootState) => any 109 | } 110 | 111 | export type Attach = string | ((parent: any, self: O) => () => void) 112 | 113 | export type ConstructorRepresentation = new (...args: any[]) => any 114 | 115 | export interface Catalogue { 116 | [name: string]: ConstructorRepresentation 117 | } 118 | 119 | export type Args = T extends ConstructorRepresentation ? ConstructorParameters : any[] 120 | 121 | export interface InstanceProps { 122 | args?: Filter, OGL.OGLRenderingContext> 123 | object?: T 124 | visible?: boolean 125 | dispose?: null 126 | attach?: Attach 127 | } 128 | 129 | export interface Instance { 130 | root: RootStore 131 | parent: Instance | null 132 | children: Instance[] 133 | type: string 134 | props: InstanceProps & Record 135 | object: O & { __ogl?: Instance; __handlers: Partial } 136 | isHidden: boolean 137 | } 138 | 139 | interface MathRepresentation { 140 | set(...args: any[]): any 141 | } 142 | type MathProps

= { 143 | [K in keyof P]: P[K] extends infer M ? (M extends MathRepresentation ? M | Parameters | number : {}) : {} 144 | } 145 | 146 | type EventProps

= P extends OGL.Mesh ? Partial : {} 147 | 148 | interface ReactProps

{ 149 | children?: React.ReactNode 150 | ref?: React.Ref

151 | key?: React.Key 152 | } 153 | 154 | type OGLElementProps> = Partial< 155 | Overwrite & MathProps

& EventProps

> 156 | > 157 | 158 | export type OGLElement = Mutable< 159 | Overwrite, Omit>, 'object'>> 160 | > 161 | 162 | type OGLExports = typeof OGL 163 | type OGLElementsImpl = { 164 | [K in keyof OGLExports as Uncapitalize]: OGLExports[K] extends ConstructorRepresentation 165 | ? OGLElement 166 | : never 167 | } 168 | 169 | type ColorNames = 'black' | 'white' | 'red' | 'green' | 'blue' | 'fuchsia' | 'cyan' | 'yellow' | 'orange' 170 | type UniformValue = ColorNames | number | number[] | OGL.Texture | OGL.Texture[] 171 | type UniformRepresentation = UniformValue | { [structName: string]: UniformValue } 172 | type UniformList = { 173 | [uniform: string]: UniformRepresentation | { value: UniformRepresentation } 174 | } 175 | 176 | export interface OGLElements extends OGLElementsImpl { 177 | primitive: Omit, 'args'> & { object: any } 178 | program: Overwrite< 179 | OGLElement, 180 | { 181 | vertex?: string 182 | fragment?: string 183 | uniforms?: UniformList 184 | } 185 | > 186 | geometry: OGLElement & { 187 | [name: string]: Partial> & Required> 188 | } 189 | } 190 | 191 | declare module 'react' { 192 | namespace JSX { 193 | interface IntrinsicElements extends OGLElements {} 194 | } 195 | } 196 | 197 | declare module 'react/jsx-runtime' { 198 | namespace JSX { 199 | interface IntrinsicElements extends OGLElements {} 200 | } 201 | } 202 | 203 | declare module 'react/jsx-dev-runtime' { 204 | namespace JSX { 205 | interface IntrinsicElements extends OGLElements {} 206 | } 207 | } 208 | -------------------------------------------------------------------------------- /src/utils.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as OGL from 'ogl' 3 | import type { Fiber } from 'react-reconciler' 4 | import { RESERVED_PROPS, INSTANCE_PROPS, POINTER_EVENTS } from './constants' 5 | import { useIsomorphicLayoutEffect } from './hooks' 6 | import { ConstructorRepresentation, DPR, EventHandlers, Instance, RootState, RootStore } from './types' 7 | 8 | /** 9 | * Converts camelCase primitives to PascalCase. 10 | */ 11 | export const toPascalCase = (str: string) => str.charAt(0).toUpperCase() + str.substring(1) 12 | 13 | /** 14 | * Checks for inheritance between two classes. 15 | */ 16 | export const classExtends = (a: any, b: any) => (Object.prototype.isPrototypeOf.call(a, b) as boolean) || a === b 17 | 18 | /** 19 | * Interpolates DPR from [min, max] based on device capabilities. 20 | */ 21 | export const calculateDpr = (dpr: DPR) => 22 | Array.isArray(dpr) ? Math.min(Math.max(dpr[0], window.devicePixelRatio), dpr[1]) : dpr 23 | 24 | /** 25 | * Returns only instance props from reconciler fibers. 26 | */ 27 | export function getInstanceProps(queue: Fiber['pendingProps']): Instance['props'] { 28 | const props: Instance['props'] = {} 29 | 30 | for (const key in queue) { 31 | if (!RESERVED_PROPS.includes(key)) props[key] = queue[key] 32 | } 33 | 34 | return props 35 | } 36 | 37 | /** 38 | * Prepares an object, returning an instance descriptor. 39 | */ 40 | export function prepare(target: T, root: RootStore, type: string, props: Instance['props']): Instance { 41 | const object = (target as unknown as Instance['object']) ?? {} 42 | 43 | // Create instance descriptor 44 | let instance = object.__ogl 45 | if (!instance) { 46 | instance = { 47 | root, 48 | parent: null, 49 | children: [], 50 | type, 51 | props: getInstanceProps(props), 52 | object, 53 | isHidden: false, 54 | } 55 | object.__ogl = instance 56 | } 57 | 58 | return instance 59 | } 60 | 61 | /** 62 | * Resolves a potentially pierced key type against an object. 63 | */ 64 | export function resolve(root: any, key: string) { 65 | let target = root[key] 66 | if (!key.includes('-')) return { root, key, target } 67 | 68 | // Resolve pierced target 69 | const chain = key.split('-') 70 | target = chain.reduce((acc, key) => acc[key], root) 71 | key = chain.pop()! 72 | 73 | // Switch root if atomic 74 | if (!target?.set) root = chain.reduce((acc, key) => acc[key], root) 75 | 76 | return { root, key, target } 77 | } 78 | 79 | // Checks if a dash-cased string ends with an integer 80 | const INDEX_REGEX = /-\d+$/ 81 | 82 | /** 83 | * Attaches an instance to a parent via its `attach` prop. 84 | */ 85 | export function attach(parent: Instance, child: Instance) { 86 | if (typeof child.props.attach === 'string') { 87 | // If attaching into an array (foo-0), create one 88 | if (INDEX_REGEX.test(child.props.attach)) { 89 | const target = child.props.attach.replace(INDEX_REGEX, '') 90 | const { root, key } = resolve(parent.object, target) 91 | if (!Array.isArray(root[key])) root[key] = [] 92 | } 93 | 94 | const { root, key } = resolve(parent.object, child.props.attach) 95 | child.object.__previousAttach = root[key] 96 | root[key] = child.object 97 | child.object.__currentAttach = parent.object.__currentAttach = root[key] 98 | } else if (typeof child.props.attach === 'function') { 99 | child.object.__previousAttach = child.props.attach(parent.object, child.object) 100 | } 101 | } 102 | 103 | /** 104 | * Removes an instance from a parent via its `attach` prop. 105 | */ 106 | export function detach(parent: Instance, child: Instance) { 107 | if (typeof child.props.attach === 'string') { 108 | // Reset parent key if last attached 109 | if (parent.object.__currentAttach === child.object.__currentAttach) { 110 | const { root, key } = resolve(parent.object, child.props.attach) 111 | root[key] = child.object.__previousAttach 112 | } 113 | } else { 114 | child.object.__previousAttach(parent.object, child.object) 115 | } 116 | 117 | delete child.object.__previousAttach 118 | delete child.object.__currentAttach 119 | delete parent.object.__currentAttach 120 | } 121 | 122 | /** 123 | * Safely mutates an OGL element, respecting special JSX syntax. 124 | */ 125 | export function applyProps(target: T, newProps: Instance['props'], oldProps?: Instance['props']): void { 126 | const object = target as Instance['object'] 127 | 128 | // Mutate our OGL element 129 | for (const prop in newProps) { 130 | // Don't mutate reserved keys 131 | if (RESERVED_PROPS.includes(prop as typeof RESERVED_PROPS[number])) continue 132 | if (INSTANCE_PROPS.includes(prop as typeof INSTANCE_PROPS[number])) continue 133 | 134 | // Don't mutate unchanged keys 135 | if (newProps[prop] === oldProps?.[prop]) continue 136 | 137 | // Collect event handlers 138 | const isHandler = POINTER_EVENTS.includes(prop as typeof POINTER_EVENTS[number]) 139 | if (isHandler) { 140 | object.__handlers = { ...object.__handlers, [prop]: newProps[prop] } 141 | continue 142 | } 143 | 144 | const value = newProps[prop] 145 | const { root, key, target } = resolve(object, prop) 146 | 147 | // Prefer to use properties' copy and set methods 148 | // otherwise, mutate the property directly 149 | const isMathClass = typeof target?.set === 'function' && typeof target?.copy === 'function' 150 | if (!ArrayBuffer.isView(value) && isMathClass) { 151 | if (target.constructor === (value as ConstructorRepresentation).constructor) { 152 | target.copy(value) 153 | } else if (Array.isArray(value)) { 154 | target.set(...value) 155 | } else { 156 | // Support shorthand scalar syntax like scale={1} 157 | const scalar = new Array(target.length).fill(value) 158 | target.set(...scalar) 159 | } 160 | } else { 161 | // Allow shorthand values for uniforms 162 | const uniformList = value as any 163 | if (key === 'uniforms') { 164 | for (const uniform in uniformList) { 165 | // @ts-ignore 166 | let uniformValue = uniformList[uniform]?.value ?? uniformList[uniform] 167 | 168 | // Handle uniforms shorthand 169 | if (typeof uniformValue === 'string') { 170 | // Uniform is a string, convert it into a color 171 | uniformValue = new OGL.Color(uniformValue) 172 | } else if ( 173 | uniformValue?.constructor === Array && 174 | (uniformValue as any[]).every((v: any) => typeof v === 'number') 175 | ) { 176 | // @ts-ignore Uniform is an array, convert it into a vector 177 | uniformValue = new OGL[`Vec${uniformValue.length}`](...uniformValue) 178 | } 179 | 180 | root.uniforms[uniform] = { value: uniformValue } 181 | } 182 | } else { 183 | // Mutate the property directly 184 | root[key] = value 185 | } 186 | } 187 | } 188 | } 189 | 190 | /** 191 | * Creates event handlers, returning an event handler method. 192 | */ 193 | export function createEvents(state: RootState) { 194 | const handleEvent = (event: PointerEvent, type: keyof EventHandlers) => { 195 | // Convert mouse coordinates 196 | state.mouse!.x = (event.offsetX / state.size.width) * 2 - 1 197 | state.mouse!.y = -(event.offsetY / state.size.height) * 2 + 1 198 | 199 | // Filter to interactive meshes 200 | const interactive: OGL.Mesh[] = [] 201 | state.scene.traverse((node: OGL.Transform) => { 202 | // Mesh has registered events and a defined volume 203 | if ( 204 | node instanceof OGL.Mesh && 205 | (node as Instance['object']).__handlers && 206 | node.geometry?.attributes?.position 207 | ) 208 | interactive.push(node) 209 | }) 210 | 211 | // Get elements that intersect with our pointer 212 | state.raycaster!.castMouse(state.camera, state.mouse) 213 | const intersects: OGL.Mesh[] = state.raycaster!.intersectMeshes(interactive) 214 | 215 | // Used to discern between generic events and custom hover events. 216 | // We hijack the pointermove event to handle hover state 217 | const isHoverEvent = type === 'onPointerMove' 218 | 219 | // Trigger events for hovered elements 220 | for (const entry of intersects) { 221 | // Bail if object doesn't have handlers (managed externally) 222 | if (!(entry as unknown as any).__handlers) continue 223 | 224 | const object = entry as Instance['object'] 225 | const handlers = object.__handlers 226 | 227 | if (isHoverEvent && !state.hovered!.get(object.id)) { 228 | // Mark object as hovered and fire its hover events 229 | state.hovered!.set(object.id, object) 230 | 231 | // Fire hover events 232 | handlers.onPointerMove?.({ ...object.hit, nativeEvent: event }) 233 | handlers.onPointerOver?.({ ...object.hit, nativeEvent: event }) 234 | } else { 235 | // Otherwise, fire its generic event 236 | handlers[type]?.({ ...object.hit, nativeEvent: event }) 237 | } 238 | } 239 | 240 | // Cleanup stale hover events 241 | if (isHoverEvent || type === 'onPointerDown') { 242 | state.hovered!.forEach((object) => { 243 | const handlers = object.__handlers 244 | 245 | if (!intersects.length || !intersects.find((i) => i === object)) { 246 | // Reset hover state 247 | state.hovered!.delete(object.id) 248 | 249 | // Fire unhover event 250 | if (handlers?.onPointerOut) handlers.onPointerOut({ ...object.hit, nativeEvent: event }) 251 | } 252 | }) 253 | } 254 | 255 | return intersects 256 | } 257 | 258 | return { handleEvent } 259 | } 260 | 261 | export type SetBlock = false | Promise | null 262 | 263 | /** 264 | * Used to block rendering via its `set` prop. Useful for suspenseful effects. 265 | */ 266 | export function Block({ set }: { set: React.Dispatch> }) { 267 | useIsomorphicLayoutEffect(() => { 268 | set(new Promise(() => null)) 269 | return () => set(false) 270 | }, []) 271 | 272 | return null 273 | } 274 | 275 | /** 276 | * Generic error boundary. Calls its `set` prop on error. 277 | */ 278 | export class ErrorBoundary extends React.Component< 279 | { set: React.Dispatch; children: React.ReactNode }, 280 | { error: boolean } 281 | > { 282 | state = { error: false } 283 | static getDerivedStateFromError = () => ({ error: true }) 284 | componentDidCatch(error: any) { 285 | this.props.set(error) 286 | } 287 | render() { 288 | return this.state.error ? null : this.props.children 289 | } 290 | } 291 | -------------------------------------------------------------------------------- /tests/__snapshots__/native.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Canvas should correctly mount 1`] = `null`; 4 | -------------------------------------------------------------------------------- /tests/__snapshots__/utils.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`applyProps should accept scalar shorthand 1`] = ` 4 | Array [ 5 | 3, 6 | 3, 7 | 3, 8 | ] 9 | `; 10 | 11 | exports[`applyProps should accept shorthand uniforms 1`] = ` 12 | Object { 13 | "bar": Object { 14 | "value": 1, 15 | }, 16 | "foo": Object { 17 | "value": 0, 18 | }, 19 | } 20 | `; 21 | 22 | exports[`applyProps should convert CSS color names to color uniforms 1`] = ` 23 | Object { 24 | "color": Object { 25 | "value": Color [ 26 | 1, 27 | 0, 28 | 0, 29 | ], 30 | }, 31 | } 32 | `; 33 | 34 | exports[`applyProps should convert arrays into vector uniforms 1`] = ` 35 | Object { 36 | "uv": Object { 37 | "value": Vec2 [ 38 | 0, 39 | 1, 40 | ], 41 | }, 42 | } 43 | `; 44 | 45 | exports[`applyProps should diff & merge uniforms 1`] = ` 46 | Object { 47 | "a": Object { 48 | "value": 0, 49 | }, 50 | "b": Object { 51 | "value": 1, 52 | }, 53 | "c": Object { 54 | "value": 2, 55 | }, 56 | } 57 | `; 58 | 59 | exports[`applyProps should pierce into nested properties 1`] = ` 60 | Object { 61 | "color": "red", 62 | } 63 | `; 64 | 65 | exports[`applyProps should spread array prop values 1`] = ` 66 | Array [ 67 | 1, 68 | 2, 69 | 3, 70 | ] 71 | `; 72 | -------------------------------------------------------------------------------- /tests/__snapshots__/web.test.tsx.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Canvas should correctly mount 1`] = ` 4 |

5 |
8 | 13 |
14 |
15 | `; 16 | -------------------------------------------------------------------------------- /tests/events.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { render, fireEvent } from '@testing-library/react' 3 | import { Canvas } from '../src' 4 | 5 | it('handles all interactive meshes', async () => { 6 | const canvas = React.createRef() 7 | const handleOnClick = jest.fn() 8 | 9 | await React.act(async () => { 10 | render( 11 | 12 | 13 | 14 | 15 | 16 | 17 | 18 | , 19 | ) 20 | }) 21 | 22 | const event = new MouseEvent('click') 23 | ;(event as any).offsetX = 640 24 | ;(event as any).offsetY = 400 25 | 26 | fireEvent(canvas.current!, event) 27 | 28 | expect(handleOnClick).toHaveBeenCalled() 29 | }) 30 | 31 | it('handles onClick', async () => { 32 | const canvas = React.createRef() 33 | const handleOnClick = jest.fn() 34 | 35 | await React.act(async () => { 36 | render( 37 | 38 | 39 | 40 | 41 | 42 | , 43 | ) 44 | }) 45 | 46 | const event = new MouseEvent('click') 47 | ;(event as any).offsetX = 640 48 | ;(event as any).offsetY = 400 49 | 50 | fireEvent(canvas.current!, event) 51 | 52 | expect(handleOnClick).toHaveBeenCalled() 53 | }) 54 | 55 | it('handles onPointerUp', async () => { 56 | const canvas = React.createRef() 57 | const handlePointerUp = jest.fn() 58 | 59 | await React.act(async () => { 60 | render( 61 | 62 | 63 | 64 | 65 | 66 | , 67 | ) 68 | }) 69 | 70 | const event = new PointerEvent('pointerup') 71 | ;(event as any).offsetX = 640 72 | ;(event as any).offsetY = 400 73 | 74 | fireEvent(canvas.current!, event) 75 | 76 | expect(handlePointerUp).toHaveBeenCalled() 77 | }) 78 | 79 | it('handles onPointerDown', async () => { 80 | const canvas = React.createRef() 81 | const handlePointerDown = jest.fn() 82 | 83 | await React.act(async () => { 84 | render( 85 | 86 | 87 | 88 | 89 | 90 | , 91 | ) 92 | }) 93 | 94 | const event = new PointerEvent('pointerdown') 95 | ;(event as any).offsetX = 640 96 | ;(event as any).offsetY = 400 97 | 98 | fireEvent(canvas.current!, event) 99 | 100 | expect(handlePointerDown).toHaveBeenCalled() 101 | }) 102 | 103 | it('handles onPointerMove', async () => { 104 | const canvas = React.createRef() 105 | const handlePointerMove = jest.fn() 106 | 107 | await React.act(async () => { 108 | render( 109 | 110 | 111 | 112 | 113 | 114 | , 115 | ) 116 | }) 117 | 118 | const event = new PointerEvent('pointermove') 119 | ;(event as any).offsetX = 640 120 | ;(event as any).offsetY = 400 121 | 122 | fireEvent(canvas.current!, event) 123 | 124 | expect(handlePointerMove).toHaveBeenCalled() 125 | }) 126 | 127 | it('handles onPointerOver', async () => { 128 | const canvas = React.createRef() 129 | const handleOnPointerOver = jest.fn() 130 | 131 | await React.act(async () => { 132 | render( 133 | 134 | 135 | 136 | 137 | 138 | , 139 | ) 140 | }) 141 | 142 | const event = new PointerEvent('pointermove') 143 | ;(event as any).offsetX = 640 144 | ;(event as any).offsetY = 400 145 | 146 | fireEvent(canvas.current!, event) 147 | 148 | expect(handleOnPointerOver).toHaveBeenCalled() 149 | }) 150 | 151 | it('handles onPointerOut', async () => { 152 | const canvas = React.createRef() 153 | const handlePointerOut = jest.fn() 154 | 155 | await React.act(async () => { 156 | render( 157 | 158 | 159 | 160 | 161 | 162 | , 163 | ) 164 | }) 165 | 166 | // Move pointer over mesh 167 | const event = new PointerEvent('pointermove') 168 | ;(event as any).offsetX = 640 169 | ;(event as any).offsetY = 400 170 | fireEvent(canvas.current!, event) 171 | 172 | // Move pointer away from mesh 173 | const event2 = new PointerEvent('pointermove') 174 | ;(event2 as any).offsetX = 0 175 | ;(event2 as any).offsetY = 0 176 | 177 | fireEvent(canvas.current!, event2) 178 | 179 | expect(handlePointerOut).toHaveBeenCalled() 180 | }) 181 | -------------------------------------------------------------------------------- /tests/hooks.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as OGL from 'ogl' 3 | import { create } from 'zustand' 4 | import { render } from './utils' 5 | import { OGLContext, useOGL, useFrame, RootState, Subscription, Instance, useInstanceHandle } from '../src' 6 | 7 | describe('useOGL', () => { 8 | it('should return OGL state', async () => { 9 | let state: RootState = null! 10 | 11 | const Test = () => { 12 | state = useOGL() 13 | return null 14 | } 15 | 16 | await React.act(async () => { 17 | render( 18 | ({ test: 'test' })) as any}> 19 | 20 | , 21 | ) 22 | }) 23 | 24 | expect(state.test).toBe('test') 25 | }) 26 | 27 | it('should throw when used outside of context', async () => { 28 | let threw = false 29 | 30 | try { 31 | useOGL() 32 | } catch (_) { 33 | threw = true 34 | } 35 | 36 | expect(threw).toBe(true) 37 | }) 38 | }) 39 | 40 | describe('useFrame', () => { 41 | it('should subscribe an element to the frameloop', async () => { 42 | let state: RootState = null! 43 | let time: number = null! 44 | 45 | const subscribe = (callback: React.RefObject) => { 46 | callback.current('test' as any, 1) 47 | } 48 | 49 | const Test = () => { 50 | useFrame((...args) => { 51 | state = args[0] 52 | time = args[1] 53 | }) 54 | return null 55 | } 56 | 57 | await React.act(async () => { 58 | render( 59 | ({ subscribe })) as any}> 60 | 61 | , 62 | ) 63 | }) 64 | 65 | expect(state).toBeDefined() 66 | expect(time).toBeDefined() 67 | }) 68 | 69 | it('should accept render priority', async () => { 70 | let priority = 0 71 | 72 | const subscribe = (_: React.RefObject, renderPriority: number) => { 73 | if (renderPriority) priority += renderPriority 74 | } 75 | 76 | const Test = () => { 77 | useFrame(null!, 1) 78 | return null 79 | } 80 | 81 | await React.act(async () => { 82 | render( 83 | ({ subscribe })) as any}> 84 | 85 | , 86 | ) 87 | }) 88 | 89 | expect(priority).not.toBe(0) 90 | }) 91 | }) 92 | 93 | describe('useInstanceHandle', () => { 94 | it('should return Instance state', async () => { 95 | const ref = React.createRef() 96 | let instance!: React.RefObject 97 | 98 | const Component = () => { 99 | instance = useInstanceHandle(ref) 100 | return 101 | } 102 | await React.act(async () => render()) 103 | 104 | expect(instance.current).toBe((ref.current as unknown as any).__ogl) 105 | }) 106 | }) 107 | -------------------------------------------------------------------------------- /tests/index.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import * as OGL from 'ogl' 3 | import { render } from './utils' 4 | import { OGLElement, extend, createPortal } from '../src' 5 | 6 | class CustomElement extends OGL.Transform {} 7 | 8 | declare module '../src' { 9 | interface OGLElements { 10 | customElement: OGLElement 11 | } 12 | } 13 | 14 | describe('renderer', () => { 15 | it('should render JSX', async () => { 16 | const state = await React.act(async () => render()) 17 | expect(state.scene.children.length).not.toBe(0) 18 | }) 19 | 20 | it('should render extended elements', async () => { 21 | extend({ CustomElement }) 22 | const state = await React.act(async () => render()) 23 | expect(state.scene.children[0]).toBeInstanceOf(CustomElement) 24 | }) 25 | 26 | it('should go through lifecycle', async () => { 27 | const lifecycle: string[] = [] 28 | 29 | function Test() { 30 | React.useInsertionEffect(() => void lifecycle.push('useInsertionEffect'), []) 31 | React.useImperativeHandle(React.useRef(), () => void lifecycle.push('refCallback')) 32 | React.useLayoutEffect(() => void lifecycle.push('useLayoutEffect'), []) 33 | React.useEffect(() => void lifecycle.push('useEffect'), []) 34 | lifecycle.push('render') 35 | return ( 36 | void lifecycle.push('ref')} 38 | attach={() => (lifecycle.push('attach'), () => lifecycle.push('detach'))} 39 | /> 40 | ) 41 | } 42 | await React.act(async () => render()) 43 | 44 | expect(lifecycle).toStrictEqual([ 45 | 'render', 46 | 'useInsertionEffect', 47 | 'attach', 48 | 'ref', 49 | 'refCallback', 50 | 'useLayoutEffect', 51 | 'useEffect', 52 | ]) 53 | }) 54 | 55 | it('should set pierced props', async () => { 56 | const mesh = React.createRef() 57 | 58 | await React.act(async () => { 59 | render( 60 | 61 | 62 | 63 | , 64 | ) 65 | }) 66 | 67 | expect(Object.keys(mesh.current!.geometry.attributes)).toStrictEqual(['test']) 68 | }) 69 | 70 | it('should handle attach', async () => { 71 | const state = await React.act(async () => 72 | render( 73 | <> 74 | 75 | 76 | 77 | 78 | 79 | 80 | { 82 | parent.program = self 83 | return () => (parent.program = undefined) 84 | }} 85 | /> 86 | 87 | , 88 | ), 89 | ) 90 | 91 | const [element1, element2] = state.scene.children as OGL.Mesh[] 92 | 93 | expect(element1.program).not.toBe(undefined) 94 | expect(element2.program).not.toBe(undefined) 95 | }) 96 | 97 | it('should pass gl to args', async () => { 98 | let crashed = false 99 | 100 | try { 101 | await React.act(async () => render()) 102 | } catch (_) { 103 | crashed = true 104 | } 105 | 106 | expect(crashed).toBe(false) 107 | }) 108 | 109 | it('should accept vertex and fragment as program args', async () => { 110 | const vertex = 'vertex' 111 | const fragment = 'fragment' 112 | 113 | const state = await React.act(async () => 114 | render( 115 | 116 | 117 | 118 | , 119 | ), 120 | ) 121 | 122 | const [mesh] = state.scene.children as OGL.Mesh[] 123 | 124 | expect((mesh.program as any).vertex).toBe(vertex) 125 | expect((mesh.program as any).fragment).toBe(fragment) 126 | }) 127 | 128 | it('should update program uniforms reactively', async () => { 129 | const mesh = React.createRef() 130 | 131 | const Test = ({ value }: { value: any }) => ( 132 | 133 | 134 | 135 | 136 | ) 137 | 138 | await React.act(async () => render()) 139 | expect(mesh.current!.program.uniforms.uniform.value).toBe(false) 140 | 141 | await React.act(async () => render()) 142 | expect(mesh.current!.program.uniforms.uniform.value).toBe(true) 143 | }) 144 | 145 | it('should accept shorthand props as uniforms', async () => { 146 | const mesh = React.createRef() 147 | 148 | const renderer = new OGL.Renderer({ canvas: document.createElement('canvas') }) 149 | const texture = new OGL.Texture(renderer.gl) 150 | 151 | await React.act(async () => { 152 | render( 153 | 154 | 155 | 156 | , 157 | ) 158 | }) 159 | 160 | const { color, vector, textures } = mesh.current!.program.uniforms 161 | 162 | expect(color.value).toBeInstanceOf(OGL.Color) 163 | expect(vector.value).toBeInstanceOf(OGL.Vec3) 164 | expect(textures.value).toBeInstanceOf(Array) 165 | expect(textures.value[0]).toBe(texture) 166 | expect(textures.value[1]).toBe(texture) 167 | }) 168 | 169 | it('should accept props as geometry attributes', async () => { 170 | const mesh = React.createRef() 171 | 172 | const position = { size: 2, data: new Float32Array([-1, -1, 3, -1, -1, 3]) } 173 | const uv = { size: 2, data: new Float32Array([0, 0, 2, 0, 0, 2]) } 174 | 175 | await React.act(async () => { 176 | render( 177 | 178 | 179 | 180 | , 181 | ) 182 | }) 183 | 184 | expect(mesh.current!.geometry.attributes.position).toBeDefined() 185 | expect(mesh.current!.geometry.attributes.uv).toBeDefined() 186 | }) 187 | 188 | it('should bind & unbind events', async () => { 189 | let bind = false 190 | let unbind = false 191 | 192 | await React.act(async () => { 193 | const state = render(, { 194 | events: { 195 | connected: false, 196 | connect: () => (bind = true), 197 | disconnect: () => (unbind = true), 198 | }, 199 | }) 200 | state.root.unmount() 201 | }) 202 | 203 | expect(bind).toBe(true) 204 | expect(unbind).toBe(true) 205 | }) 206 | 207 | it('should create an identical instance when reconstructing', async () => { 208 | const object1 = new OGL.Transform() 209 | const object2 = new OGL.Transform() 210 | 211 | object1.addChild(new OGL.Transform()) 212 | object2.addChild(new OGL.Transform()) 213 | 214 | const Test = ({ n }: { n: number }) => ( 215 | 216 | 217 | 218 | 219 | ) 220 | 221 | let state = await React.act(async () => render()) 222 | 223 | const [oldInstance] = state.scene.children as any[] 224 | expect(oldInstance).toBe(object1) 225 | 226 | state = await React.act(async () => render()) 227 | 228 | const [newInstance] = state.scene.children as any[] 229 | expect(newInstance).toBe(object2) // Swapped to new instance 230 | expect(newInstance.children[1].visible).toBe(false) // Preserves scene hierarchy 231 | expect(newInstance.test.visible).toBe(true) // Preserves scene hierarchy through attach 232 | }) 233 | 234 | it('should prepare foreign objects when portaling', async () => { 235 | const object = new OGL.Transform() 236 | const mesh = React.createRef() 237 | 238 | const state = await React.act(async () => 239 | render( 240 | createPortal( 241 | 242 | 243 | 244 | , 245 | object, 246 | ), 247 | ), 248 | ) 249 | 250 | expect(state.scene.children.length).toBe(0) 251 | expect(object.children.length).not.toBe(0) 252 | expect(mesh.current!.parent).toBe(object) 253 | }) 254 | 255 | it('should update attach reactively', async () => { 256 | const mesh = React.createRef() 257 | const program1 = React.createRef() 258 | const program2 = React.createRef() 259 | 260 | const Test = ({ first = false, mono = false }) => ( 261 | 262 | 263 | 264 | {!mono && } 265 | 266 | ) 267 | 268 | await React.act(async () => render()) 269 | expect(mesh.current!.program).toBe(program1.current) 270 | 271 | await React.act(async () => render(, { frameloop: 'never' })) 272 | expect(mesh.current!.program).toBe(undefined) 273 | 274 | await React.act(async () => render()) 275 | expect(mesh.current!.program).toBe(program1.current) 276 | 277 | await React.act(async () => render()) 278 | expect(mesh.current!.program).toBe(program2.current) 279 | }) 280 | }) 281 | -------------------------------------------------------------------------------- /tests/native.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { View } from 'react-native' 3 | import { create, ReactTestRenderer } from 'react-test-renderer' 4 | import { Canvas } from '../src/Canvas.native' // explicitly require native module 5 | 6 | describe('Canvas', () => { 7 | it('should correctly mount', async () => { 8 | let renderer: ReactTestRenderer = null! 9 | 10 | await React.act(async () => { 11 | renderer = create( 12 | 13 | 14 | , 15 | ) 16 | }) 17 | 18 | expect(renderer.toJSON()).toMatchSnapshot() 19 | }) 20 | 21 | it('should forward ref', async () => { 22 | const ref = React.createRef() 23 | 24 | await React.act(async () => { 25 | create( 26 | 27 | 28 | , 29 | ) 30 | }) 31 | 32 | expect(ref.current).toBeDefined() 33 | }) 34 | 35 | it('should forward context', async () => { 36 | const ParentContext = React.createContext(null!) 37 | let receivedValue!: boolean 38 | 39 | function Test() { 40 | receivedValue = React.useContext(ParentContext) 41 | return null 42 | } 43 | 44 | await React.act(async () => { 45 | create( 46 | 47 | 48 | 49 | 50 | , 51 | ) 52 | }) 53 | 54 | expect(receivedValue).toBe(true) 55 | }) 56 | 57 | it('should correctly unmount', async () => { 58 | let renderer: ReactTestRenderer 59 | 60 | await React.act(async () => { 61 | renderer = create( 62 | 63 | 64 | , 65 | ) 66 | }) 67 | 68 | expect(async () => await React.act(async () => renderer.unmount())).not.toThrow() 69 | }) 70 | }) 71 | -------------------------------------------------------------------------------- /tests/utils.test.tsx: -------------------------------------------------------------------------------- 1 | import * as OGL from 'ogl' 2 | import { resolve, applyProps } from '../src/utils' 3 | import { RESERVED_PROPS, INSTANCE_PROPS } from '../src/constants' 4 | 5 | describe('resolve', () => { 6 | it('should resolve pierced props', () => { 7 | const object = { foo: { bar: 1 } } 8 | const { root, key, target } = resolve(object, 'foo-bar') 9 | 10 | expect(root).toBe(object['foo']) 11 | expect(key).toBe('bar') 12 | expect(target).toBe(root[key]) 13 | }) 14 | 15 | it('should switch roots for atomic targets', () => { 16 | const object = { foo: { bar: new OGL.Vec2() } } 17 | const { root, key, target } = resolve(object, 'foo-bar') 18 | 19 | expect(root).toBe(object) 20 | expect(key).toBe('bar') 21 | }) 22 | }) 23 | 24 | describe('applyProps', () => { 25 | it('should accept shorthand uniforms', async () => { 26 | const canvas = document.createElement('canvas') 27 | const gl = canvas.getContext('webgl2')! as OGL.OGLRenderingContext 28 | const program = new OGL.Program(gl, { 29 | vertex: ' ', 30 | fragment: ' ', 31 | uniforms: {}, 32 | }) 33 | 34 | applyProps(program, { 35 | uniforms: { 36 | foo: { value: 0 }, 37 | bar: 1, 38 | }, 39 | }) 40 | 41 | expect(program.uniforms).toMatchSnapshot() 42 | }) 43 | 44 | it('should convert CSS color names to color uniforms', async () => { 45 | const canvas = document.createElement('canvas') 46 | const gl = canvas.getContext('webgl2')! as OGL.OGLRenderingContext 47 | const program = new OGL.Program(gl, { 48 | vertex: ' ', 49 | fragment: ' ', 50 | uniforms: {}, 51 | }) 52 | 53 | applyProps(program, { 54 | uniforms: { 55 | color: 'red', 56 | }, 57 | }) 58 | 59 | expect(program.uniforms).toMatchSnapshot() 60 | }) 61 | 62 | it('should convert arrays into vector uniforms', async () => { 63 | const canvas = document.createElement('canvas') 64 | const gl = canvas.getContext('webgl2')! as OGL.OGLRenderingContext 65 | const program = new OGL.Program(gl, { 66 | vertex: ' ', 67 | fragment: ' ', 68 | uniforms: {}, 69 | }) 70 | 71 | applyProps(program, { 72 | uniforms: { 73 | uv: [0, 1], 74 | }, 75 | }) 76 | 77 | expect(program.uniforms).toMatchSnapshot() 78 | }) 79 | 80 | it('should diff & merge uniforms', async () => { 81 | const canvas = document.createElement('canvas') 82 | const gl = canvas.getContext('webgl2')! as OGL.OGLRenderingContext 83 | const program = new OGL.Program(gl, { 84 | vertex: ' ', 85 | fragment: ' ', 86 | uniforms: {}, 87 | }) 88 | 89 | applyProps(program, { 90 | uniforms: { 91 | a: 0, 92 | b: 1, 93 | c: null, 94 | }, 95 | }) 96 | applyProps(program, { uniforms: { c: 2 } }) 97 | 98 | expect(program.uniforms).toMatchSnapshot() 99 | }) 100 | 101 | it('should pierce into nested properties', async () => { 102 | const canvas = document.createElement('canvas') 103 | const gl = canvas.getContext('webgl2')! as OGL.OGLRenderingContext 104 | const program = new OGL.Program(gl, { 105 | vertex: ' ', 106 | fragment: ' ', 107 | uniforms: {}, 108 | }) 109 | 110 | applyProps(program, { 111 | 'uniforms-color': 'red', 112 | }) 113 | 114 | expect(program.uniforms).toMatchSnapshot() 115 | }) 116 | 117 | it('should prefer to copy from external props', async () => { 118 | const target = { color: new OGL.Color() } 119 | target.color.copy = jest.fn() 120 | 121 | applyProps(target, { 122 | color: new OGL.Color(), 123 | }) 124 | 125 | expect(target.color).toBeInstanceOf(OGL.Color) 126 | expect(target.color.copy).toHaveBeenCalled() 127 | }) 128 | 129 | it('should spread array prop values', async () => { 130 | const target = { position: new OGL.Vec3() } 131 | 132 | applyProps(target, { 133 | position: [1, 2, 3], 134 | }) 135 | 136 | expect(target.position).toBeInstanceOf(OGL.Vec3) 137 | expect(Array.from(target.position)).toMatchSnapshot() 138 | }) 139 | 140 | it('should accept scalar shorthand', async () => { 141 | const target = { position: new OGL.Vec3() } 142 | 143 | applyProps(target, { 144 | position: 3, 145 | }) 146 | 147 | expect(target.position).toBeInstanceOf(OGL.Vec3) 148 | expect(Array.from(target.position)).toMatchSnapshot() 149 | }) 150 | 151 | it('should properly set array-like buffer views', async () => { 152 | const target = { pixel: null } 153 | const pixel = new Uint8Array([255, 0, 0, 255]) 154 | 155 | applyProps(target, { pixel }) 156 | 157 | expect(target.pixel).toBe(pixel) 158 | }) 159 | 160 | it('should properly set non-math classes who implement set', async () => { 161 | const target = { test: new Map() } 162 | const test = new Map() 163 | test.set(1, 2) 164 | 165 | applyProps(target, { test }) 166 | 167 | expect(target.test).toBe(test) 168 | }) 169 | 170 | it('should not set react internal and react-ogl instance props', async () => { 171 | const target: any = {} 172 | 173 | applyProps( 174 | target, 175 | [...RESERVED_PROPS, ...INSTANCE_PROPS].reduce((acc, key, value) => ({ ...acc, [key]: value }), {}), 176 | ) 177 | 178 | Object.keys(target).forEach((key) => { 179 | expect(RESERVED_PROPS).not.toContain(key) 180 | expect(INSTANCE_PROPS).not.toContain(key) 181 | }) 182 | }) 183 | }) 184 | -------------------------------------------------------------------------------- /tests/utils/WebGLRenderingContext.ts: -------------------------------------------------------------------------------- 1 | const functions = [ 2 | 'attachShader', 3 | 'bindAttribLocation', 4 | 'bindBuffer', 5 | 'bindFramebuffer', 6 | 'bindRenderbuffer', 7 | 'bindTexture', 8 | 'blendColor', 9 | 'blendEquation', 10 | 'blendEquationSeparate', 11 | 'blendFunc', 12 | 'blendFuncSeparate', 13 | 'bufferData', 14 | 'bufferSubData', 15 | 'checkFramebufferStatus', 16 | 'clear', 17 | 'clearColor', 18 | 'clearDepth', 19 | 'clearStencil', 20 | 'colorMask', 21 | 'compileShader', 22 | 'compressedTexImage2D', 23 | 'compressedTexSubImage2D', 24 | 'copyTexImage2D', 25 | 'copyTexSubImage2D', 26 | 'createBuffer', 27 | 'createFramebuffer', 28 | 'createProgram', 29 | 'createRenderbuffer', 30 | 'createShader', 31 | 'createTexture', 32 | 'cullFace', 33 | 'deleteBuffer', 34 | 'deleteFramebuffer', 35 | 'deleteProgram', 36 | 'deleteRenderbuffer', 37 | 'deleteShader', 38 | 'deleteTexture', 39 | 'depthFunc', 40 | 'depthMask', 41 | 'depthRange', 42 | 'detachShader', 43 | 'disable', 44 | 'disableVertexAttribArray', 45 | 'drawArrays', 46 | 'drawElements', 47 | 'enable', 48 | 'enableVertexAttribArray', 49 | 'finish', 50 | 'flush', 51 | 'framebufferRenderbuffer', 52 | 'framebufferTexture2D', 53 | 'frontFace', 54 | 'generateMipmap', 55 | 'getActiveAttrib', 56 | 'getActiveUniform', 57 | 'getAttachedShaders', 58 | 'getAttribLocation', 59 | 'getBufferParameter', 60 | 'getContextAttributes', 61 | 'getError', 62 | 'getFramebufferAttachmentParameter', 63 | 'getProgramParameter', 64 | 'getRenderbufferParameter', 65 | 'getShaderParameter', 66 | 'getShaderSource', 67 | 'getSupportedExtensions', 68 | 'getTexParameter', 69 | 'getUniform', 70 | 'getUniformLocation', 71 | 'getVertexAttrib', 72 | 'getVertexAttribOffset', 73 | 'hint', 74 | 'isBuffer', 75 | 'isContextLost', 76 | 'isEnabled', 77 | 'isFramebuffer', 78 | 'isProgram', 79 | 'isRenderbuffer', 80 | 'isShader', 81 | 'isTexture', 82 | 'lineWidth', 83 | 'linkProgram', 84 | 'pixelStorei', 85 | 'polygonOffset', 86 | 'readPixels', 87 | 'renderbufferStorage', 88 | 'sampleCoverage', 89 | 'scissor', 90 | 'setPixelRatio', 91 | 'setSize', 92 | 'shaderSource', 93 | 'stencilFunc', 94 | 'stencilFuncSeparate', 95 | 'stencilMask', 96 | 'stencilMaskSeparate', 97 | 'stencilOp', 98 | 'stencilOpSeparate', 99 | 'texParameterf', 100 | 'texParameteri', 101 | 'texImage2D', 102 | 'texSubImage2D', 103 | 'uniform1f', 104 | 'uniform1fv', 105 | 'uniform1i', 106 | 'uniform1iv', 107 | 'uniform2f', 108 | 'uniform2fv', 109 | 'uniform2i', 110 | 'uniform2iv', 111 | 'uniform3f', 112 | 'uniform3fv', 113 | 'uniform3i', 114 | 'uniform3iv', 115 | 'uniform4f', 116 | 'uniform4fv', 117 | 'uniform4i', 118 | 'uniform4iv', 119 | 'uniformMatrix2fv', 120 | 'uniformMatrix3fv', 121 | 'uniformMatrix4fv', 122 | 'useProgram', 123 | 'validateProgram', 124 | 'vertexAttrib1f', 125 | 'vertexAttrib1fv', 126 | 'vertexAttrib2f', 127 | 'vertexAttrib2fv', 128 | 'vertexAttrib3f', 129 | 'vertexAttrib3fv', 130 | 'vertexAttrib4f', 131 | 'vertexAttrib4fv', 132 | 'vertexAttribPointer', 133 | 'viewport', 134 | ] 135 | 136 | const enums: { [key: string]: any } = { 137 | DEPTH_BUFFER_BIT: 256, 138 | STENCIL_BUFFER_BIT: 1024, 139 | COLOR_BUFFER_BIT: 16384, 140 | POINTS: 0, 141 | LINES: 1, 142 | LINE_LOOP: 2, 143 | LINE_STRIP: 3, 144 | TRIANGLES: 4, 145 | TRIANGLE_STRIP: 5, 146 | TRIANGLE_FAN: 6, 147 | ZERO: 0, 148 | ONE: 1, 149 | SRC_COLOR: 768, 150 | ONE_MINUS_SRC_COLOR: 769, 151 | SRC_ALPHA: 770, 152 | ONE_MINUS_SRC_ALPHA: 771, 153 | DST_ALPHA: 772, 154 | ONE_MINUS_DST_ALPHA: 773, 155 | DST_COLOR: 774, 156 | ONE_MINUS_DST_COLOR: 775, 157 | SRC_ALPHA_SATURATE: 776, 158 | FUNC_ADD: 32774, 159 | BLEND_EQUATION: 32777, 160 | BLEND_EQUATION_RGB: 32777, 161 | BLEND_EQUATION_ALPHA: 34877, 162 | FUNC_SUBTRACT: 32778, 163 | FUNC_REVERSE_SUBTRACT: 32779, 164 | BLEND_DST_RGB: 32968, 165 | BLEND_SRC_RGB: 32969, 166 | BLEND_DST_ALPHA: 32970, 167 | BLEND_SRC_ALPHA: 32971, 168 | CONSTANT_COLOR: 32769, 169 | ONE_MINUS_CONSTANT_COLOR: 32770, 170 | CONSTANT_ALPHA: 32771, 171 | ONE_MINUS_CONSTANT_ALPHA: 32772, 172 | BLEND_COLOR: 32773, 173 | ARRAY_BUFFER: 34962, 174 | ELEMENT_ARRAY_BUFFER: 34963, 175 | ARRAY_BUFFER_BINDING: 34964, 176 | ELEMENT_ARRAY_BUFFER_BINDING: 34965, 177 | STREAM_DRAW: 35040, 178 | STATIC_DRAW: 35044, 179 | DYNAMIC_DRAW: 35048, 180 | BUFFER_SIZE: 34660, 181 | BUFFER_USAGE: 34661, 182 | CURRENT_VERTEX_ATTRIB: 34342, 183 | FRONT: 1028, 184 | BACK: 1029, 185 | FRONT_AND_BACK: 1032, 186 | TEXTURE_2D: 3553, 187 | CULL_FACE: 2884, 188 | BLEND: 3042, 189 | DITHER: 3024, 190 | STENCIL_TEST: 2960, 191 | DEPTH_TEST: 2929, 192 | SCISSOR_TEST: 3089, 193 | POLYGON_OFFSET_FILL: 32823, 194 | SAMPLE_ALPHA_TO_COVERAGE: 32926, 195 | SAMPLE_COVERAGE: 32928, 196 | NO_ERROR: 0, 197 | INVALID_ENUM: 1280, 198 | INVALID_VALUE: 1281, 199 | INVALID_OPERATION: 1282, 200 | OUT_OF_MEMORY: 1285, 201 | CW: 2304, 202 | CCW: 2305, 203 | LINE_WIDTH: 2849, 204 | ALIASED_POINT_SIZE_RANGE: 33901, 205 | ALIASED_LINE_WIDTH_RANGE: 33902, 206 | CULL_FACE_MODE: 2885, 207 | FRONT_FACE: 2886, 208 | DEPTH_RANGE: 2928, 209 | DEPTH_WRITEMASK: 2930, 210 | DEPTH_CLEAR_VALUE: 2931, 211 | DEPTH_FUNC: 2932, 212 | STENCIL_CLEAR_VALUE: 2961, 213 | STENCIL_FUNC: 2962, 214 | STENCIL_FAIL: 2964, 215 | STENCIL_PASS_DEPTH_FAIL: 2965, 216 | STENCIL_PASS_DEPTH_PASS: 2966, 217 | STENCIL_REF: 2967, 218 | STENCIL_VALUE_MASK: 2963, 219 | STENCIL_WRITEMASK: 2968, 220 | STENCIL_BACK_FUNC: 34816, 221 | STENCIL_BACK_FAIL: 34817, 222 | STENCIL_BACK_PASS_DEPTH_FAIL: 34818, 223 | STENCIL_BACK_PASS_DEPTH_PASS: 34819, 224 | STENCIL_BACK_REF: 36003, 225 | STENCIL_BACK_VALUE_MASK: 36004, 226 | STENCIL_BACK_WRITEMASK: 36005, 227 | VIEWPORT: 2978, 228 | SCISSOR_BOX: 3088, 229 | COLOR_CLEAR_VALUE: 3106, 230 | COLOR_WRITEMASK: 3107, 231 | UNPACK_ALIGNMENT: 3317, 232 | PACK_ALIGNMENT: 3333, 233 | MAX_TEXTURE_SIZE: 3379, 234 | MAX_VIEWPORT_DIMS: 3386, 235 | SUBPIXEL_BITS: 3408, 236 | RED_BITS: 3410, 237 | GREEN_BITS: 3411, 238 | BLUE_BITS: 3412, 239 | ALPHA_BITS: 3413, 240 | DEPTH_BITS: 3414, 241 | STENCIL_BITS: 3415, 242 | POLYGON_OFFSET_UNITS: 10752, 243 | POLYGON_OFFSET_FACTOR: 32824, 244 | TEXTURE_BINDING_2D: 32873, 245 | SAMPLE_BUFFERS: 32936, 246 | SAMPLES: 32937, 247 | SAMPLE_COVERAGE_VALUE: 32938, 248 | SAMPLE_COVERAGE_INVERT: 32939, 249 | COMPRESSED_TEXTURE_FORMATS: 34467, 250 | DONT_CARE: 4352, 251 | FASTEST: 4353, 252 | NICEST: 4354, 253 | GENERATE_MIPMAP_HINT: 33170, 254 | BYTE: 5120, 255 | UNSIGNED_BYTE: 5121, 256 | SHORT: 5122, 257 | UNSIGNED_SHORT: 5123, 258 | INT: 5124, 259 | UNSIGNED_INT: 5125, 260 | FLOAT: 5126, 261 | DEPTH_COMPONENT: 6402, 262 | ALPHA: 6406, 263 | RGB: 6407, 264 | RGBA: 6408, 265 | LUMINANCE: 6409, 266 | LUMINANCE_ALPHA: 6410, 267 | UNSIGNED_SHORT_4_4_4_4: 32819, 268 | UNSIGNED_SHORT_5_5_5_1: 32820, 269 | UNSIGNED_SHORT_5_6_5: 33635, 270 | FRAGMENT_SHADER: 35632, 271 | VERTEX_SHADER: 35633, 272 | MAX_VERTEX_ATTRIBS: 34921, 273 | MAX_VERTEX_UNIFORM_VECTORS: 36347, 274 | MAX_VARYING_VECTORS: 36348, 275 | MAX_COMBINED_TEXTURE_IMAGE_UNITS: 35661, 276 | MAX_VERTEX_TEXTURE_IMAGE_UNITS: 35660, 277 | MAX_TEXTURE_IMAGE_UNITS: 34930, 278 | MAX_FRAGMENT_UNIFORM_VECTORS: 36349, 279 | SHADER_TYPE: 35663, 280 | DELETE_STATUS: 35712, 281 | LINK_STATUS: 35714, 282 | VALIDATE_STATUS: 35715, 283 | ATTACHED_SHADERS: 35717, 284 | ACTIVE_UNIFORMS: 35718, 285 | ACTIVE_ATTRIBUTES: 35721, 286 | SHADING_LANGUAGE_VERSION: 35724, 287 | CURRENT_PROGRAM: 35725, 288 | NEVER: 512, 289 | LESS: 513, 290 | EQUAL: 514, 291 | LEQUAL: 515, 292 | GREATER: 516, 293 | NOTEQUAL: 517, 294 | GEQUAL: 518, 295 | ALWAYS: 519, 296 | KEEP: 7680, 297 | REPLACE: 7681, 298 | INCR: 7682, 299 | DECR: 7683, 300 | INVERT: 5386, 301 | INCR_WRAP: 34055, 302 | DECR_WRAP: 34056, 303 | VENDOR: 7936, 304 | RENDERER: 7937, 305 | VERSION: 7938, 306 | NEAREST: 9728, 307 | LINEAR: 9729, 308 | NEAREST_MIPMAP_NEAREST: 9984, 309 | LINEAR_MIPMAP_NEAREST: 9985, 310 | NEAREST_MIPMAP_LINEAR: 9986, 311 | LINEAR_MIPMAP_LINEAR: 9987, 312 | TEXTURE_MAG_FILTER: 10240, 313 | TEXTURE_MIN_FILTER: 10241, 314 | TEXTURE_WRAP_S: 10242, 315 | TEXTURE_WRAP_T: 10243, 316 | TEXTURE: 5890, 317 | TEXTURE_CUBE_MAP: 34067, 318 | TEXTURE_BINDING_CUBE_MAP: 34068, 319 | TEXTURE_CUBE_MAP_POSITIVE_X: 34069, 320 | TEXTURE_CUBE_MAP_NEGATIVE_X: 34070, 321 | TEXTURE_CUBE_MAP_POSITIVE_Y: 34071, 322 | TEXTURE_CUBE_MAP_NEGATIVE_Y: 34072, 323 | TEXTURE_CUBE_MAP_POSITIVE_Z: 34073, 324 | TEXTURE_CUBE_MAP_NEGATIVE_Z: 34074, 325 | MAX_CUBE_MAP_TEXTURE_SIZE: 34076, 326 | TEXTURE0: 33984, 327 | TEXTURE1: 33985, 328 | TEXTURE2: 33986, 329 | TEXTURE3: 33987, 330 | TEXTURE4: 33988, 331 | TEXTURE5: 33989, 332 | TEXTURE6: 33990, 333 | TEXTURE7: 33991, 334 | TEXTURE8: 33992, 335 | TEXTURE9: 33993, 336 | TEXTURE10: 33994, 337 | TEXTURE11: 33995, 338 | TEXTURE12: 33996, 339 | TEXTURE13: 33997, 340 | TEXTURE14: 33998, 341 | TEXTURE15: 33999, 342 | TEXTURE16: 34000, 343 | TEXTURE17: 34001, 344 | TEXTURE18: 34002, 345 | TEXTURE19: 34003, 346 | TEXTURE20: 34004, 347 | TEXTURE21: 34005, 348 | TEXTURE22: 34006, 349 | TEXTURE23: 34007, 350 | TEXTURE24: 34008, 351 | TEXTURE25: 34009, 352 | TEXTURE26: 34010, 353 | TEXTURE27: 34011, 354 | TEXTURE28: 34012, 355 | TEXTURE29: 34013, 356 | TEXTURE30: 34014, 357 | TEXTURE31: 34015, 358 | ACTIVE_TEXTURE: 34016, 359 | REPEAT: 10497, 360 | CLAMP_TO_EDGE: 33071, 361 | MIRRORED_REPEAT: 33648, 362 | FLOAT_VEC2: 35664, 363 | FLOAT_VEC3: 35665, 364 | FLOAT_VEC4: 35666, 365 | INT_VEC2: 35667, 366 | INT_VEC3: 35668, 367 | INT_VEC4: 35669, 368 | BOOL: 35670, 369 | BOOL_VEC2: 35671, 370 | BOOL_VEC3: 35672, 371 | BOOL_VEC4: 35673, 372 | FLOAT_MAT2: 35674, 373 | FLOAT_MAT3: 35675, 374 | FLOAT_MAT4: 35676, 375 | SAMPLER_2D: 35678, 376 | SAMPLER_CUBE: 35680, 377 | VERTEX_ATTRIB_ARRAY_ENABLED: 34338, 378 | VERTEX_ATTRIB_ARRAY_SIZE: 34339, 379 | VERTEX_ATTRIB_ARRAY_STRIDE: 34340, 380 | VERTEX_ATTRIB_ARRAY_TYPE: 34341, 381 | VERTEX_ATTRIB_ARRAY_NORMALIZED: 34922, 382 | VERTEX_ATTRIB_ARRAY_POINTER: 34373, 383 | VERTEX_ATTRIB_ARRAY_BUFFER_BINDING: 34975, 384 | IMPLEMENTATION_COLOR_READ_TYPE: 35738, 385 | IMPLEMENTATION_COLOR_READ_FORMAT: 35739, 386 | COMPILE_STATUS: 35713, 387 | LOW_FLOAT: 36336, 388 | MEDIUM_FLOAT: 36337, 389 | HIGH_FLOAT: 36338, 390 | LOW_INT: 36339, 391 | MEDIUM_INT: 36340, 392 | HIGH_INT: 36341, 393 | FRAMEBUFFER: 36160, 394 | RENDERBUFFER: 36161, 395 | RGBA4: 32854, 396 | RGB5_A1: 32855, 397 | RGB565: 36194, 398 | DEPTH_COMPONENT16: 33189, 399 | STENCIL_INDEX: 6401, 400 | STENCIL_INDEX8: 36168, 401 | DEPTH_STENCIL: 34041, 402 | RENDERBUFFER_WIDTH: 36162, 403 | RENDERBUFFER_HEIGHT: 36163, 404 | RENDERBUFFER_INTERNAL_FORMAT: 36164, 405 | RENDERBUFFER_RED_SIZE: 36176, 406 | RENDERBUFFER_GREEN_SIZE: 36177, 407 | RENDERBUFFER_BLUE_SIZE: 36178, 408 | RENDERBUFFER_ALPHA_SIZE: 36179, 409 | RENDERBUFFER_DEPTH_SIZE: 36180, 410 | RENDERBUFFER_STENCIL_SIZE: 36181, 411 | FRAMEBUFFER_ATTACHMENT_OBJECT_TYPE: 36048, 412 | FRAMEBUFFER_ATTACHMENT_OBJECT_NAME: 36049, 413 | FRAMEBUFFER_ATTACHMENT_TEXTURE_LEVEL: 36050, 414 | FRAMEBUFFER_ATTACHMENT_TEXTURE_CUBE_MAP_FACE: 36051, 415 | COLOR_ATTACHMENT0: 36064, 416 | DEPTH_ATTACHMENT: 36096, 417 | STENCIL_ATTACHMENT: 36128, 418 | DEPTH_STENCIL_ATTACHMENT: 33306, 419 | NONE: 0, 420 | FRAMEBUFFER_COMPLETE: 36053, 421 | FRAMEBUFFER_INCOMPLETE_ATTACHMENT: 36054, 422 | FRAMEBUFFER_INCOMPLETE_MISSING_ATTACHMENT: 36055, 423 | FRAMEBUFFER_INCOMPLETE_DIMENSIONS: 36057, 424 | FRAMEBUFFER_UNSUPPORTED: 36061, 425 | FRAMEBUFFER_BINDING: 36006, 426 | RENDERBUFFER_BINDING: 36007, 427 | MAX_RENDERBUFFER_SIZE: 34024, 428 | INVALID_FRAMEBUFFER_OPERATION: 1286, 429 | UNPACK_FLIP_Y_WEBGL: 37440, 430 | UNPACK_PREMULTIPLY_ALPHA_WEBGL: 37441, 431 | CONTEXT_LOST_WEBGL: 37442, 432 | UNPACK_COLORSPACE_CONVERSION_WEBGL: 37443, 433 | BROWSER_DEFAULT_WEBGL: 37444, 434 | } 435 | 436 | const extensions: { [key: string]: any } = { 437 | // ratified 438 | OES_texture_float: {}, 439 | OES_texture_half_float: {}, 440 | WEBGL_lose_context: { 441 | loseContext: () => {}, 442 | }, 443 | OES_standard_derivatives: {}, 444 | OES_vertex_array_object: { 445 | createVertexArrayOES: () => {}, 446 | bindVertexArrayOES: () => {}, 447 | deleteVertexArrayOES: () => {}, 448 | createVertexArray: () => {}, 449 | bindVertexArray: () => {}, 450 | deleteVertexArray: () => {}, 451 | }, 452 | WEBGL_debug_renderer_info: null, 453 | WEBGL_debug_shaders: null, 454 | WEBGL_compressed_texture_s3tc: null, 455 | WEBGL_depth_texture: {}, 456 | OES_element_index_uint: {}, 457 | EXT_texture_filter_anisotropic: null, 458 | EXT_frag_depth: {}, 459 | WEBGL_draw_buffers: { 460 | drawBuffers: () => {}, 461 | drawBuffersWEBGL: () => {}, 462 | }, 463 | OES_texture_half_float_linear: null, 464 | EXT_blend_minmax: { MIN_EXT: 0, MAX_EXT: 0 }, 465 | EXT_shader_texture_lod: null, 466 | // community 467 | WEBGL_compressed_texture_atc: null, 468 | WEBGL_compressed_texture_pvrtc: null, 469 | EXT_color_buffer_half_float: null, 470 | WEBGL_color_buffer_float: null, 471 | EXT_sRGB: null, 472 | WEBGL_compressed_texture_etc1: null, 473 | EXT_color_buffer_float: {}, 474 | 475 | OES_texture_float_linear: {}, 476 | ANGLE_instanced_arrays: { 477 | vertexAttribDivisor: () => {}, 478 | drawArraysInstanced: () => {}, 479 | drawElementsInstanced: () => {}, 480 | vertexAttribDivisorANGLE: () => {}, 481 | drawArraysInstancedANGLE: () => {}, 482 | drawElementsInstancedANGLE: () => {}, 483 | }, 484 | } 485 | 486 | class WebGLRenderingContext { 487 | [key: string]: any 488 | 489 | constructor(canvas: HTMLCanvasElement) { 490 | this.canvas = canvas 491 | this.drawingBufferWidth = canvas.width 492 | this.drawingBufferHeight = canvas.height 493 | 494 | functions.forEach((func) => { 495 | this[func] = () => ({}) 496 | }) 497 | 498 | Object.keys(enums).forEach((key) => { 499 | this[key] = enums[key] 500 | }) 501 | } 502 | 503 | getShaderPrecisionFormat = () => { 504 | return { 505 | rangeMin: 127, 506 | rangeMax: 127, 507 | precision: 23, 508 | } 509 | } 510 | 511 | private GL_VERSION = 7938 512 | private SCISSOR_BOX = 3088 513 | private VIEWPORT = 2978 514 | 515 | getParameter = (paramId: number) => { 516 | switch (paramId) { 517 | case this.GL_VERSION: 518 | return ['WebGL1'] 519 | case this.SCISSOR_BOX: 520 | case this.VIEWPORT: 521 | return [0, 0, 1, 1] 522 | } 523 | } 524 | 525 | getExtension = (ext: string) => { 526 | return extensions[ext] 527 | } 528 | 529 | getProgramInfoLog = () => '' 530 | 531 | getShaderInfoLog = () => '' 532 | } 533 | 534 | export default WebGLRenderingContext 535 | -------------------------------------------------------------------------------- /tests/utils/index.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { createRoot, RenderProps, RootState } from '../../src' 3 | 4 | /** 5 | * Renders JSX into OGL state. 6 | */ 7 | export const render = (element: React.ReactNode, config?: RenderProps): RootState => { 8 | // Create canvas 9 | const canvas = document.createElement('canvas') 10 | 11 | // Init internals 12 | const root = createRoot(canvas, config) 13 | 14 | // Render and get output state 15 | const state = root.render(element).getState() 16 | 17 | return { ...state, root } 18 | } 19 | -------------------------------------------------------------------------------- /tests/utils/setupTests.ts: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { ViewProps, LayoutChangeEvent } from 'react-native' 3 | import type { GLViewProps, ExpoWebGLRenderingContext } from 'expo-gl' 4 | import WebGLRenderingContext from './WebGLRenderingContext' 5 | 6 | declare global { 7 | var IS_REACT_ACT_ENVIRONMENT: boolean 8 | var IS_REACT_NATIVE_TEST_ENVIRONMENT: boolean // https://github.com/facebook/react/pull/28419 9 | } 10 | 11 | // Let React know that we'll be testing effectful components 12 | global.IS_REACT_ACT_ENVIRONMENT = true 13 | global.IS_REACT_NATIVE_TEST_ENVIRONMENT = true // hide react-test-renderer warnings 14 | 15 | // PointerEvent is not in JSDOM 16 | // https://github.com/jsdom/jsdom/pull/2666#issuecomment-691216178 17 | // https://w3c.github.io/pointerevents/#pointerevent-interface 18 | if (!global.PointerEvent) { 19 | global.PointerEvent = class extends MouseEvent implements PointerEvent { 20 | readonly pointerId: number = 0 21 | readonly width: number = 1 22 | readonly height: number = 1 23 | readonly pressure: number = 0 24 | readonly tangentialPressure: number = 0 25 | readonly tiltX: number = 0 26 | readonly tiltY: number = 0 27 | readonly twist: number = 0 28 | readonly pointerType: string = '' 29 | readonly isPrimary: boolean = false 30 | 31 | constructor(type: string, params: PointerEventInit = {}) { 32 | super(type, params) 33 | Object.assign(this, params) 34 | } 35 | 36 | getCoalescedEvents = () => [] 37 | getPredictedEvents = () => [] 38 | } 39 | } 40 | 41 | // Polyfill WebGL Context 42 | ;(HTMLCanvasElement.prototype as any).getContext = function () { 43 | return new WebGLRenderingContext(this) 44 | } 45 | 46 | // Mock useMeasure for react-ogl/web 47 | const Measure = () => { 48 | const element = React.useRef(null) 49 | const [bounds] = React.useState({ 50 | left: 0, 51 | top: 0, 52 | width: 1280, 53 | height: 800, 54 | bottom: 0, 55 | right: 0, 56 | x: 0, 57 | y: 0, 58 | }) 59 | const ref = (node: React.ReactNode) => { 60 | if (!node || element.current) return 61 | 62 | // @ts-ignore 63 | element.current = node 64 | } 65 | return [ref, bounds] 66 | } 67 | jest.mock('react-use-measure', () => ({ 68 | __esModule: true, 69 | default: Measure, 70 | })) 71 | 72 | // Mock native dependencies for react-ogl/native 73 | jest.mock('react-native', () => ({ 74 | View: class extends React.Component { 75 | componentDidMount(): void { 76 | this.props.onLayout?.({ 77 | nativeEvent: { 78 | layout: { 79 | x: 0, 80 | y: 0, 81 | width: 1280, 82 | height: 800, 83 | }, 84 | }, 85 | } as LayoutChangeEvent) 86 | } 87 | 88 | render() { 89 | return this.props.children 90 | } 91 | }, 92 | StyleSheet: { 93 | absoluteFill: { 94 | position: 'absolute', 95 | left: 0, 96 | right: 0, 97 | top: 0, 98 | bottom: 0, 99 | }, 100 | }, 101 | PixelRatio: { 102 | get() { 103 | return 1 104 | }, 105 | }, 106 | })) 107 | jest.mock( 108 | 'react-native/Libraries/Pressability/Pressability.js', 109 | () => 110 | class { 111 | getEventHandlers = () => ({}) 112 | reset() {} 113 | }, 114 | ) 115 | 116 | jest.mock('expo-gl', () => ({ 117 | GLView({ onContextCreate }: GLViewProps) { 118 | const canvas = React.useMemo( 119 | () => Object.assign(document.createElement('canvas'), { width: 1280, height: 800 }), 120 | [], 121 | ) 122 | 123 | React.useLayoutEffect(() => { 124 | const gl = canvas.getContext('webgl2') as ExpoWebGLRenderingContext 125 | gl.endFrameEXP = () => {} 126 | onContextCreate?.(gl) 127 | }, [canvas, onContextCreate]) 128 | 129 | return null 130 | }, 131 | })) 132 | -------------------------------------------------------------------------------- /tests/web.test.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react' 2 | import { render, RenderResult } from '@testing-library/react' 3 | import { Canvas } from '../src' 4 | 5 | describe('Canvas', () => { 6 | it('should correctly mount', async () => { 7 | let renderer: RenderResult = null! 8 | 9 | await React.act(async () => { 10 | renderer = render( 11 | 12 | 13 | , 14 | ) 15 | }) 16 | 17 | expect(renderer.container).toMatchSnapshot() 18 | }) 19 | 20 | it('should forward ref', async () => { 21 | const ref = React.createRef() 22 | 23 | await React.act(async () => { 24 | render( 25 | 26 | 27 | , 28 | ) 29 | }) 30 | 31 | expect(ref.current).toBeDefined() 32 | }) 33 | 34 | it('should forward context', async () => { 35 | const ParentContext = React.createContext(null!) 36 | let receivedValue!: boolean 37 | 38 | function Test() { 39 | receivedValue = React.useContext(ParentContext) 40 | return null 41 | } 42 | 43 | await React.act(async () => { 44 | render( 45 | 46 | 47 | 48 | 49 | , 50 | ) 51 | }) 52 | 53 | expect(receivedValue).toBe(true) 54 | }) 55 | 56 | it('should correctly unmount', async () => { 57 | let renderer: RenderResult 58 | 59 | await React.act(async () => { 60 | renderer = render( 61 | 62 | 63 | , 64 | ) 65 | }) 66 | 67 | expect(() => renderer.unmount()).not.toThrow() 68 | }) 69 | }) 70 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "outDir": "dist", 4 | "target": "es6", 5 | "module": "ESNext", 6 | "lib": ["ESNext", "dom"], 7 | "moduleResolution": "node", 8 | "esModuleInterop": true, 9 | "jsx": "react", 10 | "pretty": true, 11 | "strict": true, 12 | "skipLibCheck": true, 13 | "declaration": true, 14 | "emitDeclarationOnly": true, 15 | "forceConsistentCasingInFileNames": true, 16 | "paths": { 17 | "react-ogl": ["./src"] 18 | } 19 | }, 20 | "include": ["src/**/*"] 21 | } 22 | -------------------------------------------------------------------------------- /vite.config.js: -------------------------------------------------------------------------------- 1 | import * as path from 'node:path' 2 | import * as fs from 'node:fs' 3 | import { defineConfig } from 'vite' 4 | 5 | const entry = fs.existsSync(path.resolve(process.cwd(), 'dist')) ? 'index.native' : 'index' 6 | 7 | export default defineConfig(({ command }) => ({ 8 | root: command === 'serve' ? 'examples' : undefined, 9 | resolve: { 10 | alias: { 11 | 'react-ogl': path.resolve(process.cwd(), 'src'), 12 | }, 13 | }, 14 | build: { 15 | minify: false, 16 | emptyOutDir: false, 17 | sourcemap: true, 18 | target: 'es2018', 19 | lib: { 20 | formats: ['es'], 21 | entry: `src/${entry}.ts`, 22 | fileName: '[name]', 23 | }, 24 | rollupOptions: { 25 | external: (id) => !id.startsWith('.') && !path.isAbsolute(id), 26 | treeshake: false, 27 | output: { 28 | preserveModules: true, 29 | sourcemapExcludeSources: true, 30 | }, 31 | }, 32 | }, 33 | })) 34 | --------------------------------------------------------------------------------