react-ogl
Version:
A barebones react renderer for OGL.
604 lines (537 loc) • 21 kB
text/typescript
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() {},
})