# Cours 14 - Unreal C++ : Cube flottant tenu & physique de ressort

Le Gravity gun de Half-Life 2 , ça vous dit quelque chose ? Il s’agit d’un dispositif qui permet de tenir des objets flottants à environ 1 mètre devant le joueur, à hauteur de torse. L’objet suit alors le joueur quand celui-ci se déplace, et réagit physiquement quand le joueur tourne, avec de l’inertie et un léger ballottement (wobble).

Rassurez-vous, on ira pas jusqu’à pouvoir lancer les objets, etc., mais au moins donner un mouvement secondaire à l’objet. C’est l’un des savoir-faire les plus précieux du Tech Art : le mouvement secondaire (secondary motion). En animation, le mouvement primaire est piloté (la caméra du joueur, ici). Le mouvement secondaire, c’est tout ce qui suit avec retard et rebond : les cheveux, les vêtements, une antenne, une chaîne, une arme qui balance. On ne l’anime pas à la main : on le simule avec un peu de physique. Si vous avez touché aux spring bones d’un rig, au jiggle deformer, ou aux noeuds “spring” de Blender, c’est exactement le même principe. Ici, vous écrivez la simulation vous-même en C++, ce qui vous donne l’intuition de ce qui se passe sous le capot de ces outils.

On construit AHeldCube : un Actor qui lit la caméra du joueur, calcule un point cible devant lui, et y amène le cube. En Période 1, on fait suivre le cube avec une interpolation simple (lag, inertie douce). En Période 2, on remplace cette interpolation par un vrai ressort-amortisseur qui produit le dépassement et l’oscillation (le wobble), puis on ajoute un bob vertical piloté par la vitesse pour que tourner fasse onduler le cube de haut en bas.

Le projet : un personnage première personne Jusqu'ici (Semaines 12-13), vous n'avez créé que des Actors. Cette semaine, le cube doit suivre un joueur, donc il vous faut un personnage contrôlable. Comme en semaine 12 on a créé notre projet à partir d’un template First Person, tout est en place :

  1. Ce template fournit déjà tout : un personnage première personne avec une caméra à hauteur des yeux, le déplacement clavier/souris, et l’input câblé (Enhanced Input). Vous n’avez aucun code d’input à écrire.

  2. On laisse ce personnage tel quel. Notre cube sera un Actor indépendant qui se contente de lire la caméra du joueur, peu importe le personnage utilisé.

Pourquoi un Actor indépendant plutôt qu’un code dans le personnage ? Parce que le cube ne contrôle rien : il observe le joueur et réagit. En le gardant séparé, on réutilise exactement le squelette d’Actor de la Semaine 13 (Constructor + composant mesh + Tick), et le cube fonctionne avec n’importe quel pawn ou personnage, sans dépendre du template. C’est aussi plus proche de la réalité : un effet de mouvement secondaire est presque toujours un module qu’on attache, pas du code noyé dans le personnage.

Le cube qui suit le joueur (inertie simple)

Ce qu’on réutilise

  • De la Semaine 13 : le squelette d’Actor (AMyTestActor) avec un UProceduralMeshComponent racine et la fonction AppendBox, le Tick activé, les UPROPERTY exposées à l’éditeur, et le réflexe du DeltaTime pour être indépendant du framerate.
  • Des maths des Semaines 2-3 : un vecteur, sa direction (forward), une multiplication scalaire pour avancer le long d’une direction.

L’idée

À chaque frame, on veut placer le cube à un point cible : 1 mètre devant la caméra du joueur, un peu sous la ligne des yeux (hauteur de torse). Si on y téléporte le cube directement, il colle parfaitement à la caméra : rigide, sans vie. À la place, on le fait converger doucement vers la cible. Comme il met un petit temps à rattraper, il traîne quand on bouge : c’est l’inertie.

Créer la classe depuis Unreal

  1. Dans l’éditeur Unreal, Tools → New C++ Class…

  2. Classe parente Actor, Next.

  3. Nommer HeldCube (sans le préfixe A, Unreal l’ajoute), vérifier Public, Create Class.

  4. Unreal génère les fichiers, compile, et ouvre Rider.

Module procédural. Comme en Semaine 13, ce cube est généré en C++. Ajoutez "ProceduralMeshComponent" à PublicDependencyModuleNames dans le Build.cs du projet si ce n’a pas été fait au cours 13, puis régénérez les fichiers projet (Tools → Refresh Rider Uproject Project).

Fichier 1 : HeldCube.h

#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "ProceduralMeshComponent.h"
#include "HeldCube.generated.h"
 
UCLASS()
class MYCPP_API AHeldCube : public AActor
{
    GENERATED_BODY()
 
public:
    AHeldCube();
 
    virtual void OnConstruction(const FTransform& Transform) override;
    virtual void Tick(float DeltaTime) override;
 
    // Distance devant la caméra, en cm (100 = 1 m)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Hold")
    float HoldDistance = 100.f;
 
    // Décalage vertical par rapport aux yeux (négatif = plus bas, vers le torse)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Hold")
    float HoldHeightOffset = -20.f;
 
    // Vitesse de rattrapage en position (plus grand = moins d'inertie)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Hold")
    float FollowSpeed = 6.f;
 
    // Vitesse de rattrapage en rotation
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Hold")
    float RotationFollowSpeed = 8.f;
 
