@codyjasonbennett/react-ogl
Version:
A barebones react renderer for OGL.
245 lines (208 loc) • 8.24 kB
text/typescript
import * as React from 'react'
import * as OGL from 'ogl'
import { RESERVED_PROPS, INSTANCE_PROPS, POINTER_EVENTS } from './constants'
import { useIsomorphicLayoutEffect } from './hooks'
import { DPR, EventHandlers, Instance, Interactive, RootState } from './types'
/**
* Converts camelCase primitives to PascalCase.
*/
export const toPascalCase = (str: string) => str.charAt(0).toUpperCase() + str.substring(1)
/**
* Checks for inheritance between two classes.
*/
export const classExtends = (a: any, b: any) => (Object.prototype.isPrototypeOf.call(a, b) as boolean) || a === b
/**
* Interpolates DPR from [min, max] based on device capabilities.
*/
export const calculateDpr = (dpr: DPR) =>
Array.isArray(dpr) ? Math.min(Math.max(dpr[0], window.devicePixelRatio), dpr[1]) : dpr
/**
* Resolves a potentially pierced key type against an object.
*/
export function resolve(root: any, key: string) {
let target = root[key]
if (!key.includes('-')) return { root, key, target }
// Resolve pierced target
const chain = key.split('-')
target = chain.reduce((acc, key) => acc[key], root)
key = chain.pop()!
// Switch root if atomic
if (!target?.set) root = chain.reduce((acc, key) => acc[key], root)
return { root, key, target }
}
// Checks if a dash-cased string ends with an integer
const INDEX_REGEX = /-\d+$/
/**
* Attaches an instance to a parent via its `attach` prop.
*/
export function attach(parent: Instance, child: Instance) {
if (typeof child.props.attach === 'string') {
// If attaching into an array (foo-0), create one
if (INDEX_REGEX.test(child.props.attach)) {
const target = child.props.attach.replace(INDEX_REGEX, '')
const { root, key } = resolve(parent.object, target)
if (!Array.isArray(root[key])) root[key] = []
}
const { root, key } = resolve(parent.object, child.props.attach)
child.object.__previousAttach = root[key]
root[key] = child.object
child.object.__currentAttach = parent.object.__currentAttach = root[key]
} else if (typeof child.props.attach === 'function') {
child.object.__previousAttach = child.props.attach(parent.object, child.object)
}
}
/**
* Removes an instance from a parent via its `attach` prop.
*/
export function detach(parent: Instance, child: Instance) {
if (typeof child.props.attach === 'string') {
// Reset parent key if last attached
if (parent.object.__currentAttach === child.object.__currentAttach) {
const { root, key } = resolve(parent.object, child.props.attach)
root[key] = child.object.__previousAttach
}
} else {
child.object.__previousAttach(parent.object, child.object)
}
delete child.object.__previousAttach
delete child.object.__currentAttach
delete parent.object.__currentAttach
}
/**
* Safely mutates an OGL element, respecting special JSX syntax.
*/
export function applyProps(object: any, newProps: any, oldProps?: any) {
// Mutate our OGL element
for (const prop in newProps) {
// Don't mutate reserved keys
if (RESERVED_PROPS.includes(prop as typeof RESERVED_PROPS[number])) continue
if (INSTANCE_PROPS.includes(prop as typeof INSTANCE_PROPS[number])) continue
// Don't mutate unchanged keys
if (newProps[prop] === oldProps?.[prop]) continue
// Collect event handlers
const isHandler = POINTER_EVENTS.includes(prop as typeof POINTER_EVENTS[number])
if (isHandler) {
object.__handlers = { ...object.__handlers, [prop]: newProps[prop] }
continue
}
const value = newProps[prop]
const { root, key, target } = resolve(object, prop)
// Prefer to use properties' copy and set methods
// otherwise, mutate the property directly
const isMathClass = typeof target?.set === 'function' && typeof target?.copy === 'function'
if (!ArrayBuffer.isView(value) && isMathClass) {
if (target.constructor === value.constructor) {
target.copy(value)
} else if (Array.isArray(value)) {
target.set(...value)
} else {
// Support shorthand scalar syntax like scale={1}
const scalar = new Array(target.length).fill(value)
target.set(...scalar)
}
} else {
// Allow shorthand values for uniforms
const uniformList = value as any
if (key === 'uniforms') {
for (const uniform in uniformList) {
// @ts-ignore
let uniformValue = uniformList[uniform]?.value ?? uniformList[uniform]
// Handle uniforms shorthand
if (typeof uniformValue === 'string') {
// Uniform is a string, convert it into a color
uniformValue = new OGL.Color(uniformValue)
} else if (
uniformValue?.constructor === Array &&
(uniformValue as any[]).every((v: any) => typeof v === 'number')
) {
// @ts-ignore Uniform is an array, convert it into a vector
uniformValue = new OGL[`Vec${uniformValue.length}`](...uniformValue)
}
root.uniforms[uniform] = { value: uniformValue }
}
} else {
// Mutate the property directly
root[key] = value
}
}
}
}
/**
* Creates event handlers, returning an event handler method.
*/
export function createEvents(state: RootState) {
const handleEvent = (event: PointerEvent, type: keyof EventHandlers) => {
// Convert mouse coordinates
state.mouse!.x = (event.offsetX / state.size.width) * 2 - 1
state.mouse!.y = -(event.offsetY / state.size.height) * 2 + 1
// Filter to interactive meshes
const interactive: Interactive[] = []
state.scene.traverse((node: OGL.Transform | Interactive) => {
// Mesh has registered events and a defined volume
if (node instanceof OGL.Mesh && node.__handlers && node.geometry?.attributes?.position) interactive.push(node)
})
// Get elements that intersect with our pointer
state.raycaster!.castMouse(state.camera, state.mouse)
const intersects: Interactive[] = state.raycaster!.intersectMeshes(interactive)
// Used to discern between generic events and custom hover events.
// We hijack the pointermove event to handle hover state
const isHoverEvent = type === 'onPointerMove'
// Trigger events for hovered elements
for (const object of intersects) {
const handlers = object.__handlers
// Bail if object doesn't have handlers (managed externally)
if (!handlers) continue
if (isHoverEvent && !state.hovered!.get(object.id)) {
// Mark object as hovered and fire its hover events
state.hovered!.set(object.id, object)
// Fire hover events
handlers.onPointerMove?.({ ...object.hit, nativeEvent: event })
handlers.onPointerOver?.({ ...object.hit, nativeEvent: event })
} else {
// Otherwise, fire its generic event
handlers[type]?.({ ...object.hit, nativeEvent: event })
}
}
// Cleanup stale hover events
if (isHoverEvent || type === 'onPointerDown') {
state.hovered!.forEach((object) => {
const handlers = object.__handlers
if (!intersects.length || !intersects.find((i) => i === object)) {
// Reset hover state
state.hovered!.delete(object.id)
// Fire unhover event
if (handlers?.onPointerOut) handlers.onPointerOut({ ...object.hit, nativeEvent: event })
}
})
}
return intersects
}
return { handleEvent }
}
export type SetBlock = false | Promise<null> | null
/**
* Used to block rendering via its `set` prop. Useful for suspenseful effects.
*/
export function Block({ set }: { set: React.Dispatch<React.SetStateAction<SetBlock>> }) {
useIsomorphicLayoutEffect(() => {
set(new Promise(() => null))
return () => set(false)
}, [])
return null
}
/**
* Generic error boundary. Calls its `set` prop on error.
*/
export class ErrorBoundary extends React.Component<
{ set: React.Dispatch<any>; children: React.ReactNode },
{ error: boolean }
> {
state = { error: false }
static getDerivedStateFromError = () => ({ error: true })
componentDidCatch(error: any) {
this.props.set(error)
}
render() {
return this.state.error ? null : this.props.children
}
}