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)