    // Demi-côté du cube, en cm (50 = cube de 1 m)
    UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Hold")
    float CubeHalfSize = 10.f;
 
private:
    UPROPERTY(VisibleAnywhere)
    UProceduralMeshComponent* CubeMesh;
 
    // Fabrique le cube en C++ (même brique AppendBox qu'en Semaine 13)
    void BuildCube();
    static void AppendBox(const FVector& Center, const FVector& HalfExtent,
        TArray<FVector>& Verts, TArray<int32>& Tris,
        TArray<FVector>& Normals, TArray<FVector2D>& UVs);
};

Fichier 2 : HeldCube.cpp

#include "Public/HeldCube.h"
#include "Kismet/GameplayStatics.h"   // pour GetPlayerCameraManager
#include "Camera/PlayerCameraManager.h"
 
AHeldCube::AHeldCube()
{
    // On bouge le cube à chaque frame : Tick activé.
    PrimaryActorTick.bCanEverTick = true;
 
    // Même squelette qu'en Semaine 13 : un mesh racine, mais généré en C++.
    CubeMesh = CreateDefaultSubobject<UProceduralMeshComponent>(TEXT("CubeMesh"));
    RootComponent = CubeMesh;
 
    // Un objet "tenu magiquement" ne doit pas se cogner au joueur :
    // on coupe la collision pour cette première version.
    CubeMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);
}
 
// Identique à la Semaine 13 : ajoute les 6 faces d'une boîte aux listes du mesh.
void AHeldCube::AppendBox(const FVector& C, const FVector& H,
    TArray<FVector>& Verts, TArray<int32>& Tris,
    TArray<FVector>& Normals, TArray<FVector2D>& UVs)
{
    auto AddQuad = [&](const FVector& P0, const FVector& P1,
                       const FVector& P2, const FVector& P3, const FVector& N)
    {
        const int32 Base = Verts.Num();
        Verts.Add(P0); Verts.Add(P1); Verts.Add(P2); Verts.Add(P3);
        for (int32 k = 0; k < 4; ++k) { Normals.Add(N); }
        UVs.Add(FVector2D(0, 0)); UVs.Add(FVector2D(1, 0));
        UVs.Add(FVector2D(1, 1)); UVs.Add(FVector2D(0, 1));
        Tris.Add(Base); Tris.Add(Base + 2); Tris.Add(Base + 1);
        Tris.Add(Base); Tris.Add(Base + 3); Tris.Add(Base + 2);
    };
 
    const float x = H.X, y = H.Y, z = H.Z;
    AddQuad(C+FVector(-x,-y, z), C+FVector( x,-y, z), C+FVector( x, y, z), C+FVector(-x, y, z), FVector(0,0, 1));
    AddQuad(C+FVector(-x,-y,-z), C+FVector(-x, y,-z), C+FVector( x, y,-z), C+FVector( x,-y,-z), FVector(0,0,-1));
    AddQuad(C+FVector( x,-y,-z), C+FVector( x, y,-z), C+FVector( x, y, z), C+FVector( x,-y, z), FVector( 1,0,0));
    AddQuad(C+FVector(-x,-y,-z), C+FVector(-x,-y, z), C+FVector(-x, y, z), C+FVector(-x, y,-z), FVector(-1,0,0));
    AddQuad(C+FVector(-x, y,-z), C+FVector(-x, y, z), C+FVector( x, y, z), C+FVector( x, y,-z), FVector(0, 1,0));
    AddQuad(C+FVector(-x,-y,-z), C+FVector( x,-y,-z), C+FVector( x,-y, z), C+FVector(-x,-y, z), FVector(0,-1,0));
}
 
void AHeldCube::BuildCube()
{
    TArray<FVector>  Verts;   TArray<int32>     Tris;
    TArray<FVector>  Normals; TArray<FVector2D> UVs;
 
    // Un seul cube, centré sur l'Actor.
    AppendBox(FVector::ZeroVector, FVector(CubeHalfSize), Verts, Tris, Normals, UVs);
 
    CubeMesh->ClearAllMeshSections();
    CubeMesh->CreateMeshSection_LinearColor(
        0, Verts, Tris, Normals, UVs,
        TArray<FLinearColor>(), TArray<FProcMeshTangent>(), false);
}
 
void AHeldCube::OnConstruction(const FTransform& Transform)
{
    Super::OnConstruction(Transform);
    BuildCube();   // cube généré à la (re)construction : visible dès l'éditeur
}
 
void AHeldCube::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
 
    // 1. Récupérer la caméra du joueur 0. N'existe qu'au runtime (en Play),
    //    pas dans l'éditeur : on sort proprement si elle est absente.
    APlayerCameraManager* Cam =
        UGameplayStatics::GetPlayerCameraManager(this, 0);
    if (!Cam)
    {
        return;
    }
 
    const FVector  CamLoc = Cam->GetCameraLocation();
    const FRotator CamRot = Cam->GetCameraRotation();
 
    // 2. Le point cible : 1 m devant la caméra (le long du forward),
    //    décalé un peu vers le bas pour tomber à hauteur de torse.
    const FVector Forward  = CamRot.Vector();        // vecteur unitaire "devant"
    const FVector TargetLoc = CamLoc + Forward * HoldDistance + FVector::UpVector * HoldHeightOffset;
 
    // 3. Inertie : au lieu de coller le cube sur la cible, on l'y amène
    //    progressivement. VInterpTo rapproche Current de Target d'une
    //    fraction proportionnelle à DeltaTime * Speed (ease-out).
    const FVector NewLoc =
        FMath::VInterpTo(GetActorLocation(), TargetLoc, DeltaTime, FollowSpeed);
    SetActorLocation(NewLoc);
 
    // 4. La rotation suit la caméra, elle aussi avec un léger retard.
    const FRotator NewRot =
        FMath::RInterpTo(GetActorRotation(), CamRot, DeltaTime, RotationFollowSpeed);
    SetActorRotation(NewRot);
}

