ecctrl

repository·main·Indexed 20 days ago

https://github.com/pmndrs/ecctrl

A modular toolkit for physics-driven character, vehicle, and custom-gravity controllers for React Three Fiber and Rapier. Version 2.0.0 supports ShapeCast characters, vehicles with combustion-engine configurations, propeller drones, and a lightweight animation state resolver. It includes utilities for performance-optimized curve LUTs, manual physics timing via TimeControl, and DOM-based touch controls like Joysticks and VirtualButtons.

Tokens
24.5K
Snippets
74
Records
89
Agent score
72%

What's inside ecctrl

  1. Drive input via stores or controller refs

    main

    You can bypass the built-in UI and drive input manually in two ways:

    1. Using Input Stores: Use useJoystickStore and useButtonStore to programmatically set input states. This is useful if you are building a custom UI.
    2. Using Controller Refs: Call methods directly on the controller instance (e.g., ecctrl.current or vehicle.current) to set movement states.
    // Using stores
    import { useButtonStore, useJoystickStore } from "ecctrl/input";
    
    useJoystickStore.getState().setJoystick(x, y);
    useButtonStore.getState().setButtonActive("jump", true);
    
    // Using controller refs
    ecctrl.current?.setMovement({ forward: true, jump: false });
    vehicle.current?.setMovement({ forward: true, steerLeft: true });
  2. Manage Character Animation States

    main

    The EcctrlAnimationStateController automatically resolves a character's animation state (e.g., IDLE, WALK, RUN, JUMP_START, etc.) based on the character controller's physical state and movement inputs.

    How it works: It monitors the EcctrlHandle for properties like isOnGround, isFalling, isMoving, and runActive.

    Customization: You can provide a custom resolver to EcctrlAnimationStateControllerProps to change how states are determined. The onChange callback triggers whenever the resolved state changes.

    import {
      EcctrlAnimationStateController,
      resolveEcctrlAnimationState,
      useEcctrlAnimationStore,
      type EcctrlAnimationState,
      type EcctrlAnimationStateContext,
      type EcctrlAnimationStateResolver,
    } from "ecctrl/animation";
    
    // States: "IDLE" | "WALK" | "RUN" | "JUMP_START" | "JUMP_IDLE" | "JUMP_FALL" | "JUMP_LAND"
  3. Optimize performance for large scenes

    main

    Ecctrl is designed for high performance using runtime-friendly patterns like ref-based state and baked LUTs. To optimize larger scenes:

    • Detection Modes: Use ShapeCast for complex character behavior. Switch specific characters or wheels to RayCast mode when you need the lowest possible detection cost.
    • Subpath Imports: Use subpath exports to avoid importing unused systems.
    • Gravity: Use dynamic gravity that reads from refs to avoid React or Zustand updates every frame.
  4. Import Ecctrl subpaths for optimized builds

    main

    Ecctrl uses subpath exports to allow you to import only the specific systems you need, reducing bundle size. You can also use ecctrl/all to import every public subpath, which is useful for prototyping.

    Common import patterns:

    • Core character controller: ecctrl
    • Vehicles: ecctrl/vehicle
    • Input components: ecctrl/input
    • Animation: ecctrl/animation
    • Gravity: ecctrl/gravity
    • Camera: ecctrl/camera
    • Time: ecctrl/time
    • Curves: ecctrl/curves
    • Leva integration: ecctrl/leva
    import { Ecctrl } from "ecctrl";
    import { EcctrlVehicle, ShapeCastWheel, ThrustPropeller } from "ecctrl/vehicle";
    import { Joystick, VirtualButton } from "ecctrl/input";
    import { EcctrlAnimationStateController } from "ecctrl/animation";
    import { useCustomGravity } from "ecctrl/gravity";
    import { EcctrlCameraControls } from "ecctrl/camera";
    import { TimeControl } from "ecctrl/time";
    import { bakeCurveLUT, evaluateCurveLUT } from "ecctrl/curves";
    import { CurveEditorPlugin } from "ecctrl/leva";
  5. Configure Custom Gravity in Ecctrl

    main

    To use custom gravity fields instead of Rapier's global gravity, you must:

    1. Set the <Physics> gravity to [0, 0, 0] to prevent forces from stacking.
    2. Add the enableCustomGravity prop to the <Ecctrl> component.
    3. Use the useCustomGravity hook to define a gravity field function that calculates gravity based on a body's position.
    import { useEffect } from "react";
    import * as THREE from "three";
    import { Physics } from "@react-three/rapier";
    import { Ecctrl } from "ecctrl";
    import { useCustomGravity } from "ecctrl/gravity";
    
    const center = new THREE.Vector3(0, 20, 0);
    const gravity = new THREE.Vector3();
    
    function GravitySetup() {
      const setGravityField = useCustomGravity((state) => state.setGravityField);
    
      useEffect(() => {
        setGravityField((bodyPos) => gravity.subVectors(center, bodyPos).normalize().multiplyScalar(9.81));
      }, [setGravityField]);
    
      return null;
    }
    
    export function Scene() {
      return (
        <Physics gravity={[0, 0, 0]}>
          <GravitySetup />
          <Ecctrl enableCustomGravity>
            <CharacterModel />
          </Ecctrl>
        </Physics>
      );
    }
  6. Implement Custom Gravity Fields

    main

    Use the useCustomGravity hook to define non-standard gravity environments (e.g., radial gravity or directional fields).

    Workflow:

    1. Call useCustomGravity to get the setGravityField setter.
    2. Define a function (pos: THREE.Vector3) => THREE.Vector3 that returns the gravity vector for a given world position.
    3. Use setGravityField to update the field. For per-frame changes, keep the function reference stable and update internal refs used by the function to avoid unnecessary re-renders.
    import { useCustomGravity, type CustomGravityState } from "ecctrl/gravity";
    
    const setGravityField = useCustomGravity((state) => state.setGravityField);
    const center = useRef(new THREE.Vector3(0, 20, 0));
    const gravity = useMemo(() => new THREE.Vector3(), []);
    
    useEffect(() => {
      setGravityField((pos) => {
        // Example: Radial gravity pulling towards 'center'
        return gravity.subVectors(center.current, pos).normalize().multiplyScalar(9.81);
      });
    }, [setGravityField, gravity]);
  7. Implement animation state with EcctrlAnimationStateController

    main

    Ecctrl uses a lightweight animation state resolver. The controller writes the current state into useEcctrlAnimationStore, which you can consume to drive your model's animations.

    Default States: "IDLE" | "WALK" | "RUN" | "JUMP_START" | "JUMP_IDLE" | "JUMP_FALL" | "JUMP_LAND"

    Basic Setup:

    1. Use EcctrlAnimationStateController to link the Ecctrl handle to the animation store.
    2. Consume useEcctrlAnimationStore in your model component to react to state changes.

    Custom State Mapping: Pass a resolver to EcctrlAnimationStateController to implement custom logic (e.g., switching to RUN based on moveSpeed). Use resolveEcctrlAnimationState(ctx) within your resolver to fall back to default behavior.

    import { useEffect, useRef } from "react";
    import { Ecctrl, type EcctrlHandle } from "ecctrl";
    import { EcctrlAnimationStateController, useEcctrlAnimationStore } from "ecctrl/animation";
    import { useAnimations, useGLTF } from "@react-three/drei";
    
    const ANIMATION_MAP = {
      IDLE: "Idle",
      WALK: "Walk",
      RUN: "Run",
      JUMP_START: "Jump_Start",
      JUMP_IDLE: "Jump_Idle",
      JUMP_FALL: "Jump_Fall",
      JUMP_LAND: "Jump_Land",
    } as const;
    
    function AnimatedCharacterModel() {
      const group = useRef(null);
      const { scene, animations } = useGLTF("/character.glb");
      const { actions } = useAnimations(animations, group);
      const animationState = useEcctrlAnimationStore((state) => state.animationState);
    
      useEffect(() => {
        const action = actions[ANIMATION_MAP[animationState]];
        if (!action) return;
    
        action.reset().fadeIn(0.15).play();
        return () => action.fadeOut(0.15);
      }, [actions, animationState]);
    
      return (
        <group ref={group}>
          <primitive object={scene} />
        </group>
      );
    }
    
    function CharacterWithAnimation() {
      const ecctrl = useRef<EcctrlHandle>(null);
    
      return (
        <>
          <EcctrlAnimationStateController ecctrl={ecctrl} />
          <Ecctrl ref={ecctrl}>
            <AnimatedCharacterModel />
          </Ecctrl>
        </>
      );
    }
  8. Add touch screen controls with EcctrlJoystick

    main

    Ecctrl supports touch screen controls via the built-in EcctrlJoystick component.

    Important: You must place the <EcctrlJoystick /> component outside of your <Canvas> component.

    import Ecctrl, {EcctrlJoystick} from 'ecctrl'
    
    // ...
    <EcctrlJoystick />
    <Canvas>
      {/* ... */}
    </Canvas>
  9. Build a propeller drone with EcctrlVehicle

    main

    Drones are built using EcctrlVehicle combined with ThrustPropeller modules. The vehicle acts as the 'flight brain', mixing throttle and managing stability.

    Drone Control Modes:

    • POSITION: Position-targeted flight. The controller stabilizes tilt, yaw, and vertical movement based on a target position.
    • VELOCITY: Manual velocity-style flight. Input maps to target horizontal, vertical, yaw, pitch, and roll behavior.

    Propeller Configuration:

    • maxThrust: Maximum thrust per propeller.
    • torqueRatio: Reaction torque ratio.
    • invertThrust: Inverts the thrust direction.
    • invertTorque: Inverts the reaction torque direction.
    import { EcctrlVehicle, ThrustPropeller } from "ecctrl/vehicle";
    import { CuboidCollider } from "@react-three/rapier";
    
    <EcctrlVehicle
      droneConfig={{
        controlMode: "POSITION",
        maxHorizSpeed: 20,
        maxVertSpeed: 8,
        maxTiltAngle: Math.PI / 4,
      }}
    >
      <CuboidCollider args={[0.6, 0.15, 0.6]} />
      <ThrustPropeller position={[1, 0, 1]} />
      <ThrustPropeller position={[-1, 0, 1]} invertTorque />
      <ThrustPropeller position={[1, 0, -1]} invertTorque />
      <ThrustPropeller position={[-1, 0, -1]} />
    </EcctrlVehicle>
  10. Integrate the Ecctrl character controller

    main

    The Ecctrl component is the main entry point for the character controller. It is designed to work with @react-three/fiber and @react-three/rapier.

    To control the character, use a useRef with the EcctrlHandle type to access the controller's state and methods. The Ecctrl component extends RigidBodyProps, so you can pass standard Rapier properties like position, rotation, density, and gravityScale directly to it.

    Note on Density: Default values are tuned for a Rapier density of approximately 1. If you use much higher densities (e.g., 200), you must scale up suspension, damping, engine power, and braking torque values accordingly.

    const ecctrl = useRef<EcctrlHandle>(null);
    
    <Ecctrl ref={ecctrl}>
      <CharacterModel />
    </Ecctrl>
  11. Implement click-to-move with PointToMove mode

    main

    When using mode="PointToMove", you can move the character to specific coordinates using the setMoveToPoint function retrieved from the useGame hook. The point argument must be a vec3 value.

    import { useGame } from 'ecctrl'
    
    // ...
    const setMoveToPoint = useGame((state) => state.setMoveToPoint)
    
    // Call this function whenever the character needs to move to a specific location
    setMoveToPoint(point) // 'point' is a vec3 value
  12. Use Input Components (Joystick and VirtualButton)

    main

    Ecctrl provides built-in UI components for mobile/touch input that integrate with global stores.

    Joystick

    • Output: Normalized to approximately [-1, 1] for both X and Y.
    • Store: Access via useJoystickStore. Use setJoystick(x, y, id) to update or resetJoystick(id) to clear.

    VirtualButton

    • Store: Access via useButtonStore. Use setButtonActive(id, active) to toggle or resetAllButtons() to clear all.
    • Requirement: Every VirtualButton must have a unique id.
    import {
      Joystick,
      VirtualButton,
      useJoystickStore,
      useButtonStore,
    } from "ecctrl/input";