diff --git a/README.md b/README.md
index 1e1ec5aa1..d59492a46 100644
--- a/README.md
+++ b/README.md
@@ -139,6 +139,7 @@ The `native` route of the library **does not** export `Html` or `Loader`. The de
useVideoTexture
useTrailTexture
useFont
+
Performance
@@ -2572,6 +2573,8 @@ type Props = {
instanceItems?: any[]
/** The max number of items to instance (optional) */
maxItems?: number
+ /** External parsed sprite data, usually from useSpriteLoader ready for use */
+ spriteDataset?: any
}
```
@@ -2580,8 +2583,9 @@ The SpriteAnimator component provided by drei is a powerful tool for animating s
Notes:
- The SpriteAnimator component internally uses the useFrame hook from react-three-fiber (r3f) for efficient frame updates and rendering.
-- The sprites should contain equal size frames
+- The sprites (without a JSON file) should contain equal size frames
- Trimming of spritesheet frames is not yet supported
+- Internally uses the `useSpriteLoader` or can use data from it directly
```jsx
+
void
+```
+
+```jsx
+const { spriteObj } = useSpriteLoader(
+ 'multiasset.png',
+ 'multiasset.json',
+
+ ['orange', 'Idle Blinking', '_Bat'],
+ null
+)
+
+
+
+
+```
+
+
# Performance
#### Instances
diff --git a/src/core/SpriteAnimator.tsx b/src/core/SpriteAnimator.tsx
index 7b31f075c..dc646e32e 100644
--- a/src/core/SpriteAnimator.tsx
+++ b/src/core/SpriteAnimator.tsx
@@ -2,6 +2,7 @@ import * as React from 'react'
import { useFrame, Vector3 } from '@react-three/fiber'
import * as THREE from 'three'
import { Instances, Instance } from './Instances'
+import { useSpriteLoader } from './useSpriteLoader'
export type SpriteAnimatorProps = {
startFrame?: number
@@ -9,7 +10,7 @@ export type SpriteAnimatorProps = {
fps?: number
frameName?: string
textureDataURL?: string
- textureImageURL: string
+ textureImageURL?: string
loop?: boolean
numberOfFrames?: number
autoPlay?: boolean
@@ -29,6 +30,7 @@ export type SpriteAnimatorProps = {
resetOnEnd?: boolean
maxItems?: number
instanceItems?: any[]
+ spriteDataset?: any
} & JSX.IntrinsicElements['group']
type SpriteAnimatorState = {
@@ -74,17 +76,16 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
resetOnEnd,
maxItems,
instanceItems,
+ spriteDataset,
...props
},
fref
) => {
const ref = React.useRef()
const spriteData = React.useRef(null)
- //const hasEnded = React.useRef(false)
const matRef = React.useRef()
const spriteRef = React.useRef()
const timerOffset = React.useRef(window.performance.now())
- const textureData = React.useRef()
const currentFrame = React.useRef(startFrame || 0)
const currentFrameName = React.useRef(frameName || '')
const fpsInterval = 1000 / (fps || 30)
@@ -97,6 +98,7 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
const pos = React.useRef(offset)
const softEnd = React.useRef(false)
const frameBuffer = React.useRef([])
+ const { spriteObj, loadJsonAndTexture } = useSpriteLoader(null, null, animationNames, numberOfFrames)
//
function reset() {}
@@ -110,7 +112,7 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
hasEnded: false,
ref: fref,
}),
- [textureImageURL]
+ [textureImageURL, spriteDataset]
)
React.useImperativeHandle(fref, () => ref.current, [])
@@ -119,22 +121,6 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
pos.current = offset
}, [offset])
- function loadJsonAndTextureAndExecuteCallback(
- jsonUrl: string,
- textureUrl: string,
- callback: (json: any, texture: THREE.Texture) => void
- ): void {
- const textureLoader = new THREE.TextureLoader()
- const jsonPromise = fetch(jsonUrl).then((response) => response.json())
- const texturePromise = new Promise((resolve) => {
- textureLoader.load(textureUrl, resolve)
- })
-
- Promise.all([jsonPromise, texturePromise]).then((response) => {
- callback(response[0], response[1])
- })
- }
-
const calculateAspectRatio = (width: number, height: number): Vector3 => {
const aspectRatio = height / width
if (spriteRef.current) {
@@ -145,18 +131,18 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
// initial loads
React.useEffect(() => {
- if (textureDataURL && textureImageURL) {
- loadJsonAndTextureAndExecuteCallback(textureDataURL, textureImageURL, parseSpriteData)
- } else if (textureImageURL) {
- // only load the texture, this is an image sprite only
- const textureLoader = new THREE.TextureLoader()
- new Promise((resolve) => {
- textureLoader.load(textureImageURL, resolve)
- }).then((texture) => {
- parseSpriteData(null, texture)
- })
+ if (spriteDataset) {
+ parseSpriteDataLite(spriteDataset?.spriteTexture?.clone(), spriteDataset.spriteData)
+ } else {
+ loadJsonAndTexture(textureImageURL, textureDataURL)
+ }
+ }, [spriteDataset])
+
+ React.useEffect(() => {
+ if (spriteObj) {
+ parseSpriteDataLite(spriteObj?.spriteTexture?.clone(), spriteObj?.spriteData)
}
- }, [])
+ }, [spriteObj])
React.useEffect(() => {
setDisplayAsSprite(asSprite ?? true)
@@ -196,17 +182,14 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
}
}, [frameName])
- // parse sprite-data from JSON file (jsonHash or jsonArray)
- const parseSpriteData = (json: any, _spriteTexture: THREE.Texture): void => {
- // sprite only case
- if (json === null) {
+ // lite version for pre-loaded assets
+ const parseSpriteDataLite = (textureData: THREE.Texture, frameData: any = null) => {
+ if (frameData === null) {
if (numberOfFrames) {
//get size from texture
- const width = _spriteTexture.image.width
- const height = _spriteTexture.image.height
- const frameWidth = width / numberOfFrames
- const frameHeight = height
- textureData.current = _spriteTexture
+ const width = textureData.image.width
+ const height = textureData.image.height
+
totalFrames.current = numberOfFrames
if (playBackwards) {
@@ -222,35 +205,22 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
},
}
- if (parseInt(frameWidth.toString(), 10) === frameWidth) {
- // if it fits
- for (let i = 0; i < numberOfFrames; i++) {
- spriteData.current.frames.push({
- frame: { x: i * frameWidth, y: 0, w: frameWidth, h: frameHeight },
- rotated: false,
- trimmed: false,
- spriteSourceSize: { x: 0, y: 0, w: frameWidth, h: frameHeight },
- sourceSize: { w: frameWidth, h: height },
- })
- }
- }
+ spriteData.current.frames = frameData
}
} else {
- spriteData.current = json
- spriteData.current.frames = Array.isArray(json.frames) ? json.frames : parseFrames()
- totalFrames.current = Array.isArray(json.frames) ? json.frames.length : Object.keys(json.frames).length
- textureData.current = _spriteTexture
+ spriteData.current = frameData
+ totalFrames.current = spriteData.current.frames.length
if (playBackwards) {
currentFrame.current = totalFrames.current - 1
}
- const { w, h } = getFirstItem(json.frames).sourceSize
+ const { w, h } = getFirstItem(spriteData.current.frames).sourceSize
const aspect = calculateAspectRatio(w, h)
setAspect(aspect)
if (matRef.current) {
- matRef.current.map = _spriteTexture
+ matRef.current.map = textureData
}
}
@@ -269,51 +239,7 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
}
}
- _spriteTexture.premultiplyAlpha = false
-
- setSpriteTexture(_spriteTexture)
- }
-
- // for frame based JSON Hash sprite data
- const parseFrames = (): any => {
- const sprites: any = {}
- const data = spriteData.current
- const delimiters = animationNames
- if (delimiters) {
- for (let i = 0; i < delimiters.length; i++) {
- sprites[delimiters[i]] = []
-
- for (const innerKey in data['frames']) {
- const value = data['frames'][innerKey]
- const frameData = value['frame']
- const x = frameData['x']
- const y = frameData['y']
- const width = frameData['w']
- const height = frameData['h']
- const sourceWidth = value['sourceSize']['w']
- const sourceHeight = value['sourceSize']['h']
-
- if (innerKey.toLowerCase().indexOf(delimiters[i].toLowerCase()) !== -1) {
- sprites[delimiters[i]].push({
- x: x,
- y: y,
- w: width,
- h: height,
- frame: frameData,
- sourceSize: { w: sourceWidth, h: sourceHeight },
- })
- }
- }
- }
- return sprites
- } else if (frameName) {
- const spritesArr: any[] = []
- for (const key in data.frames) {
- spritesArr.push(data.frames[key])
- }
-
- return spritesArr
- }
+ setSpriteTexture(textureData)
}
// modify the sprite material after json is parsed and state updated
@@ -348,6 +274,7 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
// run the animation on each frame
const runAnimation = (): void => {
//if (!frameName) return
+
const now = window.performance.now()
const diff = now - timerOffset.current
const {
@@ -383,10 +310,6 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
currentFrame: currentFrame.current,
})
- if (!_offset) {
- console.log('will end')
- }
-
state.hasEnded = resetOnEnd ? false : true
if (resetOnEnd) {
pauseRef.current = true
@@ -515,6 +438,7 @@ export const SpriteAnimator: React.FC = /* @__PURE__ */ Rea
{displayAsSprite && (
= /* @__PURE__ */ Rea
>
= /* @__PURE__ */ Rea
/>
{(instanceItems ?? [0]).map((item, index) => {
- const texture = spriteTexture.clone()
- if (matRef.current && frameBuffer.current[index]) {
- texture.offset.set(frameBuffer.current[index].offset.x, frameBuffer.current[index].offset.y) // Set the offset for this item
- }
-
- return (
-
-
-
- )
+ return
})}
)}
diff --git a/src/core/index.ts b/src/core/index.ts
index ce07c133c..88bc66dfa 100644
--- a/src/core/index.ts
+++ b/src/core/index.ts
@@ -57,6 +57,7 @@ export * from './useProgress'
export * from './useTexture'
export * from './useVideoTexture'
export * from './useFont'
+export * from './useSpriteLoader'
// Misc
export * from './Stats'
diff --git a/src/core/useSpriteLoader.tsx b/src/core/useSpriteLoader.tsx
new file mode 100644
index 000000000..e46b92536
--- /dev/null
+++ b/src/core/useSpriteLoader.tsx
@@ -0,0 +1,210 @@
+import { Texture, TextureLoader } from 'three'
+import { useLoader, useThree } from '@react-three/fiber'
+import { useEffect, useState } from 'react'
+import * as React from 'react'
+import * as THREE from 'three'
+
+// utils
+export const getFirstItem = (param: any): any => {
+ if (Array.isArray(param)) {
+ return param[0]
+ } else if (typeof param === 'object' && param !== null) {
+ const keys = Object.keys(param)
+ return param[keys[0]][0]
+ } else {
+ return { w: 0, h: 0 }
+ }
+}
+
+export const calculateAspectRatio = (width: number, height: number, factor: number, v: any): THREE.Vector3 => {
+ const adaptedHeight = height * (v.aspect > width / height ? v.width / width : v.height / height)
+ const adaptedWidth = width * (v.aspect > width / height ? v.width / width : v.height / height)
+ const scaleX = adaptedWidth * factor
+ const scaleY = adaptedHeight * factor
+ const currentMaxScale = 1
+ // Calculate the maximum scale based on the aspect ratio and max scale limit
+ let finalMaxScaleW = Math.min(currentMaxScale, scaleX)
+ let finalMaxScaleH = Math.min(currentMaxScale, scaleY)
+
+ // Ensure that scaleX and scaleY do not exceed the max scale while maintaining aspect ratio
+ if (scaleX > currentMaxScale) {
+ finalMaxScaleW = currentMaxScale
+ finalMaxScaleH = (scaleY / scaleX) * currentMaxScale
+ }
+
+ return new THREE.Vector3(finalMaxScaleW, finalMaxScaleH, 1)
+}
+
+export function useSpriteLoader(
+ input?: Url | null,
+ json?: string | null,
+ animationNames?: string[] | null,
+ numberOfFrames?: number | null,
+ onLoad?: (texture: Texture, textureData?: any) => void
+): any {
+ const v = useThree((state) => state.viewport)
+ const gl = useThree((state) => state.gl)
+ const spriteDataRef = React.useRef(null)
+ const totalFrames = React.useRef(0)
+ const aspectFactor = 0.1
+ const [spriteData, setSpriteData] = useState | null>(null)
+ const [spriteTexture, setSpriteTexture] = React.useState(new THREE.Texture())
+ const textureLoader = new THREE.TextureLoader()
+ const [spriteObj, setSpriteObj] = useState | null>(null)
+
+ React.useLayoutEffect(() => {
+ if (json && input) {
+ loadJsonAndTextureAndExecuteCallback(json, input, parseSpriteData)
+ } else if (input) {
+ // only load the texture, this is an image sprite only
+ loadStandaloneSprite()
+ }
+
+ return () => {
+ if (input) {
+ useLoader.clear(TextureLoader, input)
+ }
+ }
+ }, [])
+
+ function loadJsonAndTexture(textureUrl: string, jsonUrl?: string) {
+ if (jsonUrl && textureUrl) {
+ loadJsonAndTextureAndExecuteCallback(jsonUrl, textureUrl, parseSpriteData)
+ } else {
+ loadStandaloneSprite(textureUrl)
+ }
+ }
+
+ function loadStandaloneSprite(textureUrl?: string) {
+ if (textureUrl || input) {
+ new Promise((resolve) => {
+ textureLoader.load(textureUrl ?? input!, resolve)
+ }).then((texture) => {
+ parseSpriteData(null, texture)
+ })
+ }
+ }
+
+ /**
+ *
+ */
+ function loadJsonAndTextureAndExecuteCallback(
+ jsonUrl: string,
+ textureUrl: string,
+ callback: (json: any, texture: THREE.Texture) => void
+ ): void {
+ const jsonPromise = fetch(jsonUrl).then((response) => response.json())
+ const texturePromise = new Promise((resolve) => {
+ textureLoader.load(textureUrl, resolve)
+ })
+
+ Promise.all([jsonPromise, texturePromise]).then((response) => {
+ callback(response[0], response[1])
+ })
+ }
+
+ const parseSpriteData = (json: any, _spriteTexture: THREE.Texture): void => {
+ let aspect = new THREE.Vector3(1, 1, 1)
+ // sprite only case
+ if (json === null) {
+ if (_spriteTexture && numberOfFrames) {
+ //get size from texture
+ const width = _spriteTexture.image.width
+ const height = _spriteTexture.image.height
+ const frameWidth = width / numberOfFrames
+ const frameHeight = height
+ totalFrames.current = numberOfFrames
+ spriteDataRef.current = {
+ frames: [],
+ meta: {
+ version: '1.0',
+ size: { w: width, h: height },
+ scale: '1',
+ },
+ }
+
+ if (parseInt(frameWidth.toString(), 10) === frameWidth) {
+ // if it fits
+ for (let i = 0; i < numberOfFrames; i++) {
+ spriteDataRef.current.frames.push({
+ frame: { x: i * frameWidth, y: 0, w: frameWidth, h: frameHeight },
+ rotated: false,
+ trimmed: false,
+ spriteSourceSize: { x: 0, y: 0, w: frameWidth, h: frameHeight },
+ sourceSize: { w: frameWidth, h: height },
+ })
+ }
+ }
+
+ aspect = calculateAspectRatio(frameWidth, frameHeight, aspectFactor, v)
+ }
+ } else if (_spriteTexture) {
+ spriteDataRef.current = json
+ spriteDataRef.current.frames = Array.isArray(json.frames) ? json.frames : parseFrames()
+ totalFrames.current = Array.isArray(json.frames) ? json.frames.length : Object.keys(json.frames).length
+
+ const { w, h } = getFirstItem(json.frames).sourceSize
+ aspect = calculateAspectRatio(w, h, aspectFactor, v)
+ }
+
+ setSpriteData(spriteDataRef.current)
+ _spriteTexture.encoding = THREE.sRGBEncoding
+ setSpriteTexture(_spriteTexture)
+ setSpriteObj({ spriteTexture: _spriteTexture, spriteData: spriteDataRef.current, aspect: aspect })
+ }
+
+ // for frame based JSON Hash sprite data
+ const parseFrames = (): any => {
+ const sprites: any = {}
+ const data = spriteDataRef.current
+ const delimiters = animationNames
+ if (delimiters) {
+ for (let i = 0; i < delimiters.length; i++) {
+ sprites[delimiters[i]] = []
+
+ for (const innerKey in data['frames']) {
+ const value = data['frames'][innerKey]
+ const frameData = value['frame']
+ const x = frameData['x']
+ const y = frameData['y']
+ const width = frameData['w']
+ const height = frameData['h']
+ const sourceWidth = value['sourceSize']['w']
+ const sourceHeight = value['sourceSize']['h']
+
+ if (typeof innerKey === 'string' && innerKey.toLowerCase().indexOf(delimiters[i].toLowerCase()) !== -1) {
+ sprites[delimiters[i]].push({
+ x: x,
+ y: y,
+ w: width,
+ h: height,
+ frame: frameData,
+ sourceSize: { w: sourceWidth, h: sourceHeight },
+ })
+ }
+ }
+ }
+ }
+
+ return sprites
+ }
+
+ React.useLayoutEffect(() => {
+ onLoad?.(spriteTexture, spriteData)
+ }, [spriteTexture, spriteData])
+
+ // https://github.com/mrdoob/three.js/issues/22696
+ // Upload the texture to the GPU immediately instead of waiting for the first render
+ // NOTE: only available for WebGLRenderer
+ useEffect(() => {
+ if ('initTexture' in gl) {
+ const array = Array.isArray(spriteTexture) ? spriteTexture : [spriteTexture]
+ array.forEach(gl.initTexture)
+ }
+ }, [gl, spriteTexture])
+
+ return { spriteObj, loadJsonAndTexture }
+}
+
+useSpriteLoader.preload = (url: string) => useLoader.preload(TextureLoader, url)
+useSpriteLoader.clear = (input: string) => useLoader.clear(TextureLoader, input)