Comprendre le code Tout ce qui touche au mesh (constructeur partiel, AppendBox, BuildCube, OnConstruction) reprend exactement la brique de la Semaine 13. Trois choses sont propres à W14 : la désactivation de la collision dans le constructeur, et surtout le Tick qui lit la caméra du joueur pour calculer un point cible devant lui, puis y amène le cube avec de l'inertie.

Le constructeur : Tick, mesh et collision

PrimaryActorTick.bCanEverTick = true;
 
CubeMesh = CreateDefaultSubobject<UProceduralMeshComponent>(TEXT("CubeMesh"));
RootComponent = CubeMesh;
 
CubeMesh->SetCollisionEnabled(ECollisionEnabled::NoCollision);

Les trois premières lignes sont strictement identiques à AMyTestActor de la Semaine 13 : activer Tick, créer le composant procédural, en faire le RootComponent pour que les rotations/déplacements de l’acteur portent directement sur le mesh.

SetCollisionEnabled(ECollisionEnabled::NoCollision) est nouveau : on coupe complètement la collision sur le cube. Comme l’objet est “tenu magiquement” devant le joueur, on ne veut pas qu’il pousse le joueur ni qu’il rebondisse sur les murs en arrivant. Le cube traverse tout (visuel pur). Si on activait la collision, il faudrait gérer les conflits avec la CapsuleComponent du Character, et l’intégration ressort de la Période 2 deviendrait instable.

Tick, étape 1 : récupérer la caméra du joueur

APlayerCameraManager* Cam = UGameplayStatics::GetPlayerCameraManager(this, 0);
if (!Cam) { return; }

GetPlayerCameraManager(this, 0) renvoie le gestionnaire de caméra du joueur d’indice 0 (le joueur local). Le this sert à donner accès au World dans lequel l’acteur vit. Le 0 est l’index du joueur (en split-screen, il pourrait y en avoir plusieurs).

L’avantage de passer par le PlayerCameraManager plutôt que par le Character directement : notre cube reste agnostique du personnage. Peu importe le type de pawn utilisé par le projet (FirstPersonCharacter, ThirdPersonCharacter, custom), tant qu’il y a une caméra, le cube fonctionne.

Le piège : ce gestionnaire n’existe qu’en Play. Dans l’éditeur (hors Play), il vaut nullptr parce qu’aucun joueur n’est encore instancié. Le if (!Cam) return; évite un crash et laisse le cube immobile à l’origine tant qu’on n’a pas appuyé sur Play.

Tick, étape 2 : calculer le point cible (1 m devant, à hauteur de torse)

const FVector  CamLoc = Cam->GetCameraLocation();
const FRotator CamRot = Cam->GetCameraRotation();
 
const FVector Forward  = CamRot.Vector();
const FVector TargetLoc = CamLoc
    + Forward * HoldDistance
    + FVector::UpVector * HoldHeightOffset;

GetCameraLocation() et GetCameraRotation() donnent exactement la position et l’orientation de la caméra à cette frame, c’est-à-dire ce que voit le joueur.

CamRot.Vector() convertit un FRotator (pitch/yaw/roll en degrés) en un vecteur unitaire pointant “devant” (la direction du regard). C’est l’axe X du repère local de la caméra exprimé en coordonnées monde : si le yaw vaut 0, le vecteur retourne (1, 0, 0).

CamLoc + Forward * HoldDistance : on part de la caméra, on avance de HoldDistance cm dans la direction du regard. Avec HoldDistance = 100, ça fait 1 m devant.

+ FVector::UpVector * HoldHeightOffset : on descend ensuite le point de HoldHeightOffset cm (la valeur par défaut est négative). Comme FVector::UpVector vaut (0, 0, 1) et que +Z pointe vers le haut dans Unreal, multiplier par -20 donne un décalage vers le bas, à hauteur de torse plutôt que de yeux.

Rappel des axes UE (gaucher) : X = avant, Y = droite, Z = haut.

Tick, étape 3 : l’inertie en position avec VInterpTo

const FVector NewLoc =
    FMath::VInterpTo(GetActorLocation(), TargetLoc, DeltaTime, FollowSpeed);
SetActorLocation(NewLoc);

VInterpTo(Current, Target, DeltaTime, Speed) est la brique d’interpolation amortie d’Unreal. À chaque frame, elle calcule une nouvelle position entre Current et Target, avec un pas proportionnel à DeltaTime * Speed.

Comportement : quand le cube est loin de la cible, le pas est grand (rattrapage rapide) ; quand il s’en approche, le pas devient petit (ralentissement). C’est un ease-out : le cube n’atteint jamais la cible instantanément mais s’en approche de plus en plus lentement.

