diff --git a/examples/Shared/assets/hiphopgirl_offset.glb b/examples/Shared/assets/hiphopgirl_offset.glb new file mode 100644 index 00000000..97915a43 Binary files /dev/null and b/examples/Shared/assets/hiphopgirl_offset.glb differ diff --git a/examples/Shared/src/App.tsx b/examples/Shared/src/App.tsx index 523b50bd..92876897 100644 --- a/examples/Shared/src/App.tsx +++ b/examples/Shared/src/App.tsx @@ -21,6 +21,7 @@ import { ChangeMaterials } from './ChangeMaterials' import { SkyboxExample } from './SkyboxExample' import { MorphTargets } from './MorphTargets' import { ReanimatedRotation } from './ReanimatedRotation' +import { FrustumCulling } from './FrustumCulling' function NavigationItem(props: { name: string; route: string }) { const navigation = useNavigation() @@ -68,6 +69,7 @@ function HomeScreen() { + ) } @@ -112,6 +114,7 @@ function App() { + diff --git a/examples/Shared/src/FrustumCulling.tsx b/examples/Shared/src/FrustumCulling.tsx new file mode 100644 index 00000000..fd09695c --- /dev/null +++ b/examples/Shared/src/FrustumCulling.tsx @@ -0,0 +1,336 @@ +import * as React from 'react' +import { useEffect, useState } from 'react' +import { Dimensions, StyleSheet, View, Text, TouchableOpacity } from 'react-native' +import { + FilamentScene, + FilamentView, + Camera, + ModelRenderer, + Animator, + DefaultLight, + useCameraManipulator, + useModel, + useFilamentContext, + getAssetFromModel, +} from 'react-native-filament' +import { Gesture, GestureDetector } from 'react-native-gesture-handler' +import { useSharedValue } from 'react-native-worklets-core' +import HipHopGirlGlb from '@assets/hiphopgirl.glb' +import HipHopGirlOffsetGlb from '@assets/hiphopgirl_offset.glb' + +const SCALE_OPTIONS = [1, 2, 5, 10, 100, 1000] + +type ModelType = 'normal' | 'offset' + +const MODEL_SOURCES = { + normal: HipHopGirlGlb, + offset: HipHopGirlOffsetGlb, +} as const + +// Camera positions for each model type +const CAMERA_POSITIONS = { + normal: { + orbitHomePosition: [0, 1, 5] as [number, number, number], + targetPosition: [0, 1, 0] as [number, number, number], + }, + offset: { + // Offset model is at X ≈ 1 (mesh vertices baked at X≈100, scale 0.01) + orbitHomePosition: [1, 1, 5] as [number, number, number], + targetPosition: [1, 1, 0] as [number, number, number], + }, +} + +interface TestConfig { + scaleFactor: number + modelType: ModelType +} + +// Settings screen to select model type and scale factor +function ConfigSelector({ onSelect }: { onSelect: (config: TestConfig) => void }) { + const [modelType, setModelType] = useState('normal') + + return ( + + Frustum Culling Test + + Select Model: + + setModelType('normal')} + > + + Normal (origin) + + + setModelType('offset')} + > + + Offset (X=10) + + + + + + Select BoundingBox Scale Factor: + + + {SCALE_OPTIONS.map((scale) => ( + onSelect({ scaleFactor: scale, modelType })} + > + {scale}x + + ))} + + + {modelType === 'offset' + ? 'Offset model tests PR #328 fix (scale from center vs origin).' + : 'Normal model is at origin - offset model recommended for testing.'} + + + ) +} + +// Renderer component that loads and displays the model +function Renderer({ scaleFactor, modelType }: { scaleFactor: number; modelType: ModelType }) { + const animationIndex = useSharedValue(2) // IDLE animation + + const { renderableManager } = useFilamentContext() + const model = useModel(MODEL_SOURCES[modelType]) + + // Auto-apply scale when model is loaded + useEffect(() => { + if (model.state !== 'loaded') return + + const asset = getAssetFromModel(model) + if (asset == null) return + + try { + renderableManager.scaleBoundingBox(asset, scaleFactor) + console.log(`Scaled bounding box by factor: ${scaleFactor}`) + } catch (e) { + console.log('scaleBoundingBox failed:', e) + } + }, [model.state, renderableManager, scaleFactor]) + + // Camera with gesture control - position based on model type + const cameraPosition = CAMERA_POSITIONS[modelType] + const cameraManipulator = useCameraManipulator({ + orbitHomePosition: cameraPosition.orbitHomePosition, + targetPosition: cameraPosition.targetPosition, + orbitSpeed: [0.003, 0.003], + }) + + // Pan gesture for camera rotation + const viewHeight = Dimensions.get('window').height + const panGesture = Gesture.Pan() + .onBegin((event) => { + const yCorrected = viewHeight - event.translationY + cameraManipulator?.grabBegin(event.translationX, yCorrected, false) + }) + .onUpdate((event) => { + const yCorrected = viewHeight - event.translationY + cameraManipulator?.grabUpdate(event.translationX, yCorrected) + }) + .maxPointers(1) + .onEnd(() => { + cameraManipulator?.grabEnd() + }) + + // Pinch gesture for zoom + const previousScale = useSharedValue(1) + const scaleMultiplier = 100 + const pinchGesture = Gesture.Pinch() + .onBegin(({ scale }) => { + previousScale.value = scale + }) + .onUpdate(({ scale, focalX, focalY }) => { + const delta = scale - previousScale.value + cameraManipulator?.scroll(focalX, focalY, -delta * scaleMultiplier) + previousScale.value = scale + }) + const combinedGesture = Gesture.Race(pinchGesture, panGesture) + + return ( + + + + + + + + {model.state === 'loaded' && ( + + )} + + + + + + Frustum Culling Test + + Rotate the camera to check if the model disappears at certain angles. + + + Drag: rotate camera / Pinch: zoom + + {model.state !== 'loaded' ? ( + Loading model... + ) : ( + + Model: {modelType} | Scale: {scaleFactor}x + + )} + + + ) +} + +export function FrustumCulling() { + const [config, setConfig] = useState(null) + + if (config === null) { + return + } + + return ( + + + + + setConfig(null)}> + Back + + + ) +} + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + filamentView: { + flex: 1, + backgroundColor: 'lightblue', + }, + loadingText: { + fontSize: 14, + color: '#666', + marginTop: 8, + }, + info: { + padding: 16, + backgroundColor: 'white', + borderTopWidth: StyleSheet.hairlineWidth, + borderTopColor: '#ccc', + }, + title: { + fontSize: 16, + fontWeight: 'bold', + color: '#333', + marginBottom: 8, + }, + instructions: { + fontSize: 14, + color: '#666', + marginBottom: 4, + }, + controls: { + fontSize: 12, + color: '#888', + marginBottom: 4, + }, + scaleApplied: { + fontSize: 12, + fontFamily: 'monospace', + color: '#34C759', + marginTop: 8, + }, + // Selector styles + selectorContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + backgroundColor: '#f5f5f5', + padding: 20, + }, + selectorTitle: { + fontSize: 24, + fontWeight: 'bold', + color: '#333', + marginBottom: 16, + }, + selectorDescription: { + fontSize: 16, + color: '#666', + marginBottom: 24, + }, + buttonGrid: { + flexDirection: 'row', + flexWrap: 'wrap', + justifyContent: 'center', + gap: 12, + maxWidth: 300, + }, + scaleButton: { + backgroundColor: '#007AFF', + paddingVertical: 16, + paddingHorizontal: 24, + borderRadius: 8, + minWidth: 80, + alignItems: 'center', + }, + scaleButtonText: { + color: 'white', + fontSize: 18, + fontWeight: 'bold', + }, + hint: { + fontSize: 12, + color: '#888', + marginTop: 24, + textAlign: 'center', + }, + modelButtonRow: { + flexDirection: 'row', + gap: 12, + }, + modelButton: { + paddingVertical: 12, + paddingHorizontal: 20, + borderRadius: 8, + borderWidth: 2, + borderColor: '#007AFF', + backgroundColor: 'white', + }, + modelButtonSelected: { + backgroundColor: '#007AFF', + }, + modelButtonText: { + fontSize: 14, + fontWeight: 'bold', + color: '#007AFF', + }, + modelButtonTextSelected: { + color: 'white', + }, + backButton: { + position: 'absolute', + top: 50, + left: 16, + backgroundColor: 'rgba(0, 0, 0, 0.6)', + paddingVertical: 8, + paddingHorizontal: 16, + borderRadius: 8, + }, + backButtonText: { + color: 'white', + fontSize: 14, + fontWeight: 'bold', + }, +}) diff --git a/package/cpp/core/RNFRenderableManagerImpl.cpp b/package/cpp/core/RNFRenderableManagerImpl.cpp index b86b4a3f..7a6d2358 100644 --- a/package/cpp/core/RNFRenderableManagerImpl.cpp +++ b/package/cpp/core/RNFRenderableManagerImpl.cpp @@ -284,8 +284,10 @@ void RenderableManagerImpl::scaleBoundingBox(std::shared_ptr(scaleFactor); + Box box = Box().set(center - scaledHalfExtent, center + scaledHalfExtent); renderableManager.setAxisAlignedBoundingBox(renderable, box); } } diff --git a/package/src/hooks/useBuffer.ts b/package/src/hooks/useBuffer.ts index df1211c1..4a966caa 100644 --- a/package/src/hooks/useBuffer.ts +++ b/package/src/hooks/useBuffer.ts @@ -1,7 +1,7 @@ -import { useEffect, useMemo, useState } from 'react' +import { useMemo } from 'react' import { FilamentBuffer } from '../native/FilamentBuffer' import { FilamentProxy } from '../native/FilamentProxy' -import { withCleanupScope } from '../utilities/withCleanupScope' +import { useDisposableResource } from './useDisposableResource' import { Image } from 'react-native' // In React Native, `require(..)` returns a number. @@ -31,9 +31,7 @@ export interface BufferProps { /** * Asynchronously load an asset from the given web URL, local file path, or resource ID. */ -export function useBuffer({ source: source, releaseOnUnmount = true }: BufferProps): FilamentBuffer | undefined { - const [buffer, setBuffer] = useState(undefined) - +export function useBuffer({ source, releaseOnUnmount = true }: BufferProps): FilamentBuffer | undefined { const uri = useMemo(() => { if (typeof source === 'object') { return source.uri @@ -49,23 +47,5 @@ export function useBuffer({ source: source, releaseOnUnmount = true }: BufferPro return asset.uri }, [source]) - // TODO: useDisposableResource - useEffect(() => { - let localBuffer: FilamentBuffer | undefined - FilamentProxy.loadAsset(uri) - .then((asset) => { - localBuffer = asset - setBuffer(asset) - }) - .catch((error) => { - console.error(`Failed to load asset: ${uri}`, error) - }) - return withCleanupScope(() => { - if (releaseOnUnmount) { - localBuffer?.release() - } - }) - }, [releaseOnUnmount, uri]) - - return buffer + return useDisposableResource(() => FilamentProxy.loadAsset(uri), [uri], { releaseOnUnmount }) } diff --git a/package/src/hooks/useDisposableResource.ts b/package/src/hooks/useDisposableResource.ts index aaa02efa..9e992ee9 100644 --- a/package/src/hooks/useDisposableResource.ts +++ b/package/src/hooks/useDisposableResource.ts @@ -1,4 +1,4 @@ -import { DependencyList, useEffect, useState } from 'react' +import { DependencyList, useEffect, useRef, useState } from 'react' import { withCleanupScope } from '../utilities/withCleanupScope' import { PointerHolder } from '../types/PointerHolder' @@ -6,6 +6,14 @@ type ReleasingResource = Pick const emptyStaticArray: DependencyList = [] +export interface UseDisposableResourceOptions { + /** + * Whether to release the resource when the component unmounts. + * @default true + */ + releaseOnUnmount?: boolean +} + /** * Any resource that is a {@link PointerHolder} and can be released, should be loaded * using this hook. It takes care of properly releasing the resource when the component @@ -16,16 +24,32 @@ const emptyStaticArray: DependencyList = [] */ export const useDisposableResource = ( initialize: () => Promise | undefined, - deps?: DependencyList + deps?: DependencyList, + options?: UseDisposableResourceOptions ): T | undefined => { + const { releaseOnUnmount = true } = options ?? {} const [resource, setResource] = useState() + const tokenRef = useRef(0) useEffect(() => { + const currentToken = ++tokenRef.current let isValid = true let currentAsset: T | undefined initialize()?.then((a) => { if (a == null) return + if (currentToken !== tokenRef.current) { + // This is a stale callback from a previous mount (e.g., StrictMode double-mount). + // Don't call setResource() - we don't want to update state with stale data. + // Don't call a.release() - it may interfere with resources in the new mount. + // + // Memory cleanup: The asset `a` will be released when: + // 1. This callback returns and `a` goes out of scope + // 2. JavaScript GC collects the Promise closure + // 3. The prevented release triggers the C++ shared_ptr destructor + return + } + if (isValid) { // this useEffect is still mounted setResource(a) @@ -42,9 +66,11 @@ export const useDisposableResource = ( return () => { setResource(undefined) isValid = false - withCleanupScope(() => { - currentAsset?.release() - })() + if (releaseOnUnmount) { + withCleanupScope(() => { + currentAsset?.release() + })() + } } // eslint-disable-next-line react-hooks/exhaustive-deps }, deps ?? emptyStaticArray)