Skip to content

The camera position is not updated in real time. #104

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
Alecapra96 opened this issue Apr 20, 2025 · 12 comments
Open

The camera position is not updated in real time. #104

Alecapra96 opened this issue Apr 20, 2025 · 12 comments
Labels
question Further information is requested

Comments

@Alecapra96
Copy link

import { OrbitControls } from "@playcanvas/react/scripts";
import { Application, Entity } from "@playcanvas/react";
import { FILLMODE_FILL_WINDOW, RESOLUTION_AUTO } from "playcanvas";

export function App() {
  return (
    <Application
      fillMode={FILLMODE_FILL_WINDOW}
      resolutionMode={RESOLUTION_AUTO}
    >
      <Entity name="camera" position={[11, 0, 2]}>
        <Camera />
        <OrbitControls />
      </Entity>
      <Entity name="box" scale={[4, 4, 4]}>
        <Render type="box" />
      </Entity>
    </Application>
  );
}

export default App;

If I change the position of <Entity name="camera" position={[11, 0, 2]}> It doesn't take effect until I refresh the page.
The solution I found is:

// src/app.tsx
import { useState } from "react";
import { Application, Entity } from "@playcanvas/react";
import { Camera, Render } from "@playcanvas/react/components";
import { OrbitControls } from "@playcanvas/react/scripts";
import { FILLMODE_FILL_WINDOW, RESOLUTION_AUTO } from "playcanvas";

export function App() {
  // Camera position presets
  const presets: Record<string, [number, number, number]> = {
    front: [0, 0, 4],
    side: [4, 0, 0],
    top: [0, 4, 0],
    iso: [2, 2, 2],
  };

  // State for the current camera position
  const [camPos, setCamPos] = useState<[number, number, number]>(presets.front);

  return (
    <div className="w-full h-full dark">
      <Application
        fillMode={FILLMODE_FILL_WINDOW}
        resolutionMode={RESOLUTION_AUTO}
      >
        {/* Key based on position forces Entity remount */}
        <Entity key={camPos.join(",")} name="camera" position={camPos}>
          <Camera />
          <OrbitControls />
        </Entity>

        <Entity name="box" scale={[4, 4, 4]}>
          <Render type="box" />
        </Entity>
      </Application>

      {/* UI controls to change the camera position */}
      <div className="absolute top-4 left-4 flex space-x-2 z-10">
        {Object.entries(presets).map(([label, coords]) => (
          <button
            key={label}
            onClick={() => setCamPos(coords)}
            className="px-4 py-2 bg-blue-600 text-white rounded shadow hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-300"
          >
            {label}
          </button>
        ))}
      </div>
    </div>
  );
}

export default App;

But it's not the best, and if I want to make animations with the camera or transitions from front to back, I don't know how to do it.

Dependencies :

"dependencies": {
"@playcanvas/react": "^0.3.0",
"@preact/preset-vite": "^2.10.1",
"@tailwindcss/vite": "^4.1.4",
"lucide-react": "^0.475.0",
"playcanvas": "2.3.3",
"preact": "^10.26.5",
"tailwindcss": "^4.1.4"
},

BTW playanvas/react 0.3 dont work with the last of playcanvas

@marklundin
Copy link
Member

I will take a look. This may be because the OrbitControls takes control and overrides the camera position

@Alecapra96
Copy link
Author

I will take a look. This may be because the OrbitControls takes control and overrides the camera position

In the example:
https://playcanvas-react.vercel.app/examples/model-viewer
its work in real time for example

@marklundin marklundin added the question Further information is requested label Apr 25, 2025
@marklundin
Copy link
Member

So the behaviour your seeing is because the <OrbitControls /> takes control of the Entity's position, which overrides the position specified via the React props.

To let React manage the position again, you can disable the script by setting its enabled prop to false or unmounting it altogether. This hands control back to React, allowing it to update the transform as expected:

<Entity>
  <Camera />
  { enabledOrbitControls ? <OrbitControls /> : null }