Avec FollowSpeed = 6, le cube traîne d’environ 1/6 de seconde derrière la cible : assez pour qu’on perçoive le retard, assez court pour qu’il ne décroche pas visuellement. Plus FollowSpeed est grand, plus l’inertie est faible (le cube colle).

SetActorLocation(NewLoc) applique la nouvelle position au cube via son RootComponent (donc directement sur CubeMesh).

Tick, étape 4 : l’inertie en rotation avec RInterpTo

const FRotator NewRot =
    FMath::RInterpTo(GetActorRotation(), CamRot, DeltaTime, RotationFollowSpeed);
SetActorRotation(NewRot);

Exactement la même chose, mais sur un FRotator. Le cube oriente progressivement ses faces dans le même sens que la caméra. Sans cette ligne, le cube garderait toujours la même orientation monde, ce qui briserait l’illusion : il faut qu’il “regarde” dans la même direction que vous pour vendre le sentiment qu’il est tenu devant vous.

RotationFollowSpeed = 8 (un peu plus rapide que FollowSpeed = 6) parce qu’un retard en rotation se voit plus qu’un retard en position : le cube semblerait “vissé au monde” si la rotation traînait trop.

Pourquoi DeltaTime est déjà dans VInterpTo

Contrairement au Tick de la Semaine 13 où on multipliait explicitement par DeltaTime, ici on passe DeltaTime à VInterpTo / RInterpTo. La multiplication se fait à l’intérieur. C’est ce qui garde l’inertie indépendante du framerate : à 30 fps comme à 120 fps, le cube met le même temps physique à rattraper sa cible (et pas un temps proportionnel au nombre de frames).

Test dans l'éditeur

  1. Compiler (bouton Compile d’Unreal, ou Ctrl+F9 dans Rider).
  2. Content Browser → C++ Classes → VotreProjet, glisser HeldCube dans le niveau, n’importe où.
  3. Lancer Play. Un cube apparaît à ~1 m devant vous, légèrement sous la ligne des yeux.
  4. Déplacez-vous, tournez la caméra : le cube vous suit, mais traîne un peu avant de se recaler. C’est l’inertie.
  5. Dans le Details panel de l’Actor, jouez avec Follow Speed : 2 donne un cube très mou (gros retard), 20 un cube presque collé (peu d’inertie).

Lag n'est pas wobble Augmentez FollowSpeed, baissez-le : le cube traîne plus ou moins, mais il ne dépasse jamais sa cible et ne rebondit pas. VInterpTo est un rattrapage amorti qui s'arrête net sur la cible, sans oscillation. Or le ressenti "gravity gun" a un vrai ballottement : l'objet dépasse, revient, oscille une ou deux fois. Ce comportement-là demande de la vraie physique : un ressort. C'est tout l'objet de la Période 2.

Vrai wobble (ressort-amortisseur) + bob vertical

Le modèle : masse-ressort-amortisseur https://fr.wikipedia.org/wiki/Oscillateur_harmonique

On imagine le cube relié au point cible par un ressort, et plongé dans un fluide qui freine son mouvement (amortisseur). Deux forces s’appliquent à chaque instant :

  • Le ressort tire le cube vers la cible, avec une force proportionnelle à l’écart :
    F_ressort = Raideur * (Cible - Position). Plus le cube est loin, plus ça tire fort.
  • L’amortisseur freine le cube, avec une force opposée à sa vitesse :
    F_amort = -Amortissement * Vitesse. C’est ce qui finit par calmer l’oscillation.

En prenant une masse de 1, l’accélération est simplement la somme des deux :

Acceleration = Raideur * (Cible - Position) - Amortissement * Vitesse

Puis on intègre dans le temps (méthode d’Euler, la plus simple) :

Vitesse  += Acceleration * DeltaTime
Position += Vitesse      * DeltaTime

C’est un oscillateur harmonique amorti, exactement la physique d’une masse au bout d’un ressort. Le comportement dépend du rapport entre Raideur et Amortissement :

  • Beaucoup d’amortissement (par rapport à la raideur) : le cube rejoint la cible sans la dépasser. C’est le régime “sur-amorti”, proche de VInterpTo. Pas de wobble.
  • Peu d’amortissement : le cube dépasse la cible, revient, dépasse de l’autre côté, et oscille en s’atténuant. Régime “sous-amorti” : c’est le wobble.

Régler Raideur et Amortissement, c’est donc choisir à quel point le cube est nerveux et combien de fois il rebondit. C’est l’outil de base de tout mouvement secondaire.

Le raccourci de production : Unreal fournit déjà ce calcul tout fait, via UKismetMathLibrary::VectorSpringInterp (avec un état FVectorSpringState). On l’écrit ici à la main parce que le but est de comprendre la physique sous le capot. Une fois que vous l’avez vue, utiliser la version moteur en production est trivial (c’est le Défi 3).

HeldCube.h : ajouts On remplace l'inertie simple par le ressort. On garde HoldDistance, HoldHeightOffset, RotationFollowSpeed, et on ajoute :

// Raideur du ressort : plus grand = rattrape plus vite et plus fort
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spring")
float Stiffness = 200.f;
 
// Amortissement : plus grand = moins de rebond. Trop bas = ça oscille longtemps.
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Spring")
float Damping = 12.f;
 
private:
    // Vitesse courante du cube, intégrée d'une frame à l'autre.
    FVector CubeVelocity = FVector::ZeroVector;

