diff --git a/.storybook/main.js b/.storybook/main.js index 53553460..5a7d87c4 100644 --- a/.storybook/main.js +++ b/.storybook/main.js @@ -7,7 +7,7 @@ module.exports = { '@storybook/addon-actions/register', '@storybook/addon-links/register', '@storybook/addon-storysource/register', - '@storybook/addon-notes/register', + // '@storybook/addon-notes/register', ], webpackFinal: async (config, { configType }) => { config.resolve.alias['react-babylonjs'] = path.resolve(__dirname, '../dist/react-babylonjs') diff --git a/.storybook/preview.js b/.storybook/preview.js index b2d56188..64ae37fc 100644 --- a/.storybook/preview.js +++ b/.storybook/preview.js @@ -1,7 +1,7 @@ -import { addParameters, setAddon, configure } from '@storybook/react'; +import { addParameters } from '@storybook/react'; import { setDefaults } from '@storybook/addon-info'; -// addon-info +// // addon-info setDefaults({ header: false, // Toggles display of header with component name and description }); diff --git a/README.md b/README.md index 840c64c8..1512bf39 100644 --- a/README.md +++ b/README.md @@ -52,7 +52,7 @@ With declarative (TSX/JSX) coding and HMR, you experience the same development w ## @babylonjs/gui 1. GUI3DManager 2. **2D Controls** - scrollViewerWindow, baseSlider, babylon-button/Button, checkbox, colorPicker, container, control, displayGrid, babylon-ellipse/Ellipse, grid, babylon-image/Image, imageBasedSlider, imageScrollBar, inputPassword, inputText, babylon-line/Line, multiLine, radioButton, rectangle, scrollBar, scrollViewer, selectionPanel, slider, stackPanel, textBlock, virtualKeyboard -> note: 'babylon-*' for `button`, `ellipse`, `image` & `line` due to JSX conflict with `React.SVGProps`, otherwise use the ProperCase equavalent, but you miss editor auto-completion. +> note: 'babylon-*' for `button`, `ellipse`, `image` & `line` due to JSX conflict with `React.SVGProps`, otherwise use the ProperCase equivalent, but you miss editor auto-completion. 3. **3D Controls** - abstractButton3D, button3D, container3D, control3D, cylinderPanel, holographicButton, meshButton3D, planePanel, scatterPanel, spherePanel, stackPanel3D, volumeBasedPanel @@ -273,6 +273,8 @@ const App: React.FC = () => { > v2.2.0 (2020-04-04) - Added support for `react-spring` [demo](https://brianzinn.github.io/react-babylonjs/?path=/story/integrations--react-spring) +> v3.0.0 (2020-??-??) - Lots of pending work on master for upcoming 3.0 release. Work is on master branch and is for loading assets primarily and React.Suspense support (follow along in issues #81 and #87). + ## Breaking Changes > 0.x to 1.0 ([List](breaking-changes-0.x-to-1.0.md)) diff --git a/package.json b/package.json index 8b73afdb..d5df77ec 100644 --- a/package.json +++ b/package.json @@ -45,8 +45,8 @@ ] }, "devDependencies": { - "@babel/core": "^7.9.0", - "@babel/plugin-proposal-class-properties": "^7.5.5", + "@babel/core": "^7.11.6", + "@babel/plugin-proposal-class-properties": "^7.10.4", "@babylonjs/core": "^4.1.0", "@babylonjs/gui": "^4.1.0", "@babylonjs/inspector": "^4.1.0", @@ -55,15 +55,15 @@ "@inlet/react-pixi": "^1.2.8", "@rollup/plugin-json": "^4.0.2", "@rollup/plugin-typescript": "^4.0.0", - "@storybook/addon-actions": "^5.3.19", - "@storybook/addon-info": "^5.3.19", - "@storybook/addon-links": "^5.3.19", - "@storybook/addon-notes": "5.3.19", - "@storybook/addon-storysource": "^5.3.19", - "@storybook/addons": "^5.3.19", - "@storybook/react": "^5.3.19", - "@storybook/source-loader": "^5.3.19", - "@storybook/theming": "^5.3.19", + "@storybook/addon-actions": "^6.0.22", + "@storybook/addon-info": "^6.0.0-alpha.2", + "@storybook/addon-links": "^6.0.22", + "@storybook/addon-notes": "^6.0.0-alpha.6", + "@storybook/addon-storysource": "^6.0.22", + "@storybook/addons": "^6.0.22", + "@storybook/react": "^6.0.22", + "@storybook/source-loader": "^6.0.22", + "@storybook/theming": "^6.0.22", "@types/lodash.camelcase": "^4.3.6", "@types/node": "^8.0.0", "@types/react": "^16.7.20", @@ -89,7 +89,7 @@ "rollup": "^2.0.6", "rollup-plugin-terser": "^5.3.0", "rollup-plugin-typescript2": "^0.26.0", - "storybook": "^5.3.19", + "storybook": "^6.0.22", "ts-morph": "^8.1.0", "ts-node": "^7.0.1", "tsc-watch": "^1.0.31", diff --git a/src/hooks/useAssetManager.ts b/src/hooks/useAssetManager.ts deleted file mode 100644 index 8afd31c0..00000000 --- a/src/hooks/useAssetManager.ts +++ /dev/null @@ -1,99 +0,0 @@ -import { AbstractAssetTask, AssetsManager, Nullable, Scene } from "@babylonjs/core"; -import { useBabylonScene } from "../Scene"; - -export enum TaskType { - Binary = 'Binary' -} - -export type Task = { - taskType: TaskType, - name: string, - url: string -} - -/** - * This has limited functionality, since it only works for binary assets currently. - * onProgress() cannot be reported through the Suspense boundary without context, so thought needed on how to accomplish progress indicator in fallback. - */ -const useAssetManagerWithCache = () => { - // we need our own memoized cache. useMemo, useState, etc. fail miserably - throwing a promise forces the component to remount. - const suspenseCache: Record Nullable> = {}; - - return (tasks: Task[], useDefaultLoadingScreen: boolean = false, scene?: Scene) => { - const hookScene = useBabylonScene(); - - const createGetAssets = (tasks: Task[]): () => Nullable => { - if (!Array.isArray(tasks)) { - throw new Error('Asset Manager tasks must be an array') - } - - if (scene === undefined && hookScene === null) { - throw new Error('useAssetManager can only be used inside a Scene component (or pass scene as a prop)') - } - - const assetManager: AssetsManager = new AssetsManager(scene || hookScene!); - - tasks.forEach(task => { - switch (task.taskType) { - case TaskType.Binary: - assetManager.addBinaryFileTask(task.name, task.url); - break; - default: - throw new Error(`Only binary supported currently. Not ${task.taskType}`); - } - }) - - const taskPromise = new Promise((resolve, reject) => { - let failed = false - assetManager.useDefaultLoadingScreen = useDefaultLoadingScreen; - assetManager.onFinish = (tasks: AbstractAssetTask[]) => { - if (failed === false) { - // setTimeout(() => { /* for testing delays */ - resolve(tasks); - // }, 3000); - } - }; - assetManager.onTaskError = (task: AbstractAssetTask) => { - failed = true; - reject(`Failed task ${task.errorObject ? task.errorObject.message ?? `no error message on '${task.name}'` : task.name}`) - }; - - assetManager.load(); - }); - - let result: Nullable = null; - let error: Nullable = null; - let suspender: Nullable> = (async () => { - try { - result = await taskPromise; - } catch (e) { - error = e; - } finally { - suspender = null; - } - })(); - - const getAssets = () => { - if (suspender) { - throw suspender - }; - if (error !== null) { - throw error; - } - - return result; - }; - return getAssets; - }; - - const key = JSON.stringify(tasks); - if (suspenseCache[key] === undefined) { - suspenseCache[key] = createGetAssets(tasks); - } - - const fn: () => Nullable = suspenseCache[key]; - return [fn()]; - } -} - -export const useAssetManager = useAssetManagerWithCache(); \ No newline at end of file diff --git a/src/hooks/useAssetManager.tsx b/src/hooks/useAssetManager.tsx new file mode 100644 index 00000000..f37c88b6 --- /dev/null +++ b/src/hooks/useAssetManager.tsx @@ -0,0 +1,250 @@ +import React, { useContext, useState } from 'react'; +import { AbstractAssetTask, AssetsManager, EventState, IAssetsProgressEvent, Nullable, Scene, TextureAssetTask } from "@babylonjs/core"; +import { useBabylonScene } from "../Scene"; + +export enum TaskType { + Binary = 'Binary', + Mesh = 'Mesh', + Texture = 'Texture' +} + +export type BinaryTask = { + taskType: TaskType.Binary + name: string + url: string +} + +export type MeshTask = { + taskType: TaskType.Mesh + name: string + meshesNames: any + rootUrl: string + sceneFilename: string +} + +export type TextureTask = { + taskType: TaskType.Texture + name: string + url: string + noMipmap?: boolean + invertY?: boolean + samplingMode?: number +} + +export type Task = BinaryTask | MeshTask | TextureTask; + +export type AssetManagerContextType = { + updateProgress: (progress: AssetManagerProgressType) => void + lastProgress?: AssetManagerProgressType +} | undefined; + +export const AssetManagerContext = React.createContext(undefined); + +export type AssetManagerProgressType = { + eventData: IAssetsProgressEvent + eventState: EventState +} | undefined; + +export type AssetManagerContextProviderProps = { + startProgress?: AssetManagerProgressType, + children: any, +} + +export const AssetManagerContextProvider: React.FC = (props: AssetManagerContextProviderProps) => { + const [progress, setProgress] = useState(undefined); + + return ( + {props.children} + ); +} + +export type AssetManagerOptions = { + useDefaultLoadingScreen: boolean, + reportProgress?: boolean, // TODO: improve name. this is the opt-out of reporting (can trigger many re-renders). + scene?: Scene +} + +const getTaskKey = (task: Task): string | undefined => { + switch(task.taskType) { + case TaskType.Binary: + return `binary:${task.url}`; + case TaskType.Mesh: + return `mesh:${task.rootUrl}/${task.sceneFilename}`; + case TaskType.Texture: + return `texture:${task.url}` + default: + throw new Error(`Unknown task type: ${JSON.stringify(task)}`); + } +} + +type AssetManagerResult = { + tasks: AbstractAssetTask[], + map: Record +} + +/** + * This has limited functionality and only works for limited asset types. + * + * This is an experimental API and *WILL* change. + */ +const useAssetManagerWithCache = () => { + // we need our own memoized cache. useMemo, useState, etc. fail miserably - throwing a promise forces the component to remount. + let suspenseCache: Record Nullable> = {}; + let suspenseScene: Nullable = null; + + let tasksCompletedCache: Record = {}; + + return (tasks: Task[], options?: AssetManagerOptions) => { + const hookScene = useBabylonScene(); + const opts = options || { + useDefaultLoadingScreen: false + }; + + if (opts.scene === undefined && hookScene === null) { + throw new Error('useAssetManager can only be used inside a Scene component (or pass scene as a prop)') + } + + const assetManagerContext = useContext(AssetManagerContext); + const scene = opts.scene || hookScene!; + + if (suspenseScene == null) { + suspenseScene = scene; + } else { + if (suspenseScene !== scene) { + // console.log('new scene detected - clearing useAssetManager cache'); + // new scene detected. clearing all caches. + suspenseCache = {}; + // NOTE: could keep meshes with mesh.serialize and Mesh.Parse + // Need to research how to do with textures, etc. + // browser cache should make the load fast in most cases + tasksCompletedCache = {}; + suspenseScene = scene; + } + } + // invalidate cache with objects from another scene + // Object.getOwnPropertyNames(tasksCompletedCache).forEach(propertyName => { + // const task: AbstractAssetTask = tasksCompletedCache[propertyName]; + // if (task instanceof TextureAssetTask) { + // if (task.texture.getScene() !== scene) { + // console.log(`clearing ${task.name} from cache (different scene)`); + // delete tasksCompletedCache[propertyName]; + // } else { + // console.log(`scane scene ${task.name}`, scene, task.texture.getScene()); + // } + // } + // }); + + const createGetAssets = (tasks: Task[]): () => Nullable => { + if (!Array.isArray(tasks)) { + throw new Error('Asset Manager tasks must be an array') + } + + const newRequests: Map = new Map(); + + const assetManager: AssetsManager = new AssetsManager(scene); + const cachedTasks: any[] = []; + tasks.forEach(task => { + const key = getTaskKey(task); + if (key !== undefined && suspenseCache[key]) { + cachedTasks.push(suspenseCache[key]); + } else { + switch (task.taskType) { + case TaskType.Binary: + const binaryTask = assetManager.addBinaryFileTask(task.name, task.url); + newRequests.set(binaryTask, task); + break; + case TaskType.Mesh: + const meshTask = assetManager.addMeshTask(task.name, task.meshesNames, task.rootUrl, task.sceneFilename); + newRequests.set(meshTask, task); + break; + case TaskType.Texture: + const textureTask: TextureAssetTask = assetManager.addTextureTask(task.name, task.url, task.noMipmap, task.invertY, task.samplingMode); + newRequests.set(textureTask, task); + break; + default: + throw new Error(`Only binary/mesh supported currently. 'taskType' found on ${JSON.stringify(task)}`); + } + } + }) + + const createResultFromTasks = (tasks: AbstractAssetTask[]): AssetManagerResult => { + const map = tasks.reduce>((prev: Record, cur: AbstractAssetTask) => { + prev[cur.name] = cur + return prev; + }, {}); + return { tasks, map}; + } + + const taskPromise = (tasks.length === cachedTasks.length) + ? new Promise(resolve => resolve(createResultFromTasks(cachedTasks))) + : new Promise((resolve, reject) => { + let failed = false + assetManager.useDefaultLoadingScreen = opts.useDefaultLoadingScreen; + assetManager.onFinish = (tasks: AbstractAssetTask[]) => { + // whether it failed or not - we cache all results + tasks.forEach(task => { + if (newRequests.has(task)) { + // NOTE: we can skip caching failed requests (ie: due to temporary outage / 500) + const originalTask: Task = newRequests.get(task)!; + const key = getTaskKey(originalTask)!; + tasksCompletedCache[key] = task; + } + }) + if (failed === false) { + // include cached ones as well. + const result = createResultFromTasks(tasks.concat(cachedTasks)); + resolve(result); + } + }; + + if (opts.reportProgress !== false && assetManagerContext !== undefined) { + assetManager.onProgressObservable.add((eventData: IAssetsProgressEvent, eventState: EventState) => { + // console.log('progress update received:', eventData, eventState); + assetManagerContext!.updateProgress({eventData, eventState}); + }) + } + + assetManager.onTaskError = (task: AbstractAssetTask) => { + failed = true; + reject(`Failed task ${task.errorObject ? task.errorObject.message ?? `no error message on '${task.name}'` : task.name}`) + }; + + assetManager.load(); + }); + + let result: Nullable = null; + let error: Nullable = null; + let suspender: Nullable> = (async () => { + try { + result = await taskPromise; + } catch (e) { + error = e; + } finally { + suspender = null; + } + })(); + + const getAssets = () => { + if (suspender) { + throw suspender + }; + if (error !== null) { + throw error; + } + + return result; + }; + return getAssets; + }; + + const key = JSON.stringify(tasks); + if (suspenseCache[key] === undefined) { + suspenseCache[key] = createGetAssets(tasks); + } + + const fn: () => Nullable = suspenseCache[key]; + return [fn()]; + } +} + +export const useAssetManager = useAssetManagerWithCache(); \ No newline at end of file diff --git a/stories/babylonjs/Hooks/useAssetManager.stories.js b/stories/babylonjs/Hooks/useAssetManager.stories.js index f23526c1..5d57a846 100644 --- a/stories/babylonjs/Hooks/useAssetManager.stories.js +++ b/stories/babylonjs/Hooks/useAssetManager.stories.js @@ -22,7 +22,7 @@ const MyPCS = () => { useMemo(() => { if (result && pcs) { - const floats = new Float32Array(result[0].data); + const floats = new Float32Array(result.tasks[0].data); const POINTS_PER_FLOAT = 4; const numPoints = floats.length / POINTS_PER_FLOAT; diff --git a/stories/babylonjs/Models/model-loader.stories.js b/stories/babylonjs/Models/model-loader.stories.js new file mode 100644 index 00000000..e87264a5 --- /dev/null +++ b/stories/babylonjs/Models/model-loader.stories.js @@ -0,0 +1,92 @@ +import React, { Suspense, useRef, useContext, useMemo } from 'react'; +import '@babylonjs/inspector'; +import { Engine, Scene, useAssetManager, TaskType, useBeforeRender, AssetManagerContext, AssetManagerContextProvider } from '../../../dist/react-babylonjs'; +import { Vector3 } from '@babylonjs/core'; +import '../../style.css'; + +export default { title: 'Models' }; + +const baseUrl = 'https://raw.githubusercontent.com/KhronosGroup/glTF-Sample-Models/master/2.0/' + +const modelAssetTasks = [ + { taskType: TaskType.Mesh, rootUrl: `${baseUrl}BoomBox/glTF/`, sceneFilename: 'BoomBox.gltf', name: 'boombox' }, + { taskType: TaskType.Mesh, rootUrl: `${baseUrl}Avocado/glTF/`, sceneFilename: 'Avocado.gltf', name: 'avocado' } +]; + +const MyFallback = () => { + const boxRef = useRef(); + const context = useContext(AssetManagerContext); + console.log('context in fallback:', context); + + useBeforeRender((scene) => { + if (boxRef.current) { + var deltaTimeInMillis = scene.getEngine().getDeltaTime(); + + const rpm = 10; + boxRef.current.hostInstance.rotation.x = Math.PI / 4; + boxRef.current.hostInstance.rotation.y += ((rpm / 60) * Math.PI * 2 * (deltaTimeInMillis / 1000)); + } + }) + + const eventData = context?.lastProgress?.eventData; + + return <> + + + + {eventData !== undefined && + + } + {eventData === undefined && + + } + + + + + +} + +const MyModels = () => { + const [result] = useAssetManager(modelAssetTasks); + + useMemo(() => { + console.log('Loaded Tasks', result); + const boomboxTask = result.map['boombox']; + boomboxTask.loadedMeshes[0].position = new Vector3(2.5, 0, 0); + boomboxTask.loadedMeshes[1].scaling = new Vector3(20, 20, 20); + + const avocadoTask = result.map['avocado']; + avocadoTask.loadedMeshes[0].position = new Vector3(-2.5, 0, 0); + avocadoTask.loadedMeshes[1].scaling = new Vector3(20, 20, 20); + }); + + return null; +} + +const MyScene = () => { + + return ( + + + + + + }> + + + + + + ) +} + +export const ModelLoaderStory = () => ( +
+ +
+) + +ModelLoaderStory.story = { + name: '3D-Model loader' +} \ No newline at end of file diff --git a/stories/babylonjs/Textures/image-texture.stories.js b/stories/babylonjs/Textures/image-texture.stories.js index 4cf08c96..a37a00cd 100644 --- a/stories/babylonjs/Textures/image-texture.stories.js +++ b/stories/babylonjs/Textures/image-texture.stories.js @@ -1,19 +1,49 @@ -import React from 'react' +import React, { Suspense } from 'react' import '@babylonjs/inspector' -import {Engine, Scene, Model} from '../../../dist/react-babylonjs' -import {Color4, Vector3, Color3} from '@babylonjs/core/Maths/math' +import { Engine, Scene, TaskType, useAssetManager } from '../../../dist/react-babylonjs' +import { Color4, Vector3, Color3 } from '@babylonjs/core/Maths/math' import '../../style.css' export default { title: 'Textures' }; +const textureAssets = [ + { taskType: TaskType.Texture, url: 'https://upload.wikimedia.org/wikipedia/commons/8/87/Alaskan_Malamute%2BBlank.png', name: 'malamute' }, + { taskType: TaskType.Texture, url: 'assets/textures/grass.png', name: 'grass' }, + { taskType: TaskType.Texture, url: 'http://i.imgur.com/wGyk6os.png', name: 'bump' } +]; + +const Shapes = () => { + const [result] = useAssetManager(textureAssets, { + useDefaultLoadingScreen: true + }); + + return ( + <> + + + + + + + + + + + + + + + + + ) +} + /** * official examples * - https://www.babylonjs-playground.com/#YDO1F#75 * - https://www.babylonjs-playground.com/#20OAV9#15 */ function WithImageTexture() { - const url = 'https://upload.wikimedia.org/wikipedia/commons/8/87/Alaskan_Malamute%2BBlank.png'; - return ( @@ -33,29 +63,16 @@ function WithImageTexture() { specular={Color3.Green()} groundColor={Color3.Green()} /> - - - - - - - - - - - - - - - - + + + ) } export const ImageTexture = () => ( -
- +
+
)