UNPKG

react-ogl

Version:

A barebones react renderer for OGL.

604 lines (537 loc) 21 kB
import Reconciler from 'react-reconciler' import { // NoEventPriority, ContinuousEventPriority, DiscreteEventPriority, DefaultEventPriority, } from 'react-reconciler/constants.js' import { unstable_IdlePriority as idlePriority, unstable_scheduleCallback as scheduleCallback } from 'scheduler' import * as OGL from 'ogl' import * as React from 'react' import { toPascalCase, applyProps, attach, detach, classExtends, prepare } from './utils' import { RESERVED_PROPS } from './constants' import { Act, Catalogue, ConstructorRepresentation, Instance, OGLElements, RootStore } from './types' // @ts-ignore const __DEV__ = /* @__PURE__ */ (() => typeof process !== 'undefined' && process.env.NODE_ENV !== 'production')() // TODO: upstream to DefinitelyTyped for React 19 // https://github.com/facebook/react/issues/28956 type EventPriority = number function createReconciler< Type, Props, Container, Instance, TextInstance, SuspenseInstance, HydratableInstance, FormInstance, PublicInstance, HostContext, ChildSet, TimeoutHandle, NoTimeout, TransitionStatus, >( config: Omit< Reconciler.HostConfig< Type, Props, Container, Instance, TextInstance, SuspenseInstance, HydratableInstance, PublicInstance, HostContext, null, // updatePayload ChildSet, TimeoutHandle, NoTimeout >, 'getCurrentEventPriority' | 'prepareUpdate' | 'commitUpdate' > & { /** * This method should mutate the `instance` and perform prop diffing if needed. * * 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. */ commitUpdate?( instance: Instance, type: Type, prevProps: Props, nextProps: Props, internalHandle: Reconciler.OpaqueHandle, ): void // Undocumented // https://github.com/facebook/react/pull/26722 NotPendingTransition: TransitionStatus | null HostTransitionContext: React.Context<TransitionStatus> // https://github.com/facebook/react/pull/28751 setCurrentUpdatePriority(newPriority: EventPriority): void getCurrentUpdatePriority(): EventPriority resolveUpdatePriority(): EventPriority // https://github.com/facebook/react/pull/28804 resetFormInstance(form: FormInstance): void // https://github.com/facebook/react/pull/25105 requestPostPaintCallback(callback: (time: number) => void): void // https://github.com/facebook/react/pull/26025 shouldAttemptEagerTransition(): boolean // https://github.com/facebook/react/pull/31528 trackSchedulerEvent(): void // https://github.com/facebook/react/pull/31008 resolveEventType(): null | string resolveEventTimeStamp(): number /** * 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. */ maySuspendCommit(type: Type, props: Props): boolean /** * 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. */ preloadInstance(type: Type, props: Props): boolean /** * 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. */ startSuspendingCommit(): void /** * This method is called after `startSuspendingCommit` for each Host Component that indicated it might suspend a commit. */ suspendInstance(type: Type, props: Props): void /** * This method is called after all `suspendInstance` calls are complete. * * Return `null` if the commit can happen immediately. * * 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. * */ waitForCommitToBeReady(): ((initiateCommit: Function) => Function) | null }, ): Reconciler.Reconciler<Container, Instance, TextInstance, SuspenseInstance, PublicInstance> { const reconciler = Reconciler(config as any) reconciler.injectIntoDevTools({ bundleType: __DEV__ ? 1 : 0, rendererPackageName: 'react-ogl', version: React.version, }) return reconciler as any } const NoEventPriority = 0 // Custom objects that extend the OGL namespace const catalogue = { ...OGL } as unknown as Catalogue // Effectful catalogue elements that require a `WebGLRenderingContext`. const catalogueGL: ConstructorRepresentation[] = [ // Core OGL.Camera, OGL.Geometry, OGL.Mesh, OGL.Program, OGL.RenderTarget, OGL.Texture, // Extras OGL.Flowmap, OGL.GPGPU, OGL.NormalProgram, OGL.Polyline, OGL.Post, OGL.Shadow, OGL.AxesHelper, OGL.GridHelper, OGL.WireMesh, ] /** * Extends the OGL catalogue, accepting an object of keys pointing to external classes. * `gl` will flag `objects` to receive a `WebGLRenderingContext` on creation. */ export function extend(objects: Partial<Catalogue>, gl = false) { for (const key in objects) { const value = objects[key as keyof Catalogue]! catalogue[key as keyof Catalogue] = value if (gl) catalogueGL.push(value) } } // https://github.com/facebook/react/issues/20271 // This will make sure events and attach are only handled once when trees are complete function handleContainerEffects(parent: Instance, child: Instance) { // Bail if tree isn't mounted or parent is not a container. // This ensures that the tree is finalized and React won't discard results to Suspense const state = child.root.getState() if (!parent.parent && parent.object !== state.scene) return // Create instance object if (child.type !== 'primitive') { const name = toPascalCase(child.type) as keyof Catalogue const target = catalogue[name] const { args = [], ...props } = child.props // Pass internal state to elements which depend on it. // This lets them be immutable upon creation and use props const isGLInstance = Object.values(catalogueGL).some((elem) => classExtends(elem, target)) if (isGLInstance) { const { gl } = child.root.getState() const filtered = args.filter((arg) => arg !== gl) // Accept props as args for programs & geometry if (child.type === 'program' || child.type === 'geometry') { const attrs = Object.entries(props).reduce((acc, [key, value]) => { // Don't include non-attributes for geometry if (child.type === 'geometry' && !(value as OGL.Attribute)?.data) return acc // Include non-pierced props if (!key.includes('-')) acc[key] = value return acc }, filtered[0] ?? {}) child.object = new target(gl, attrs) } else { child.object = new target(gl, ...filtered) } } else { child.object = new target(...args) } } // Link instance handle child.object.__ogl = child // Auto-attach geometry and programs to meshes if (!child.props.attach) { if (child.object instanceof OGL.Geometry) child.props.attach = 'geometry' else if (child.object instanceof OGL.Program) child.props.attach = 'program' } // Apply props to OGL object applyProps(child.object, child.props) // Handle attach if (child.props.attach) { attach(parent, child) } else if (child.object instanceof OGL.Transform && parent.object instanceof OGL.Transform) { child.object.setParent(parent.object) } // Link subtree for (const childInstance of child.children) handleContainerEffects(child, childInstance) } /** * Creates an OGL element from a React node. */ function createInstance(type: keyof OGLElements, props: Instance['props'], root: RootStore) { // Convert lowercase primitive to PascalCase const name = toPascalCase(type) as keyof Catalogue // Get class from extended OGL catalogue const target = catalogue[name] // Validate OGL elements if (type !== 'primitive' && !target) throw `${type} is not a part of the OGL catalogue! Did you forget to extend?` // Validate primitives if (type === 'primitive' && !props.object) throw `"object" must be set when using primitives.` // Create instance const instance = prepare(props.object, root, type, props) return instance } /** * Adds elements to our scene and attaches children to their parents. */ const appendChild = (parent: Instance, child: Instance) => { // Link instances child.parent = parent parent.children.push(child) // Attach tree once complete handleContainerEffects(parent, child) } /** * Removes elements from scene and disposes of them. */ function removeChild(parent: Instance, child: Instance, dispose?: boolean, recursive?: boolean) { // Unlink instances child.parent = null if (recursive === undefined) { const childIndex = parent.children.indexOf(child) if (childIndex !== -1) parent.children.splice(childIndex, 1) } // Remove instance objects if (child.props.attach) { detach(parent, child) } else if (parent.object instanceof OGL.Transform && child.object instanceof OGL.Transform) { parent.object.removeChild(child.object) } // Allow objects to bail out of unmount disposal with dispose={null} const shouldDispose = child.props.dispose !== null && dispose !== false // Recursively remove instance children if (recursive !== false) { for (const node of child.children) removeChild(child, node, shouldDispose, true) child.children = [] } // Dispose if able if (shouldDispose) { const object = child.object scheduleCallback(idlePriority, () => object.dispose?.()) delete child.object.__ogl child.object = null } } /** * Inserts an instance between instances of a ReactNode. */ function insertBefore(parent: Instance, child: Instance, beforeChild: Instance, replace = false) { if (!child) return // Link instances child.parent = parent const childIndex = parent.children.indexOf(beforeChild) if (childIndex !== -1) parent.children.splice(childIndex, replace ? 1 : 0, child) if (replace) beforeChild.parent = null // Attach tree once complete handleContainerEffects(parent, child) } /** * Switches instance to a new one, moving over children. */ function switchInstance( oldInstance: Instance, type: keyof OGLElements, props: Instance['props'], fiber: Reconciler.Fiber, ) { // React 19 regression from (un)hide hooks oldInstance.object.visible = true // Create a new instance const newInstance = createInstance(type, props, oldInstance.root) // Move children to new instance for (const child of oldInstance.children) { removeChild(oldInstance, child, false, false) appendChild(newInstance, child) } oldInstance.children = [] // Link up new instance const parent = oldInstance.parent if (parent) { insertBefore(parent, newInstance, oldInstance, true) } // Switches the react-internal fiber node // https://github.com/facebook/react/issues/14983 ;[fiber, fiber.alternate].forEach((fiber) => { if (fiber !== null) { fiber.stateNode = newInstance if (fiber.ref) { if (typeof fiber.ref === 'function') fiber.ref(newInstance.object) else fiber.ref.current = newInstance.object } } }) return newInstance } /** * Shallow checks objects. */ function checkShallow(a: any, b: any) { // If comparing arrays, shallow compare if (Array.isArray(a)) { // Check if types match if (!Array.isArray(b)) return false // Shallow compare for match if (a == b) return true // Sort through keys if (a.every((v, i) => v === b[i])) return true } // Atomically compare if (a === b) return true return false } /** * Prepares a set of changes to be applied to the instance. */ function diffProps<T extends ConstructorRepresentation = any>( instance: Instance<T>, newProps: Instance<T>['props'], oldProps: Instance<T>['props'], ): Instance<T>['props'] { const changedProps: Instance<T>['props'] = {} // Sort through props for (const key in newProps) { // Skip reserved keys if (RESERVED_PROPS.includes(key as typeof RESERVED_PROPS[number])) continue // Skip primitives if (instance.type === 'primitive' && key === 'object') continue // Skip if props match if (checkShallow(newProps[key], oldProps[key])) continue // Props changed, add them changedProps[key] = newProps[key] } return changedProps } const NO_CONTEXT = {} let currentUpdatePriority: number = NoEventPriority /** * Centralizes and handles mutations through an OGL scene-graph. */ export const reconciler = /* @__PURE__ */ createReconciler< keyof OGLElements, // type Instance['props'], // props RootStore, // container Instance, // instance never, // text instance Instance, // suspense instance never, // hydratable instance never, // form instance Instance['object'], // public instance {}, // host context never, // child set typeof setTimeout | undefined, // timeout handle -1, // no timeout null // transition status >({ // Configure renderer for tree-like mutation and interop w/react-dom isPrimaryRenderer: false, supportsMutation: true, supportsHydration: false, supportsPersistence: false, // Add SSR time fallbacks scheduleTimeout: () => (typeof setTimeout !== 'undefined' ? setTimeout : undefined), cancelTimeout: () => (typeof clearTimeout !== 'undefined' ? clearTimeout : undefined), noTimeout: -1, // Text isn't supported so we skip it shouldSetTextContent: () => false, resetTextContent: () => {}, createTextInstance() { throw new Error('Text is not allowed in the OGL scene-graph!') }, hideTextInstance() { throw new Error('Text is not allowed in the OGL scene-graph!') }, unhideTextInstance: () => {}, // Modifies the ref to return the instance object itself. getPublicInstance: (instance) => instance.object, // We can optionally access different host contexts on instance creation/update. // Instances' data structures are self-sufficient, so we don't make use of this getRootHostContext: () => NO_CONTEXT, getChildHostContext: () => NO_CONTEXT, // We can optionally mutate portal containers here, but we do that in createPortal instead from state preparePortalMount: (container) => prepare(container.getState().scene, container, '', {}), // This lets us store stuff at the container-level before/after React mutates our OGL elements. // Elements are mutated in isolation, so we don't do anything here. prepareForCommit: () => null, resetAfterCommit: () => {}, // This can modify the container and clear children. // Might be useful for disposing on demand later clearContainer: () => false, // This creates a OGL element from a React element createInstance, // These methods add elements to the scene appendChild, appendInitialChild: appendChild, appendChildToContainer(container, child) { const scene = (container.getState().scene as unknown as Instance<OGL.Transform>['object']).__ogl if (!child || !scene) return appendChild(scene, child) }, // We can specify an order for children to be inserted here. // This is useful if you want to override stuff like materials insertBefore, insertInContainerBefore(container, child, beforeChild) { const scene = (container.getState().scene as unknown as Instance<OGL.Transform>['object']).__ogl if (!child || !beforeChild || !scene) return insertBefore(scene, child, beforeChild) }, // These methods remove elements from the scene removeChild, removeChildFromContainer(container, child) { const scene = (container.getState().scene as unknown as Instance<OGL.Transform>['object']).__ogl if (!child || !scene) return removeChild(scene, child) }, // This is where we mutate OGL elements in the render phase // @ts-ignore commitUpdate(instance: Instance, type: Type, oldProps: Instance['props'], newProps: Instance['props'], fiber: any) { let reconstruct = false // Element is a primitive. We must recreate it when its object prop is changed if (instance.type === 'primitive' && oldProps.object !== newProps.object) reconstruct = true // Element is a program. Check whether its vertex or fragment props changed to recreate else if (type === 'program') { if (oldProps.vertex !== newProps.vertex) reconstruct = true if (oldProps.fragment !== newProps.fragment) reconstruct = true } // Element is a geometry. Check whether its attribute props changed to recreate. else if (type === 'geometry') { for (const key in oldProps) { const isAttribute = (oldProps[key] as OGL.Attribute)?.data || (newProps[key] as OGL.Attribute)?.data if (isAttribute && oldProps[key] !== newProps[key]) { reconstruct = true break } } } // If the instance has new args, recreate it else if (newProps.args?.length !== oldProps.args?.length) reconstruct = true else if (newProps.args?.some((value, index) => value !== oldProps.args?.[index])) reconstruct = true // If flagged for recreation, swap to a new instance. if (reconstruct) return switchInstance(instance, type, newProps, fiber) // Diff through props and flag with changes const changedProps = diffProps(instance, newProps, oldProps) if (Object.keys(changedProps).length) { // Handle attach update if (changedProps?.attach) { if (oldProps.attach) detach(instance.parent!, instance) instance.props.attach = newProps.attach if (newProps.attach) attach(instance.parent!, instance) } // Update instance props Object.assign(instance.props, changedProps) // Apply changed props applyProps(instance.object, changedProps) } }, // Methods to toggle instance visibility on demand. // React uses this with React.Suspense to display fallback content hideInstance(instance) { if (instance.object instanceof OGL.Transform) { instance.object.visible = false } instance.isHidden = true }, unhideInstance(instance) { if (instance.isHidden && instance.object instanceof OGL.Transform && instance.props.visible !== false) { instance.object.visible = true } instance.isHidden = false }, // Configures a callback once the tree is finalized after commit-effects are fired finalizeInitialChildren: () => false, commitMount() {}, // Undocumented getInstanceFromNode: () => null, beforeActiveInstanceBlur() {}, afterActiveInstanceBlur() {}, detachDeletedInstance() {}, prepareScopeUpdate() {}, getInstanceFromScope: () => null, shouldAttemptEagerTransition: () => false, trackSchedulerEvent: () => {}, resolveEventType: () => null, resolveEventTimeStamp: () => -1.1, requestPostPaintCallback() {}, maySuspendCommit: () => false, preloadInstance: () => true, // true indicates already loaded startSuspendingCommit() {}, suspendInstance() {}, waitForCommitToBeReady: () => null, NotPendingTransition: null, HostTransitionContext: /* @__PURE__ */ React.createContext(null), setCurrentUpdatePriority(newPriority: number) { currentUpdatePriority = newPriority }, getCurrentUpdatePriority() { return currentUpdatePriority }, resolveUpdatePriority() { if (currentUpdatePriority !== NoEventPriority) return currentUpdatePriority switch (typeof window !== 'undefined' && window.event?.type) { case 'click': case 'contextmenu': case 'dblclick': case 'pointercancel': case 'pointerdown': case 'pointerup': return DiscreteEventPriority case 'pointermove': case 'pointerout': case 'pointerover': case 'pointerenter': case 'pointerleave': case 'wheel': return ContinuousEventPriority default: return DefaultEventPriority } }, resetFormInstance() {}, })