CubeVelocity est une variable d’état : contrairement à VInterpTo qui ne regarde que la position, le ressort a besoin de mémoriser la vitesse du cube entre les frames. Pas besoin de UPROPERTY (usage interne, pas un pointeur UObject).

HeldCube.cpp : le Tick version ressort

void AHeldCube::Tick(float DeltaTime)
{
    Super::Tick(DeltaTime);
 
    APlayerCameraManager* Cam =
        UGameplayStatics::GetPlayerCameraManager(this, 0);
    if (!Cam)
    {
        return;
    }
 
    const FRotator CamRot = Cam->GetCameraRotation();
 
    // 1. Point cible : 1 m devant la caméra, à hauteur de torse (inchangé)
    const FVector TargetLoc = Cam->GetCameraLocation()
        + CamRot.Vector() * HoldDistance
        + FVector::UpVector * HoldHeightOffset;
 
    // 2. Ressort-amortisseur (masse = 1)
    const FVector Current = GetActorLocation();
    const FVector Displacement = TargetLoc - Current;          // écart au repos
    const FVector Accel = Displacement * Stiffness
                        - CubeVelocity * Damping;
 
    // 3. Intégration d'Euler : vitesse d'abord, puis position
    CubeVelocity += Accel * DeltaTime;
    SetActorLocation(Current + CubeVelocity * DeltaTime);
 
    // 4. La rotation suit toujours avec un simple lag (pas de ressort ici)
    SetActorRotation(
        FMath::RInterpTo(GetActorRotation(), CamRot, DeltaTime, RotationFollowSpeed));
}

Comprendre ce code Le Tick de Période 2 remplace VInterpTo par un ressort manuel : à chaque frame, on calcule une accélération à partir de l'écart cube/cible, on l'intègre en vitesse, puis en position. Le bob vertical, ajouté juste après, module la cible elle-même selon la vitesse du regard, et c'est le ressort qui se charge de lisser le tout. La caméra et le TargetLoc se calculent exactement comme en Période 1.

Le ressort : accélération depuis l’écart

const FVector Current = GetActorLocation();
const FVector Displacement = TargetLoc - Current;
const FVector Accel = Displacement * Stiffness
                    - CubeVelocity * Damping;

C’est la traduction directe de la formule physique a = k·(cible - pos) - c·v (vue dans la section théorique) en code.

  • Displacement = TargetLoc - Current : le vecteur d’écart entre la cible et le cube, en cm. Si le cube est à la cible, Displacement vaut zéro et la force de ressort s’annule.
  • Displacement * Stiffness : la force de rappel du ressort (loi de Hooke linéaire). Plus l’écart est grand, plus la force est grande. Avec Stiffness = 200, un écart de 1 m (100 cm) génère une accélération de 100 * 200 = 20 000 cm/s².
  • CubeVelocity * Damping : la force de freinage de l’amortisseur, proportionnelle à la vitesse, opposée à elle (d’où le moins). Sans ce terme, le cube oscillerait éternellement autour de la cible (mouvement perpétuel d’un ressort idéal).
  • La somme des deux donne l’accélération totale à appliquer cette frame. On suppose une masse de 1, donc F = m·a se simplifie en a = F.

L’intégration d’Euler : vitesse, puis position

CubeVelocity += Accel * DeltaTime;
SetActorLocation(Current + CubeVelocity * DeltaTime);

C’est la méthode d’intégration numérique la plus simple. On a une accélération, on veut une position : il faut passer par la vitesse.

  • CubeVelocity += Accel * DeltaTime : on met à jour la vitesse. DeltaTime est le temps écoulé depuis la frame précédente (en secondes), donc Accel * DeltaTime est le changement de vitesse sur cet intervalle.
  • Current + CubeVelocity * DeltaTime : on met à jour la position avec la nouvelle vitesse. Même principe : vitesse * temps = distance parcourue.

CubeVelocity est mémorisée comme membre de classe (déclarée dans le .h), donc elle persiste d’une frame à l’autre. C’est ce qui fait toute la différence avec VInterpTo : le cube a une mémoire de mouvement. Il peut donc dépasser la cible (parce qu’il arrive lancé) et osciller (parce que la force le ramène, mais qu’il repart de l’autre côté).

On met à jour la vitesse en premier, puis on l’utilise pour la position. C’est la convention standard, plus stable numériquement que l’ordre inverse.

La rotation : on garde RInterpTo

SetActorRotation(
    FMath::RInterpTo(GetActorRotation(), CamRot, DeltaTime, RotationFollowSpeed));

Volontairement inchangée par rapport à la Période 1. Le ressort apporte son ballottement sur la position (c’est là que le mouvement secondaire est le plus visible) ; la rotation reste un simple lag. Mettre un ressort sur la rotation aussi est un excellent défi, mais ça multiplie les paramètres à régler et complique le débogage.

Le bob, étape 1 : mesurer la vitesse du point cible

const float TargetSpeed =
    (TargetLoc - PrevTargetLoc).Size() / FMath::Max(DeltaTime, KINDA_SMALL_NUMBER);
PrevTargetLoc = TargetLoc;