</Entity>

Also worth noting — the playground example linked in the issue is a bit of a special case because it re-renders everything on each change, which can mask this kind of interaction. In a typical app, the Script’s internal logic can persist across renders unless explicitly disabled.

@Alecapra96
Copy link
Author

So the behaviour your seeing is because the <OrbitControls /> takes control of the Entity's position, which overrides the position specified via the React props.

To let React manage the position again, you can disable the script by setting its enabled prop to false or unmounting it altogether. This hands control back to React, allowing it to update the transform as expected:

{ enabledOrbitControls ? : null } Also worth noting — the **playground example linked in the issue is a bit of a special case** because it re-renders everything on each change, which can mask this kind of interaction. In a typical app, the Script’s internal logic can persist across renders unless explicitly disabled.
import { useState } from "react";
import { Application, Entity } from "@playcanvas/react";
import { Camera, Render } from "@playcanvas/react/components";
import { OrbitControls } from "@playcanvas/react/scripts";
import { FILLMODE_FILL_WINDOW, RESOLUTION_AUTO } from "playcanvas";

export function App() {
  // Camera position presets
  const presets: Record<string, [number, number, number]> = {
    front: [10, 10, 4],
    side: [4, 10, 0],
    top: [0, 41, 0],
    iso: [2, 21, 2],
  };

  // State for the current camera position
  const [camPos, setCamPos] = useState<[number, number, number]>(presets.front);
  const [enabledOrbitControls] = useState(true);

  return (
    <div className="w-full h-full dark">
      <Application
        fillMode={FILLMODE_FILL_WINDOW}
        resolutionMode={RESOLUTION_AUTO}
      >
        {/* Key based on position forces Entity remount */}
        <Entity key={camPos.join(",")} name="camera" position={camPos}>
          <Camera />
          {enabledOrbitControls ? <OrbitControls /> : null}
        </Entity>

        <Entity name="box" scale={[4, 4, 4]}>
          <Render type="box" />
        </Entity>
      </Application>

      {/* UI controls to change the camera position */}
      <div className="absolute top-4 left-4 flex space-x-2 z-10">
        {Object.entries(presets).map(([label, coords]) => (
          <button
            key={label}
            onClick={() => setCamPos(coords)}
            className="px-4 py-2 bg-blue-600 text-white rounded shadow hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-300"
          >
            {label}
          </button>
        ))}
      </div>
    </div>
  );
}

export default App;

The buttons already update the camera position in real time with setCamPos(coords), but how can I achieve camera transitions between different positions?

@marklundin
Copy link
Member

There are a couple of ways you could achieve this — either using a Script, or something more React-based.

Animating with <Script/>

You can create a new Script that transitions an entity using the PlayCanvas engine API.
(Example below — untested, but you’ll get the idea.)

import { Script, Vec3 } from 'playcanvas'

class Transition extends Script {
  // Sets the target position
  target = [0, 0, 0]

  // Vec3 representation of the target position
  targetVec3 = new Vec3(0, 0, 0)

  // Vec3 to hold the interpolated (lerped) position
  lerpedPosition = new Vec3()

  // How fast the entity moves towards the target
  speed = 1

  update(dt) {
    this.targetVec3.fromArray(this.target)

    // Lerp from the current position to the target position
    this.lerpedPosition.lerp(this.entity.getPosition(), this.targetVec3, this.speed * dt)

    // Update the entity's position
    this.entity.setPosition(this.lerpedPosition)
  }
}

// In your JSX
<Entity>
  <Camera />
  {enabledOrbitControls ? (
    <OrbitControls /> // Use OrbitControls when enabled
  ) : (
    <Script script={Transition} target={[0, 10, 0]} speed={2} /> // Otherwise, use the Transition Script
  )}
</Entity>

Animating with framermotion/reactspring

Alternatively you could use something like react-spring or framer-motion for a more react friendly approach.

Check the animation example for a demo. It's using a <MotionEntity/> component which adds animation functionality to an Entity

