UNPKG

@react-three/drei

Version:

useful add-ons for react-three-fiber

403 lines (392 loc) 14.4 kB
import * as THREE from 'three'; import { TextureLoader } from 'three'; import { useThree, useLoader } from '@react-three/fiber'; import * as React from 'react'; import { useState } from 'react'; /* eslint react-hooks/exhaustive-deps: 1 */ // utils const getFirstFrame = (frames, frameName) => { if (Array.isArray(frames)) { return frames[0]; } else { const k = frameName !== null && frameName !== void 0 ? frameName : Object.keys(frames)[0]; return frames[k][0]; } }; const checkIfFrameIsEmpty = frameData => { for (let i = 3; i < frameData.length; i += 4) { if (frameData[i] !== 0) { return false; } } return true; }; function useSpriteLoader(/** The URL of the sprite sheet. */ input, /** The JSON data of the sprite sheet. */ json, /** The names of the animations in the sprite sheet. */ animationNames, /** The number of frames in the sprite sheet. */ numberOfFrames, /** A callback that is called when the sprite sheet is loaded. */ onLoad, /** The settings to use when creating the 2D context. */ canvasRenderingContext2DSettings) { const viewportRef = React.useRef(useThree(state => state.viewport)); const spriteDataRef = React.useRef(null); const totalFrames = React.useRef(0); const aspectFactor = 0.1; const inputRef = React.useRef(input); const jsonRef = React.useRef(json); const animationFramesRef = React.useRef(animationNames); const [spriteData, setSpriteData] = useState(null); const [spriteTexture, setSpriteTexture] = React.useState(new THREE.Texture()); const textureLoader = React.useMemo(() => new THREE.TextureLoader(), []); const [spriteObj, setSpriteObj] = useState(null); const calculateAspectRatio = React.useCallback((width, height, factor) => { const adaptedHeight = height * (viewportRef.current.aspect > width / height ? viewportRef.current.width / width : viewportRef.current.height / height); const adaptedWidth = width * (viewportRef.current.aspect > width / height ? viewportRef.current.width / width : viewportRef.current.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); }, []); const getRowsAndColumns = React.useCallback((texture, totalFrames) => { if (texture.image) { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d', canvasRenderingContext2DSettings); if (!ctx) { throw new Error('Failed to get 2d context'); } canvas.width = texture.image.width; canvas.height = texture.image.height; ctx.drawImage(texture.image, 0, 0); const width = texture.image.width; const height = texture.image.height; // Calculate rows and columns based on the number of frames and image dimensions const cols = Math.round(Math.sqrt(totalFrames * (width / height))); const rows = Math.round(totalFrames / cols); const frameWidth = width / cols; const frameHeight = height / rows; const emptyFrames = []; for (let row = 0; row < rows; row++) { for (let col = 0; col < cols; col++) { const frameIndex = row * cols + col; if (frameIndex >= totalFrames) { emptyFrames.push({ row, col }); continue; } const frameData = ctx.getImageData(col * frameWidth, row * frameHeight, frameWidth, frameHeight).data; const isEmpty = checkIfFrameIsEmpty(frameData); if (isEmpty) { emptyFrames.push({ row, col }); } } } return { rows, columns: cols, frameWidth, frameHeight, emptyFrames }; } else { return { rows: 0, columns: 0, frameWidth: 0, frameHeight: 0, emptyFrames: [] }; } }, [canvasRenderingContext2DSettings]); // calculate scale ratio for the frames const calculateScaleRatio = React.useCallback(frames => { // Helper function to calculate scale ratio for an array of frames const processFrameArray = frameArray => { // Find the largest frame let largestFrame = null; for (const frame of frameArray) { const { w, h } = frame.frame; const area = w * h; if (!largestFrame || area > largestFrame.area) { largestFrame = { w, h, area }; } } // Set scaleRatio property on each frame const frameArr = frameArray.map(frame => { const { w, h } = frame.frame; const area = w * h; const scaleRatio = largestFrame ? area === largestFrame.area ? 1 : Math.sqrt(area / largestFrame.area) : 1; return { ...frame, scaleRatio }; }); return frameArr; }; // Handle both array and record cases if (Array.isArray(frames)) { return processFrameArray(frames); } else { const result = {}; for (const key in frames) { result[key] = processFrameArray(frames[key]); } return result; } }, []); // for frame based JSON Hash sprite data const parseFrames = React.useCallback(() => { const sprites = {}; const data = spriteDataRef.current; const delimiters = animationFramesRef.current; if (data) { if (delimiters && Array.isArray(data['frames'])) { for (let i = 0; i < delimiters.length; i++) { // we convert each named animation group into an array sprites[delimiters[i]] = []; for (const value of data['frames']) { const frameData = value['frame']; const sourceWidth = value['sourceSize']['w']; const sourceHeight = value['sourceSize']['h']; if (typeof value['filename'] === 'string' && value['filename'].toLowerCase().indexOf(delimiters[i].toLowerCase()) !== -1) { sprites[delimiters[i]].push({ ...value, frame: frameData, sourceSize: { w: sourceWidth, h: sourceHeight } }); } } } for (const frame in sprites) { const scaleRatioData = calculateScaleRatio(sprites[frame]); if (Array.isArray(scaleRatioData)) { sprites[frame] = scaleRatioData; } } return sprites; } else if (delimiters && typeof data['frames'] === 'object') { for (let i = 0; i < delimiters.length; i++) { // we convert each named animation group into an array sprites[delimiters[i]] = []; for (const innerKey in data['frames']) { const value = data['frames'][innerKey]; const frameData = value['frame']; 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({ ...value, frame: frameData, sourceSize: { w: sourceWidth, h: sourceHeight } }); } } } for (const frame in sprites) { const scaleRatioData = calculateScaleRatio(sprites[frame]); if (Array.isArray(scaleRatioData)) { sprites[frame] = scaleRatioData; } } return sprites; } else { let spritesArr = []; if (data != null && data.frames) { if (Array.isArray(data.frames)) { spritesArr = data.frames.map(frame => ({ ...frame, x: frame.frame.x, y: frame.frame.y, w: frame.frame.w, h: frame.frame.h })); } else { spritesArr = Object.values(data.frames).flat().map(frame => ({ ...frame, x: frame.frame.x, y: frame.frame.y, w: frame.frame.w, h: frame.frame.h })); } } return calculateScaleRatio(spritesArr); } } return []; }, [calculateScaleRatio, spriteDataRef]); const parseSpriteData = React.useCallback((json, _spriteTexture) => { 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; totalFrames.current = numberOfFrames; const { rows, columns, frameWidth, frameHeight, emptyFrames } = getRowsAndColumns(_spriteTexture, numberOfFrames); const nonJsonFrames = { frames: [], meta: { version: '1.0', size: { w: width, h: height }, rows, columns, frameWidth, frameHeight, scale: '1' } }; for (let row = 0; row < rows; row++) { for (let col = 0; col < columns; col++) { const isExcluded = (emptyFrames !== null && emptyFrames !== void 0 ? emptyFrames : []).some(coord => coord.row === row && coord.col === col); if (isExcluded) { continue; } if (Array.isArray(nonJsonFrames.frames)) { nonJsonFrames.frames.push({ frame: { x: col * frameWidth, y: row * frameHeight, w: frameWidth, h: frameHeight }, scaleRatio: 1, rotated: false, trimmed: false, spriteSourceSize: { x: 0, y: 0, w: frameWidth, h: frameHeight }, sourceSize: { w: frameWidth, h: frameHeight } }); } } } aspect = calculateAspectRatio(frameWidth, frameHeight, aspectFactor); spriteDataRef.current = nonJsonFrames; } //scale ratio for standalone sprite if (spriteDataRef.current && spriteDataRef.current.frames) { spriteDataRef.current.frames = calculateScaleRatio(spriteDataRef.current.frames); } } else if (_spriteTexture) { spriteDataRef.current = json; spriteDataRef.current.frames = parseFrames(); totalFrames.current = Array.isArray(json.frames) ? json.frames.length : Object.keys(json.frames).length; const { w, h } = getFirstFrame(json.frames).sourceSize; aspect = calculateAspectRatio(w, h, aspectFactor); } setSpriteData(spriteDataRef.current); if ('encoding' in _spriteTexture) { _spriteTexture.encoding = 3001; // sRGBEncoding } else if ('colorSpace' in _spriteTexture) { //@ts-ignore _spriteTexture.colorSpace = THREE.SRGBColorSpace; } setSpriteTexture(_spriteTexture); setSpriteObj({ spriteTexture: _spriteTexture, spriteData: spriteDataRef.current, aspect: aspect }); }, [getRowsAndColumns, numberOfFrames, parseFrames, calculateAspectRatio, calculateScaleRatio]); /** * */ const loadJsonAndTextureAndExecuteCallback = React.useCallback((jsonUrl, textureUrl, callback) => { 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]); }); }, [textureLoader]); const loadStandaloneSprite = React.useCallback(textureUrl => { if (!textureUrl && !inputRef.current) { throw new Error('Either textureUrl or input must be provided'); } const validUrl = textureUrl !== null && textureUrl !== void 0 ? textureUrl : inputRef.current; if (!validUrl) { throw new Error('A valid texture URL must be provided'); } textureLoader.load(validUrl, texture => parseSpriteData(null, texture)); }, [textureLoader, parseSpriteData]); const loadJsonAndTexture = React.useCallback((textureUrl, jsonUrl) => { if (jsonUrl && textureUrl) { loadJsonAndTextureAndExecuteCallback(jsonUrl, textureUrl, parseSpriteData); } else { loadStandaloneSprite(textureUrl); } }, [loadJsonAndTextureAndExecuteCallback, loadStandaloneSprite, parseSpriteData]); React.useLayoutEffect(() => { if (jsonRef.current && inputRef.current) { loadJsonAndTextureAndExecuteCallback(jsonRef.current, inputRef.current, parseSpriteData); } else if (inputRef.current) { // only load the texture, this is an image sprite only loadStandaloneSprite(); } const _inputRef = inputRef.current; return () => { if (_inputRef) { useLoader.clear(TextureLoader, _inputRef); } }; }, [loadJsonAndTextureAndExecuteCallback, loadStandaloneSprite, parseSpriteData]); React.useLayoutEffect(() => { onLoad == null || onLoad(spriteTexture, spriteData !== null && spriteData !== void 0 ? spriteData : null); }, [spriteTexture, spriteData, onLoad]); return { spriteObj, loadJsonAndTexture }; } useSpriteLoader.preload = url => useLoader.preload(TextureLoader, url); useSpriteLoader.clear = input => useLoader.clear(TextureLoader, input); export { checkIfFrameIsEmpty, getFirstFrame, useSpriteLoader };