On veut un bob qui ne s’active que quand le joueur bouge ou tourne. On mesure donc à quelle vitesse la cible (TargetLoc) se déplace d’une frame à l’autre.

  • (TargetLoc - PrevTargetLoc).Size() : la distance parcourue par la cible depuis la frame précédente, en cm. Size() calcule la norme du vecteur (sa longueur).
  • / DeltaTime : on divise par le temps écoulé pour obtenir une vitesse en cm/s, indépendante du framerate.
  • FMath::Max(DeltaTime, KINDA_SMALL_NUMBER) : protection contre la division par zéro. KINDA_SMALL_NUMBER est une constante Unreal valant environ 1e-4. Si DeltaTime est nul ou minuscule (cas pathologique, comme un breakpoint qui bloque la frame), on évite le NaN ou l’infini qui propagerait du grain dans tout le code derrière.
  • PrevTargetLoc = TargetLoc : on mémorise la cible courante pour la prochaine frame. PrevTargetLoc est un membre de classe.

Le bob, étape 2 : lisser la vitesse pour éviter les sauts d’amplitude

SmoothedSpeed = FMath::FInterpTo(SmoothedSpeed, TargetSpeed, DeltaTime, 5.f);

FInterpTo est la version scalaire de VInterpTo (F pour float). Même principe : on rapproche SmoothedSpeed de TargetSpeed d’un pas proportionnel à DeltaTime * 5.f.

Pourquoi lisser ? TargetSpeed brut est très bruité d’une frame à l’autre : à 60 fps, un petit mouvement de souris fait bondir la valeur. Sans lissage, l’amplitude du bob sauterait visiblement de 0 à plein régime puis inversement, ce qui se verrait à l’écran. Le lissage donne une rampe douce : quand on commence à tourner, l’amplitude monte progressivement ; quand on s’arrête, elle redescend progressivement.

Le bob, étape 3 : oscillation sin modulée par la vitesse

BobTime += DeltaTime * BobFrequency;
const float SpeedFactor = FMath::Clamp(SmoothedSpeed / BobSpeedRef, 0.f, 1.f);
const float Bob = FMath::Sin(BobTime) * BobAmplitude * SpeedFactor;
  • BobTime += DeltaTime * BobFrequency : on fait avancer la phase de l’oscillation. Avec BobFrequency = 9.f, la phase avance de 9 radians par seconde (environ 1,4 cycle complet par seconde). Comme BobTime est un membre de classe, elle s’accumule entre les frames : la phase n’est jamais remise à zéro.
  • SpeedFactor = Clamp(SmoothedSpeed / BobSpeedRef, 0.f, 1.f) : on normalise la vitesse lissée entre 0 et 1, avec saturation. Avec BobSpeedRef = 300.f, à 300 cm/s SpeedFactor vaut 1 (amplitude max) ; à 150 cm/s il vaut 0,5 ; à l’arrêt il vaut 0.
  • Sin(BobTime) * BobAmplitude * SpeedFactor : l’oscillation finale. Sin(BobTime) oscille entre -1 et 1, BobAmplitude (12 cm) la met à l’échelle, et SpeedFactor module l’amplitude par la vitesse : à l’arrêt le bob est nul, à pleine vitesse il vaut ±12 cm.

C’est exactement le principe du head bob ou du weapon sway des FPS : un sin dont l’amplitude suit l’activité du joueur.

Le bob, étape 4 : décaler la cible et laisser le ressort lisser

const FVector BobbedTarget = TargetLoc + FVector::UpVector * Bob;
// Puis le ressort travaille sur BobbedTarget au lieu de TargetLoc
const FVector Displacement = BobbedTarget - Current;

L’astuce élégante : au lieu d’ajouter le bob à la position finale du cube, on l’injecte dans la cible que le ressort doit poursuivre. Trois conséquences :

  • Le ressort vise une cible qui ondule verticalement, et le cube suit avec son inertie : si le bob change vite, le ressort lisse naturellement les hautes fréquences.
  • Pas de double couche d’animation à débugger : tout passe par le même mécanisme (ressort sur une cible).
  • À l’arrêt, SpeedFactor = 0 donc Bob = 0, et BobbedTarget égale TargetLoc : on retrouve le comportement de Période 2 sans bob.

L’envoi au cube se fait ensuite avec le même code de ressort qu’avant (Accel, CubeVelocity += ..., SetActorLocation(...)), mais avec BobbedTarget à la place de TargetLoc.

Lancez Play et tournez vite la caméra : le cube part en retard, dépasse, revient, oscille une ou deux fois avant de se stabiliser. C’est le wobble. Baissez Damping à 4 : il oscille beaucoup plus longtemps (trop “gélatineux”). Montez-le à 30 : presque pas de rebond (retour à un comportement type VInterpTo). Montez Stiffness : le cube devient plus nerveux et colle mieux.

Le bob vertical : tourner fait onduler de haut en bas Le ressort produit déjà un ballottement, mais surtout dans la direction du mouvement. Quand vous tournez à gauche/droite (yaw), la cible se décale horizontalement, donc le wobble est surtout horizontal. Pour obtenir le "haut/bas quand on tourne" demandé, on ajoute un bob vertical piloté par la vitesse : plus le point cible bouge vite (parce que vous tournez ou avancez vite), plus le cube ondule verticalement. C'est exactement le principe du head bob ou du weapon sway des FPS.

On mesure la vitesse du point cible (combien il se déplace par seconde), on la lisse pour éviter les à-coups, et on s’en sert pour moduler l’amplitude d’une oscillation sin (le sin vu en Semaine 13, Défi 2).