@Alecapra96
Copy link
Author

This is what I want to recreate:
https://yomotsu.github.io/camera-controls/examples/basic.html

But it seems that a camera entity does not behave the same as a normal entity, and I notice many type errors, is this common?

@Alecapra96
Copy link
Author

Alecapra96 commented Apr 28, 2025

import { Entity } from '@playcanvas/react';
import { Camera, EnvAtlas, Render } from '@playcanvas/react/components';
import { useEnvAtlas } from '@playcanvas/react/hooks';
import { OrbitControls } from '@playcanvas/react/scripts';
import { useMotionValueEvent, useTransform, useSpring } from 'framer-motion';
import { Entity as PcEntity } from 'playcanvas';
import envMap from '/helipad-env-atlas.png';

// --- MotionEntity Component Definition ---

interface MotionEntityProps
  extends Omit<React.ComponentProps<typeof Entity>, 'animate'> {
  children?: React.ReactNode;
  animate?: {
    position?: [number, number, number];
    // Explicitly expect Euler angles for animation
    rotation?: [number, number, number];
    scale?: [number, number, number];
  };
  transition?: any;
}

// Helper hook for creating animatable 3D vectors. (No changes needed here)
const useMotionVec3 = (
  initial: number[] | undefined,
  defaultValue = 0,
  config?: any
) => {
  const x = useSpring(initial?.[0] ?? defaultValue, config);
  const y = useSpring(initial?.[1] ?? defaultValue, config);
  const z = useSpring(initial?.[2] ?? defaultValue, config);
  const array = useTransform(
    [x, y, z],
    ([xVal, yVal, zVal]) => [xVal, yVal, zVal] as [number, number, number]
  );
  const animateArray = (target?: number[]) => {
    if (!target) return;
    x.set(target[0] ?? x.get());
    y.set(target[1] ?? y.get());
    z.set(target[2] ?? z.get());
  };
  return { values: [x, y, z], array, animateArray };
};

// The MotionEntity component itself (UPDATED LOGIC FOR ROTATION)
export const MotionEntity: FC<MotionEntityProps> = ({
  children,
  animate: animateProps,
  transition: transitionConfig,
  ...props
}) => {
  // --- UPDATED ROTATION HANDLING ---
  // Check if the initial rotation prop is Euler angles (3 elements)
  const initialEulerRotation =
    Array.isArray(props.rotation) && props.rotation.length === 3
      ? (props.rotation as [number, number, number]) // Type assertion is safe after length check
      : undefined; // If it's a Quaternion or undefined, don't use it as initial value for the hook

  // If initialEulerRotation is undefined, useMotionVec3 will use its default (0)
  const rotation = useMotionVec3(initialEulerRotation, 0, transitionConfig);
  // --- END OF UPDATED ROTATION HANDLING ---

  // Position and Scale remain the same
  const position = useMotionVec3(props.position, 0, transitionConfig);
  const scale = useMotionVec3(props.scale, 1, transitionConfig);

  const entityRef = useRef<PcEntity>(null);

  // Effect to trigger animations when the 'animate' prop changes
  useEffect(() => {
    console.log(animateProps);
    if (animateProps) {
      // Animate position and scale as before
      position.animateArray(animateProps.position);
      scale.animateArray(animateProps.scale);

      // --- UPDATED ROTATION ANIMATION ---
      // Only attempt to animate rotation if it's provided AND it's Euler angles (3 elements)
      if (
        Array.isArray(animateProps.rotation) &&
        animateProps.rotation.length === 3
      ) {
        rotation.animateArray(
          animateProps.rotation as [number, number, number]
        ); // Type assertion safe after check
      } else if (animateProps.rotation !== undefined) {
        // Optional: Warn if a non-Euler rotation was passed for animation
        console.warn(
          'MotionEntity: Rotation animation only supports Euler angles [x, y, z]. Received:',
          animateProps.rotation
        );
      }
      // --- END OF UPDATED ROTATION ANIMATION ---
    }
  }, [animateProps]);

  // Update PlayCanvas entity - Rotation uses setLocalEulerAngles, which matches our 3-value hook
  useMotionValueEvent(position.array, 'change', (latestPosition) => {
    entityRef.current?.setLocalPosition(
      latestPosition[0],
      latestPosition[1],
      latestPosition[2]
    );
  });
  useMotionValueEvent(rotation.array, 'change', (latestRotation) => {
    entityRef.current?.setLocalEulerAngles(
      latestRotation[0],
      latestRotation[1],
      latestRotation[2]
    );
  });
  useMotionValueEvent(scale.array, 'change', (latestScale) => {
    entityRef.current?.setLocalScale(
      latestScale[0],
      latestScale[1],
      latestScale[2]
    );
  });

  // Render the base Entity component
  // We still pass props.rotation here; PlayCanvas Entity handles Quaternions internally for initial setup.
  // The animation hook (rotation) only uses Euler.
  return (
    <Entity
      ref={entityRef}
      {...props} // Includes the original props.rotation if provided
      // Set initial transform from motion values, which are derived from Euler/defaults
      position={position.array.get()}
      rotation={rotation.array.get()} // This will be Euler angles from the hook
      scale={scale.array.get()}
    >
      {children}
    </Entity>
  );
};
// --- End of MotionEntity Definition ---

