(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 |
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 | ,
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 | ,
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 | ,
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 | ,
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 | ,
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 | ,
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 | ,
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 | ,
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 | ,
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 |
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 | ,
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 | ,
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 | ,
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 |
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 | ,
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 |
--------------------------------------------------------------------------------