HeldCube.h, ajouts :

UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Bob")
float BobAmplitude = 12.f;     // hauteur max du bob, en cm
 
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Bob")
float BobFrequency = 9.f;      // vitesse de l'oscillation
 
UPROPERTY(EditAnywhere, BlueprintReadWrite, Category = "Bob")
float BobSpeedRef = 300.f;     // vitesse cible (cm/s) pour un bob à pleine amplitude
 
private:
    FVector CubeVelocity = FVector::ZeroVector;
    FVector PrevTargetLoc = FVector::ZeroVector;  // cible de la frame précédente
    float   SmoothedSpeed = 0.f;                  // vitesse cible lissée
    float   BobTime = 0.f;                        // phase de l'oscillation

Dans le Tick, juste après avoir calculé TargetLoc et avant le ressort, on calcule le bob et on l’ajoute à la cible (ainsi le ressort lisse aussi le bob) :

    // Vitesse du point cible : grand quand on tourne/avance vite
    const float TargetSpeed =
        (TargetLoc - PrevTargetLoc).Size() / FMath::Max(DeltaTime, KINDA_SMALL_NUMBER);
    PrevTargetLoc = TargetLoc;
 
    // On lisse pour éviter les sauts d'amplitude
    SmoothedSpeed = FMath::FInterpTo(SmoothedSpeed, TargetSpeed, DeltaTime, 5.f);
 
    // Amplitude proportionnelle à la vitesse (0 à l'arrêt, max au-delà de BobSpeedRef)
    BobTime += DeltaTime * BobFrequency;
    const float SpeedFactor = FMath::Clamp(SmoothedSpeed / BobSpeedRef, 0.f, 1.f);
    const float Bob = FMath::Sin(BobTime) * BobAmplitude * SpeedFactor;
 
    // On décale la cible verticalement du bob
    const FVector BobbedTarget = TargetLoc + FVector::UpVector * Bob;

Puis on fait travailler le ressort sur BobbedTarget au lieu de TargetLoc :

    const FVector Current = GetActorLocation();
    const FVector Displacement = BobbedTarget - Current; // Ici, remplacez TargetLoc par BobbedTarget
    const FVector Accel = Displacement * Stiffness - CubeVelocity * Damping;
    CubeVelocity += Accel * DeltaTime;
    SetActorLocation(Current + CubeVelocity * DeltaTime);

Maintenant, à l’arrêt le cube est calme (amplitude 0). Dès que vous tournez ou marchez, il se met à onduler de haut en bas, d’autant plus fort que vous bougez vite, et le ressort adoucit le tout. Combiné au dépassement du ressort, on obtient le ressenti complet : inertie, ballottement, et bob vertical à la rotation.

Stabilité de l’intégration d’Euler : si vous montez Stiffness très haut sans monter Damping, le cube peut “exploser” (partir à l’infini). C’est une limite connue de l’intégration d’Euler explicite : quand Stiffness * DeltaTime devient trop grand, chaque pas dépasse la cible de plus en plus, et ça diverge. En pratique, gardez des valeurs raisonnables (Stiffness quelques centaines, Damping autour de 10-20), ou utilisez la version moteur (VectorSpringInterp) qui gère ça proprement.

Résultat attendu Un cube unique flotte à 1 m devant vous, à hauteur de torse. Quand vous restez immobile, il est calme. Quand vous marchez, il traîne légèrement (inertie). Quand vous tournez, il part en retard, dépasse, oscille, et ondule de haut en bas le temps que vous tourniez. Vous avez simulé du mouvement secondaire entièrement en C++, à partir d'une force de ressort et d'une intégration manuelle, sans aucun système d'animation.

Récapitulatif des concepts

ConceptRôle iciD’où ça vient
GetPlayerCameraManager, GetCameraLocation/RotationLire le joueur sans dépendre du personnageNouveau
Forward vector (FRotator::Vector()) + multiplicationPoint devant la caméraMaths S02-03
FMath::VInterpTo / RInterpToInertie simple (lag, ease-out)Nouveau
Ressort-amortisseur a = k·(cible-pos) - c·vWobble physique (dépassement, oscillation)Maths S02-03
Intégration d’Euler (v += a·dt, p += v·dt)Simuler le mouvement frame par frameDeltaTime S13
FMath::Sin piloté par la vitesseBob vertical (secondary motion)Sin du Défi 2, S13
UProceduralMeshComponent + AppendBox, Tick, UPROPERTY, * DeltaTimeSquelette de l’Actor + cube généré en C++S13
Variable d’état membre (CubeVelocity)Mémoriser la vitesse entre framesNouveau

Erreurs courantes

  • Le cube ne bouge pas du tout : vous regardez la scène hors Play. GetPlayerCameraManager vaut nullptr dans l’éditeur ; il faut lancer Play pour qu’il existe.
  • Crash au lancement : Cam non vérifié avant usage. Toujours if (!Cam) return;.
  • Le cube est collé à la caméra, sans inertie : vous faites SetActorLocation(TargetLoc) directement au lieu de passer par VInterpTo ou le ressort.
  • Le cube part à l’infini / disparaît : Stiffness trop élevé par rapport à Damping, l’intégration d’Euler diverge. Baissez la raideur ou montez l’amortissement.
  • Aucun rebond, même avec le ressort : Damping trop élevé (régime sur-amorti). Baissez-le pour voir l’oscillation.
  • Le cube est invisible ou creux (on voit à travers) : géométrie non générée (vérifiez l’appel à BuildCube), ou winding des faces inversé (voir la note AppendBox de la Semaine 13).
  • Le mouvement dépend du framerate : un DeltaTime a été oublié dans l’intégration.
  • Le bob saute brutalement : SmoothedSpeed non lissé (on utilise TargetSpeed brut), ou DeltaTime non protégé par FMath::Max(..., KINDA_SMALL_NUMBER).