// --- Camera Position Presets ---
const CAMERA_PRESETS: Record<string, [number, number, number]> = {
  Default: [4, 4, -4],
  'Front Close': [0, 1, -3],
  'Box View': [0, 0, -5],
  'Top Down': [0, 6, 0],
};

// --- The Main Scene Component ---
function Scene() {
  const { asset: envAsset } = useEnvAtlas(envMap);
  const [currentCameraPos, setCurrentCameraPos] = useState<
    [number, number, number]
  >(CAMERA_PRESETS.Default);

  if (!envAsset) {
    return null;
  }

  return (
    <div style={{ position: 'relative', width: '100%', height: '100%' }}>
      {/* Camera Control Toolbar */}
      <div
        style={{
          position: 'absolute',
          top: '10px',
          left: '50%',
          transform: 'translateX(-50%)',
          zIndex: 10,
          background: 'rgba(40, 40, 40, 0.8)',
          padding: '8px 15px',
          borderRadius: '8px',
          display: 'flex',
          gap: '10px',
        }}
      >
        {Object.entries(CAMERA_PRESETS).map(([label, coords]) => (
          <button
            key={label}
            onClick={() => setCurrentCameraPos(coords)}
            disabled={
              /* ... disabled check ... */
              currentCameraPos[0] === coords[0] &&
              currentCameraPos[1] === coords[1] &&
              currentCameraPos[2] === coords[2]
            }
            style={{
              /* ... button styles ... */ padding: '5px 10px',
              cursor: 'pointer',
              border: '1px solid #555',
              borderRadius: '4px',
              background: '#eee',
              color: '#333',
            }}
            onMouseOver={(e) => (e.currentTarget.style.background = '#ddd')}
            onMouseOut={(e) => (e.currentTarget.style.background = '#eee')}
          >
            {label}
          </button>
        ))}
      </div>

      {/* PlayCanvas Scene Content */}
      <>
        <EnvAtlas asset={envAsset} showSkybox={true} />
        {/* Camera using MotionEntity */}
        <MotionEntity
          name="scene-camera"
          // Set initial position using Euler angles for consistency with animation
          position={CAMERA_PRESETS.Default}
          // Rotation prop can still be Quaternion if needed for initial setup,
          // but animation will only work if passed Euler angles via 'animate' prop.
          // rotation={[/* initial rotation if needed */]}
          animate={{ position: currentCameraPos }}
        >
          <Camera clearColor="#b5b5cf" fov={60} />
          <OrbitControls distanceMin={1} distanceMax={20} />
        </MotionEntity>

        {/* Example Box */}
        <Entity name="example-box">
          <Render type="box" />
        </Entity>
      </>
    </div>
  );
}

