@@ -391,6 +391,9 @@ export function VanillaScene({
391391 onBuildRef . current = onBuild ;
392392 const onCameraChangeRef = useRef ( onCameraChange ) ;
393393 onCameraChangeRef . current = onCameraChange ;
394+ // Debounce handle for syncing FPV position to options/URL once movement
395+ // settles (FPV emits per-frame; we only want the resting spot).
396+ const fpvSettleTimerRef = useRef ( 0 ) ;
394397 const onSelectionChangeRef = useRef ( onSelectionChange ) ;
395398 onSelectionChangeRef . current = onSelectionChange ;
396399 const onHoverChangeRef = useRef ( onHoverChange ) ;
@@ -952,14 +955,20 @@ export function VanillaScene({
952955 const scene = sceneRef . current ;
953956 const camera = cameraRef . current ;
954957 if ( ! scene || ! camera ) return ;
958+ // FPV is authoritative over the live camera. We DO sync FPV's position back
959+ // to options (debounced, so the scene URL captures where you walked), but
960+ // re-applying that camera here would fight FPV's own per-frame writes — its
961+ // target is a derived look-ahead point, not the stored eye. Skip; the
962+ // initial pose still restores via scene creation (Effect 1) on load.
963+ if ( options . dragMode === "fpv" ) return ;
955964 camera . update ( {
956965 rotX : options . rotX ,
957966 rotY : options . rotY ,
958967 zoom : options . zoom * LEGACY_ZOOM_COMPAT ,
959968 target : options . target as Vec3 ,
960969 } ) ;
961970 scene . applyCamera ( ) ;
962- } , [ options . rotX , options . rotY , options . zoom , options . target ] ) ;
971+ } , [ options . rotX , options . rotY , options . zoom , options . target , options . dragMode ] ) ;
963972
964973 // Effect 2b — lighting + shadow updates. Runs only when the light, shadow,
965974 // textureLighting, or ground color actually change (sliders, not camera).
@@ -1039,11 +1048,27 @@ export function VanillaScene({
10391048 lookSensitivity : options . fpvLookSensitivity ,
10401049 invertY : options . fpvInvertY ,
10411050 } ) ;
1042- // FPV is authoritative over the camera while engaged — don't echo
1043- // its per-frame writes back into React state; that round-trip fights
1044- // the rAF tick and causes visible jitter on mouselook and walk.
1045- // The React side picks up the final camera state when the user
1046- // exits FPV mode (next controls rebuild reads scene.getOptions()).
1051+ // FPV is authoritative over the camera while engaged — don't echo its
1052+ // per-frame writes back into React state; that round-trip fights the
1053+ // rAF tick and jitters mouselook/walk. But once movement SETTLES
1054+ // (~900ms idle) we sync the resting pose to options so the scene URL
1055+ // captures where you walked. We store the EYE position (getOrigin) as
1056+ // `target`: on reload FPV re-seeds its origin from camera.target, so
1057+ // the eye lands back at that spot. The camera-apply effect skips fpv
1058+ // mode, so this write never fights the live controls.
1059+ fpv . addEventListener ( "change" , ( ) => {
1060+ if ( fpvSettleTimerRef . current ) window . clearTimeout ( fpvSettleTimerRef . current ) ;
1061+ fpvSettleTimerRef . current = window . setTimeout ( ( ) => {
1062+ const st = scene . camera . state ;
1063+ const eye = fpv . getOrigin ( ) ;
1064+ onCameraChangeRef . current ?.( {
1065+ rotX : st . rotX ?? 90 ,
1066+ rotY : st . rotY ?? 0 ,
1067+ zoom : ( st . zoom ?? 1 ) / LEGACY_ZOOM_COMPAT ,
1068+ target : [ eye [ 0 ] , eye [ 1 ] , eye [ 2 ] ] ,
1069+ } ) ;
1070+ } , 900 ) ;
1071+ } ) ;
10471072 return fpv ;
10481073 }
10491074 const factory = options . dragMode === "pan" ? createPolyMapControls : createPolyOrbitControls ;
@@ -1061,11 +1086,13 @@ export function VanillaScene({
10611086 return controls ;
10621087 } ;
10631088 if ( controlsRef . current ) controlsRef . current . destroy ( ) ;
1089+ if ( fpvSettleTimerRef . current ) { window . clearTimeout ( fpvSettleTimerRef . current ) ; fpvSettleTimerRef . current = 0 ; }
10641090 controlsRef . current = buildControls ( ) ;
10651091 return ( ) => {
10661092 // Effect re-runs when deps change — destroy only on full unmount,
10671093 // which is signaled by the scene Effect 1 cleanup destroying scene.
10681094 // Until then, the next effect run will reuse + update controlsRef.
1095+ if ( fpvSettleTimerRef . current ) { window . clearTimeout ( fpvSettleTimerRef . current ) ; fpvSettleTimerRef . current = 0 ; }
10691096 } ;
10701097 } , [
10711098 options . renderer ,
0 commit comments