Travail personnel

Lecture

Défi 1 - Pencher dans les virages (banking) Quand on tourne vite, un objet tenu a tendance à pencher dans le virage. Ajoutez un Roll au cube proportionnel à la vitesse de rotation horizontale de la caméra (la différence de Yaw entre deux frames). C'est du mouvement secondaire de rotation, complémentaire du bob de position.

Défi 2 - Colorer selon la vitesse (Dynamic Material Instance) Réutilisez la technique du Défi 3 de la Semaine 13 : créez un Material avec un paramètre BaseColor, un Dynamic Material Instance dans BeginPlay, et faites varier la couleur du cube selon SmoothedSpeed (calme = bleu, rapide = orange) avec un FMath::Lerp.

Défi 3 (Avancé) - Passer au ressort du moteur Remplacez votre intégration d'Euler manuelle par UKismetMathLibrary::VectorSpringInterp, en gardant un membre FVectorSpringState. Comparez le ressenti et la stabilité aux valeurs extrêmes (Stiffness très haut) : la version moteur ne diverge pas. C'est le pont entre "je comprends la physique" et "j'utilise l'outil de production".

Solutions

Essayez d’abord par vous-même avant de regarder.

Solution Défi 1 - Banking

// .h
UPROPERTY(EditAnywhere, Category = "Bank") float BankStrength = 0.4f;   // degrés par (deg/s) de yaw
UPROPERTY(EditAnywhere, Category = "Bank") float BankMax = 25.f;        // roll max
private:
    float PrevYaw = 0.f;
    float SmoothedRoll = 0.f;
// dans Tick, après avoir CamRot
const float YawRate = FMath::FindDeltaAngleDegrees(PrevYaw, CamRot.Yaw) / FMath::Max(DeltaTime, KINDA_SMALL_NUMBER);
PrevYaw = CamRot.Yaw;
 
const float TargetRoll = FMath::Clamp(-YawRate * BankStrength, -BankMax, BankMax);
SmoothedRoll = FMath::FInterpTo(SmoothedRoll, TargetRoll, DeltaTime, 6.f);
 
FRotator DesiredRot = CamRot;
DesiredRot.Roll += SmoothedRoll;
SetActorRotation(FMath::RInterpTo(GetActorRotation(), DesiredRot, DeltaTime, RotationFollowSpeed));

FindDeltaAngleDegrees gère proprement le passage de 359° à 0°. Le signe - fait pencher le cube dans le virage.

Solution Défi 2 - Couleur selon la vitesse

// .h
UPROPERTY(EditAnywhere, Category = "Color") FLinearColor SlowColor = FLinearColor(0.05f, 0.2f, 0.8f);
UPROPERTY(EditAnywhere, Category = "Color") FLinearColor FastColor = FLinearColor(0.9f, 0.3f, 0.05f);
private:
    UPROPERTY() UMaterialInstanceDynamic* DynMat = nullptr;
// BeginPlay (à déclarer/override comme en S13)
void AHeldCube::BeginPlay()
{
    Super::BeginPlay();
    if (UMaterialInterface* Base = CubeMesh->GetMaterial(0))
    {
        DynMat = UMaterialInstanceDynamic::Create(Base, this);
        CubeMesh->SetMaterial(0, DynMat);
    }
}
 
// fin de Tick
if (DynMat)
{
    const float T = FMath::Clamp(SmoothedSpeed / BobSpeedRef, 0.f, 1.f);
    DynMat->SetVectorParameterValue(TEXT("BaseColor"), FMath::Lerp(SlowColor, FastColor, T));
}

Assignez d’abord votre Material au slot Element 0 du Procedural Mesh (Details panel ou SetMaterial(0, ...)), et il doit exposer un paramètre vectoriel BaseColor, sinon l’appel est ignoré silencieusement (même mécanisme qu’en Semaine 13).

Solution Défi 3 - VectorSpringInterp

#include "Kismet/KismetMathLibrary.h"
 
// .h
private:
    FVectorSpringState SpringState;
// dans Tick, en remplacement de l'intégration manuelle :
const FVector NewLoc = UKismetMathLibrary::VectorSpringInterp(
    GetActorLocation(),   // Current
    BobbedTarget,         // Target
    SpringState,          // état du ressort (vitesse interne mémorisée)
    Stiffness,            // Stiffness
    Damping,              // CriticalDampingFactor (1 = critique, <1 = wobble)
    DeltaTime,
    1.f,                  // Mass
    0.f);                 // TargetVelocityAmount
 
SetActorLocation(NewLoc);

La version moteur encapsule l’état (SpringState) et reste stable même à raideur élevée. Notez que CriticalDampingFactor y est normalisé (1 = amortissement critique, sans dépassement ; en dessous de 1 = wobble), une paramétrisation plus pratique que le Damping brut de notre version manuelle.