export default Scene;

// Dependencies: react, react-dom, playcanvas, @playcanvas/react, framer-motion

I try this but didnt work
Fun fact , if you comment ORBIT CONTROLS the box dissaper

@Alecapra96
Copy link
Author

I managed to move the camera away from the origin, but not with an animation, it would be good if the camera had the prop look at

https://stackblitz.com/edit/playcanvas-react-template-u2wqujvx?file=src%2FScene.tsx

@marklundin
Copy link
Member

marklundin commented Apr 29, 2025

I’ve taken a quick look — the key here is to disable the orbit controls while animating to a new location, and then re-enable them once the transition is complete.

Here’s the basic idea:

<Entity>
  <Camera />
  {isTransitioning ? (
    <Script
      script={TransitionToWayPoint}
      location={location}
    />
  ) : (
    <Script
      script={CameraControls}
      focusPoint={waypoint.position}
    />
  )}
</Entity>

I’ve put together a quick example here on StackBlitz.
It shows a very simple transition between target positions — enough to give you the basic idea and something to experiment with.

Let me know if you get stuck — happy to help refine it further!

@Alecapra96
Copy link
Author

Alecapra96 commented May 2, 2025

https://stackblitz.com/edit/playcanvas-react-template-yh7tyzth?file=src%2FApp.tsx

I'm trying to switch between two different camera control scripts on the same camera entity using conditional rendering in React, like this:

{isTransitioning ? <Script script={TransitionScript} ... /> : <Script script={MyOrbitControls} ... />}

When the transition script finishes (isTransitioning becomes false), my MyOrbitControls script mounts. This script needs an initial focusPoint (a pc.Vec3) from my React state so it knows what to orbit around.

The issue is, I can't reliably get this initial focusPoint into the MyOrbitControls script when it mounts.

What I've Tried (and didn't work):

Passing focusPoint via args: Tried <Script script={MyOrbitControls} args={[{ focusPoint: myCorrectVec3 }]} />. Inside the script's initialize(), this.focusPoint (or the internal this._origin) always ends up being [0, 0, 0], not the value I passed. Logs confirm the correct value was calculated in React right before.
Getting a ref to the script instance: Tried using both useRef and a callback ref on the <Script> component. The goal was to grab the instance in a useEffect (that runs when isTransitioning becomes false) and call a setup method like scriptInstance.setFocusAndInitialize(myCorrectVec3). But the ref.current (or the callback node argument) is always null when the useEffect runs, even if I wrap the logic in a setTimeout(0).
The Result (Visual Jank):

Because MyOrbitControls never gets the right initial focusPoint, it starts up using incorrect data (like focusing on [0,0,0]). As soon as it takes control after the smooth TransitionScript finishes, it applies its own incorrectly calculated transform, causing a very noticeable visual jump/snap in the camera view. It doesn't smoothly take over from where the transition ended.

My Guess:

This feels like a timing or synchronization problem between React's conditional rendering/mounting lifecycle and how the @playcanvas/react <Script> component handles passing args or assigning the ref. It seems React can't reliably talk to the script instance right after it's conditionally mounted.

Question:

Is this a known issue or limitation when using conditionally rendered <Script> components, especially with ES6 class scripts? What's the recommended way with @playcanvas/react to get initial state from React into a script instance right when it mounts in a scenario like this? Or is using pc.createScript necessary for this kind of integration to work reliably?

Thanks for any pointers!

@marklundin
Copy link
Member

marklundin commented May 7, 2025

So args={} does not work. You just need to spread the props over the component and they're set before initialize() is called.

<Script script={YourScript} speed={10} />

This sets the speed prop which is available in the initialize as this.speed

It's worth checking if you're getting any warning in dev tools for invalid props. We've got pretty good coverage for validating things

@marklundin
Copy link
Member

Just a heads up @Alecapra96 - script refs are now correctly forwarded on. See #129

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
question Further information is requested
Projects
None yet
Development

No branches or pull requests

2 participants