Problem with bullet triangle mesh shape and Bullet Character Controller

Hello, I have a problem with bullet physics. I create collisions on my terrain using a script that retrieves the vertices of triangles to create collision faces with Triangle Mesh Shape.
The problem is that my player is subject to gravity, so if he doesn’t have enough speed and walks on the edge of a face on the ground, he gets stuck.

Are you using physics to control your player-character?

If so, that can be finicky to get working reliably, I think.

Generally speaking, I think that it’s often advised that characters instead be controlled via “kinematic” means–essentially, not handled via physics, but instead via game-logic in response to collisions. This allows one to design a character that responds in a simpler, more-intuitive, and more-controlled manner.

There is my player class

class Player:
    #  Constantes de configuration 
    DEFAULT_RUN_SPEED = 3.0
    DEFAULT_WALK_SPEED = 1.5
    CROUCH_RUN_SPEED = 0.5
    CROUCH_WALK_SPEED = 0.1
    STAMINA_LOW_THRESHOLD = 20
    STAMINA_CRITICAL_THRESHOLD = 10
    TELEPORT_HEIGHT = -50
    TELEPORT_TARGET_Z = 200
    SENSIBILITY = 0.1
    FOV = 120
    JUMP_HEIGHT = 0.5
    JUMP_SPEED = 4.0
    STAMINA_DRAIN_RATE = 2.5
    STAMINA_REGEN_STILL = 7.5
    STAMINA_REGEN_MOVING = 5.0
    CAMERA_SHAKE_INTENSITY_MULTIPLIER = 0.005
    MODEL_HEIGHT_OFFSET = 1.773 / 2
    CROUCH_MODEL_Z = -1.8
    CROUCH_CONTROLLER_Z_OFFSET = -0.4
    STAND_MODEL_Z = -1.773 / 2

    def __init__(self, parentClass=None, model='models/playertest.bam'):
        """
        Initialise le joueur avec ses paramètres et composants.

        Args:
            parentClass: L'instance principale de l'application (ShowBase).
            model: Chemin vers le modèle du joueur.
        """
        #  état 
        self.parentClass = parentClass
        self.ui = GUI()
        self.knockback_vector = Vec3(0, 0, 0)
        self.knockback_time = 0.0


        self.iscrounching = False
        self.dead_state = False
        self.camera_up = False


        #  paramètres mouvement / caméra 
        self.runspeed = self.DEFAULT_RUN_SPEED
        self.walkspeed = self.DEFAULT_WALK_SPEED
        self.stamina = 100
        self.hp = 100
        self.flou = 1
        self.sensibility = max(0.01, self.SENSIBILITY)  # Validation pour éviter 0
        self.mouse_center = (self.parentClass.win.getXSize() // 2, self.parentClass.win.getYSize() // 2)
        self.jumping = False


        #  Bullet world 
        self.bullet_world = self.parentClass.bullet_world
        self.bullet_world.setGravity(Vec3(0, 0, -9.81))


        #  formes (debout / accroupi) 
        self.shape = BulletCapsuleShape(0.4/2, 1.773 - 2*0.4/2, ZUp)   # debout


        #  character controller (une seule création ici) 
        self.controller = BulletCharacterControllerNode(self.shape, 0.4, 'Player')
        self.controller_np = self.parentClass.render.attachNewNode(self.controller)
        self.controller_np.setPos(0, 0, 1)
        self.bullet_world.attachCharacter(self.controller)


        #  modèle visuel (après création du controller) 
        self.character = Character(self.parentClass)
        self.model = self.character.actor
        self.model.reparentTo(self.controller_np)
        self.model.setH(0)
        self.model.setZ(0 - 1.773/2)  # ajuster la hauteur du modèle par rapport au controller


        #  caméra 
        self.head_np = self.model.exposeJoint(None, "modelRoot", "mixamorig:Head")


        # Node intermédiaire pour corriger le roll
        self.camera_roll_correction = self.head_np.attachNewNode("camera_roll_correction")
        self.camera_roll_correction.setHpr(0, 0, 0)  # Roll, pitch, yaw neutres
        self.camera_z = 10
        self.camera_roll_correction.setPos(-10, -15, self.camera_z)  # recul + hauteur caméra


        # Lens
        self.parentClass.camLens.setFov(120)
        self.parentClass.camLens.setNearFar(0.1, 150)


        #  contrôle du cou 
        self.neck_np = self.model.controlJoint(None, "modelRoot", "mixamorig:Neck")
        self.neck_np.setR(0)  # verrouiller le roll sur le cou


        self.turning = False  # état de rotation forcée
        self.moving = False  # état de mouvement forcé


        self.raycast = PlayerRaycast(
            render=self.parentClass.render,
            camera=self.parentClass.camera,
            bullet_world=self.parentClass.bullet_world,
            max_distance=2.5,
        )


        #  input 
        self.controls = {
            "z": "forward",
            "s": "backward",
            "q": "left",
            "d": "right",
            "c": "crouch",
            "shift": "run",
            "space": "jump"
        }
        self.parentClass.accept("e", self.try_interact)
        self.parentClass.accept("e-repeat", self.try_interact)

        self.parentClass.keyMap = {action: False for action in self.controls.values()}
        for key, action in self.controls.items():
            self.parentClass.accept(key, self.setKey, [action, True])
            self.parentClass.accept(f"{key}-up", self.setKey, [action, False])

        #  HUD / lastpos / filters 
        self.lastpos = self.controller_np.getPos()
        self.fov = 120

        #  tasks 
        self.parentClass.taskMgr.add(self.update_player, "update_player")
        self.parentClass.taskMgr.add(self.param_stamina, "param_stamina")
        self.parentClass.taskMgr.add(self.update_camera, "update_camera")
        # à la fin du __init__
        self.parentClass.taskMgr.doMethodLater(0.1, self.init_filters, "init_filters")
        self.parentClass.taskMgr.add(self.raycast.update_debug_smoothly, "update_raycast_debug")

    def try_interact(self):
        """Tente une interaction avec l'environnement via le raycast."""
        if self.raycast.interact():
            print("[Player: Raycast] Interaction réussie")
        else:
            print("[Player: Raycast] Rien à interagir")

    def setKey(self, key, value):
        """Met à jour l'état d'une touche dans keyMap."""
        self.parentClass.keyMap[key] = value

    def jump(self):
        """Fait sauter le joueur si possible."""
        if not self.jumping and self.controller.isOnGround():
            self.controller.setMaxJumpHeight(self.JUMP_HEIGHT)
            self.controller.setJumpSpeed(self.JUMP_SPEED)
            self.controller.doJump()
            self.jumping = True

    def update_player(self, task):
        dt = globalClock.getDt()
        mw = self.parentClass.mouseWatcherNode

        # Gestion de la mort
        self.handle_death()

        # Téléport si tombé
        self.handle_teleport()

        # Knockback
        if self.handle_knockback(dt):
            return Task.cont

        # Input mouvement et animations
        input_vector = self.handle_movement_input(mw)

        # Crouch
        self.handle_crouch(mw)

        # Jump
        self.handle_jump(mw)

        # Calcul et application du mouvement
        self.apply_movement(input_vector, mw, dt)

        return Task.cont

    def handle_death(self):
        """Gère la logique de mort du joueur."""
        if self.hp <= 0 and not self.dead_state:
            if hasattr(self.parentClass, 'terrain') and hasattr(self.parentClass.terrain, "batterie_3d"):
                musique = self.parentClass.terrain.batterie_3d
                new_time = max(0, musique.getTime() - 0.4)
                self.dead_state = True
                for i in range(20):
                    musique.setTime(new_time)
                    musique.play()
                    sleep(0.3)
                self.parentClass.quit_game()
            else:
                print("[Player] Erreur : musique non disponible, arrêt du jeu.")
                self.parentClass.quit_game()

    def handle_teleport(self):
        """Téléporte le joueur s'il est tombé trop bas."""
        if self.controller_np.getZ() < self.TELEPORT_HEIGHT:
            target_pos = Vec3(self.controller_np.getX(), self.controller_np.getY(), self.TELEPORT_TARGET_Z)
            h, p, r = self.controller_np.getH(), self.controller_np.getP(), self.controller_np.getR()

            try:
                self.bullet_world.removeCharacter(self.controller)
            except Exception as e:
                print(f"[Player] Erreur lors de la suppression du controller : {e}")

            self.controller_np.setPos(target_pos)
            self.controller_np.setHpr(h, p, r)
            self.bullet_world.attachCharacter(self.controller)

            try:
                self.controller.setLinearMovement(Vec3(0, 0, 0), True)
            except Exception as e:
                print(f"[Player] Erreur lors de l'arrêt du mouvement : {e}")

    def handle_knockback(self, dt):
        """Gère le knockback. Retourne True si knockback actif."""
        if self.knockback_time > 0:
            self.controller.setLinearMovement(self.knockback_vector, True)
            self.knockback_time -= dt
            return True
        return False

    def handle_movement_input(self, mw):
        """Gère les inputs de mouvement et met à jour les animations."""
        input_vector = Vec3(0, 0, 0)

        if mw.is_button_down(KeyboardButton.ascii_key("z")):
            input_vector.y += 1
        if mw.is_button_down(KeyboardButton.ascii_key("s")):
            input_vector.y -= 1
        if mw.is_button_down(KeyboardButton.ascii_key("q")):
            input_vector.x -= 1
        if mw.is_button_down(KeyboardButton.ascii_key("d")):
            input_vector.x += 1

        if input_vector.length_squared() > 0:
            if mw.is_button_down(KeyboardButton.lshift()) and self.stamina > 0:
                if self.character.state != 'Run':
                    self.character.request('Run')
            else:
                if self.character.state != 'Walk':
                    self.character.request('Walk')
            self.moving = True
        else:
            if self.character.state != 'Idle':
                self.character.request('Idle')
            self.moving = False

        return input_vector

    def handle_crouch(self, mw):
        """Gère l'accroupissement."""
        if mw.is_button_down(KeyboardButton.ascii_key("c")):
            if not self.iscrounching and not self.jumping:
                self._switch_to_crouch()
        else:
            if self.iscrounching:
                self._switch_to_stand()

    def _switch_to_crouch(self):
        """Passe en mode accroupi en changeant la forme du controller."""
        if not self.iscrounching:
            sz = 0.6
            self.controller.getShape().setScale(Vec3(1, 1, sz))
            self.controller_np.setScale(Vec3(1, 1, sz))
            self.controller_np.setZ(self.controller_np.getZ() - 0.4)
            self.model.setZ(self.CROUCH_MODEL_Z)
            self.runspeed = self.CROUCH_RUN_SPEED
            self.walkspeed = self.CROUCH_WALK_SPEED
            self.iscrounching = True

    def _switch_to_stand(self):
        """Passe en mode debout en changeant la forme du controller."""
        if self.iscrounching:
            sz = 1.0
            self.controller.getShape().setScale(Vec3(1, 1, sz))
            self.controller_np.setScale(Vec3(1, 1, sz))
            self.controller_np.setZ(self.controller_np.getZ() + 0.4)
            self.model.setZ(self.STAND_MODEL_Z)
            self.runspeed = self.DEFAULT_RUN_SPEED
            self.walkspeed = self.DEFAULT_WALK_SPEED
            self.iscrounching = False

    def handle_jump(self, mw):
        """Gère le saut."""
        if mw.is_button_down(KeyboardButton.space()):
            self.jump()
        if self.controller.isOnGround():
            self.jumping = False

    def apply_movement(self, input_vector, mw, dt):
        """Calcule et applique le mouvement."""
        speed = self.runspeed if mw.is_button_down(KeyboardButton.lshift()) else self.walkspeed

        if input_vector.length_squared() > 0:
            input_vector.normalize()
            input_vector *= speed
            input_vector = self.model.getQuat(self.parentClass.render).xform(input_vector)

        try:
            self.controller.setLinearMovement(input_vector, True)
        except Exception:
            self.controller_np.setPos(self.controller_np.getPos() + input_vector * dt)

    def param_stamina(self, task):
        """
        Met à jour la stamina, ajuste les vitesses, applique le shake caméra et met à jour l'UI.
        """
        dt = globalClock.getDt()
        mw = self.parentClass.mouseWatcherNode

        #  Calcul déplacement 
        current_pos = self.controller_np.getPos()
        move_speed = (current_pos - self.lastpos).length() / max(dt, 1e-6)
        self.lastpos = current_pos

        #  RÉDUCTION / regen STAMINA 
        if mw.is_button_down(KeyboardButton.lshift()) and move_speed > 0:
            self.stamina = max(0, self.stamina - self.STAMINA_DRAIN_RATE * dt)
        else:
            regen = self.STAMINA_REGEN_STILL if move_speed == 0 else self.STAMINA_REGEN_MOVING
            self.stamina = min(100, self.stamina + regen * dt)

        #  AJUSTEMENT VITESSE 
        if self.stamina < self.STAMINA_LOW_THRESHOLD:
            self.runspeed = 1
            self.walkspeed = 0.833
        else:
            self.runspeed = self.DEFAULT_RUN_SPEED
            self.walkspeed = self.DEFAULT_WALK_SPEED

        #  SHAKE CAM SI PEU DE STAMINA 
        if self.stamina < self.STAMINA_CRITICAL_THRESHOLD:
            shake_intensity = (self.STAMINA_CRITICAL_THRESHOLD - self.stamina) * self.CAMERA_SHAKE_INTENSITY_MULTIPLIER
            self.parentClass.camera.setX(
                self.parentClass.camera,
                shake_intensity * (-1 if globalClock.getFrameCount() % 2 == 0 else 1),
            )

        #  MISE À JOUR BARRES UI 
        stamina_ratio = self.stamina / 100
        self.ui.stamina_bar['frameSize'] = (0.0, 0.8 * stamina_ratio, 0.05, 0.1)
        self.ui.stamina_bar['text'] = f"{int(self.stamina)}%"

        if hasattr(self, 'hp'):
            hp_ratio = self.hp / 100
            self.ui.hp_bar['frameSize'] = (0.0, 0.8 * hp_ratio, 0.0, 0.05)
            self.ui.hp_bar['text'] = f"{int(self.hp)}%"

        return Task.cont

    def update_camera(self, task):
        dt = globalClock.getDt()

        if self.parentClass.mouseWatcherNode.hasMouse():
            md = self.parentClass.win.getPointer(0)
            x, y = md.getX(), md.getY()
            self.parentClass.win.movePointer(0, *self.mouse_center)

            dx = (self.mouse_center[0] - x) * self.sensibility
            dy = (self.mouse_center[1] - y) * self.sensibility

            #  INPUT SUR LE COU (LOCAL AU CORPS) 
            self.neck_np.setH(self.neck_np.getH() + dx)

            new_pitch = self.neck_np.getP() - dy
            self.neck_np.setP(max(-75, min(60, new_pitch)))

        # ===== DIFF COU / CORPS (LOCAL !) =====
        diff = self.neck_np.getH()

        max_angle = 15
        follow_speed = 30
        max_turn_speed = 360  # deg/s

        if abs(diff) > max_angle:
            # quantité à rattraper
            excess = diff - max_angle if diff > 0 else diff + max_angle

            turn = excess * follow_speed * dt
            turn = max(-max_turn_speed * dt, min(max_turn_speed * dt, turn))

            #  le corps tourne 
            self.model.setH(self.model, turn)

            #  le cou garde une rotation cohérente 
            self.neck_np.setH(diff - turn)

        if abs(diff) > 180:
            corrige_turn = self.neck_np.getH()
            self.model.setH(corrige_turn)

        # ===== CAMÉRA =====
        cam_pos = self.camera_roll_correction.getPos(self.parentClass.render)
        cam_h = self.camera_roll_correction.getH(self.parentClass.render) + 157.5
        cam_p = -self.camera_roll_correction.getP(self.parentClass.render)

        self.parentClass.camera.setPos(cam_pos)
        self.parentClass.camera.setHpr(cam_h, cam_p, 0)
        if self.moving:
            #self.model.setH(render, self.parentClass.camera.getH())
            pass

        return Task.cont


    def init_filters(self, task):
        if self.parentClass.camNode and self.parentClass.camNode.getDisplayRegion(0):
            #self.filters = CommonFilters(self.parentClass.win, self.parentClass.cam)
            #self.filters.setBlurSharpen(1)
            return Task.done
        else:
            print("Caméra non encore prête, filtres désactivés.")
            self.filters = None
            return Task.cont

If I have a good memory, then to get around this problem, I need to turn off the friction when pressing keys, provided that the speed does not change.

Logic: I pressed the forward key, but the speed does not match the expected one, for example, 5 km per hour. So I’m disabling the friction on the Bullet Character Controller.

How i disable my bullet character controller friction?

Since I implemented my controller, I have deleted the old implementations. It’s hard to remember now.

You need to look at the methods of the world class, since it contains links to BulletRigidBodyNode, and extract the character node back, sort of.

Okay, based on a very quick look–I’m tired, and you gave me a lot of code to skim through there–it looks like you’re using the character-controller that comes with Bullet. Which, according to the manual, should be kinematic–so that shouldn’t be the problem.

That said, the manual does also note that said character-controller is at an early stage of development, and may have issues, in particular with dynamic bodies.

Still, if I’m correct in understanding that the triangle mesh that you use for the ground is static–both in the sense of not being a moving object and in the sense of not being altered while the player moves–then I’d expect the character-controller to work well enough.

Let me ask: How small are your polygons? Depending on how the developers of Bullet implemented their character-controller, I could perhaps see small polygons being a problem for it, and larger polygons being less troublesome?

Otherwise, it might be worth considering either checking the Bullet forum for others who may have had similar issues–or making a character-controller of your own. The latter would at least allow you to test, debug, and tweak the character-controller–but could of course be more work overall.

Here is my script that creates triangles taken from another discussion and modified with AI.

    def make_collision_from_model(self, input_model, world, excluded_materials=None):
        """
        Crée une collision Bullet (BulletTriangleMeshShape) pour un modèle, 
        en parcourant les GeomNodes et en excluant par matériau.
        
        NOTE D'OPTIMISATION: Cette fonction est dupliquée dans Porte. 
        Elle devrait être factorisée dans une fonction utilitaire statique.
        """
        excluded_materials = excluded_materials or []
        mesh = BulletTriangleMesh()

        # Parcourt tous les GeomNodes du modèle
        for np in input_model.findAllMatches('**/+GeomNode'):
            geom_node = np.node()
            # Transformation nette pour positionner correctement la géométrie dans le mesh
            transform = np.getNetTransform().getMat()

            for i in range(geom_node.getNumGeoms()):
                state = geom_node.getGeomState(i)
                attrib = state.getAttrib(MaterialAttrib)
                mat_name = ""

                # Récupération du nom du matériau
                if attrib:
                    material = attrib.getMaterial()
                    if material:
                        mat_name = material.getName()

                # Exclusion par matériau (case-insensitive)
                if any(excl.lower() in mat_name.lower() for excl in excluded_materials):
                    continue

                # Ajout de la géométrie au mesh de collision
                geom = geom_node.getGeom(i)
                mesh.addGeom(geom, transform)

        if mesh.getNumTriangles() == 0:
            print("[TERRAIN] Aucun triangle de collision trouvé.")
            return None

        # Crée la forme et le corps Bullet
        tri_shape = BulletTriangleMeshShape(mesh, dynamic=False)
        body = BulletRigidBodyNode('terrain_collision')
        body.addShape(tri_shape)
        body.setMass(0) # Mass 0 = statique

        body_np = self.parent.render.attachNewNode(body)
        body_np.setCollideMask(BitMask32.allOn()) # Masque de collision actif
        world.attachRigidBody(body)
        return body_np

And it is called like this:

self.make_collision_from_model(self.terrain, self.bullet_world, ["Balloon3", "balloon1", "Balloon6.014", "glass", "matlight", "Matériau.003", "Matériau.006", "roof"])

This looks like a modified version of the make_collision_from_model() function I made for demo programs. The main difference seems to be your usage of a “transform” which you use in addGeom(). Without actually running your program, I’d probably look there first.

I haven’t seen any issues using a Bullet character controller over arbitrary trimeshes, as long as the trimesh is well constructed. If your character is getting “stuck” on edges, there’s probably an issue with the model file in terms of how the geometry is constructed, IE not flat enough, or with vertical bits that stop the CC from traversal.

Then perhaps it’s little wonder if the method is causing issues. :/

Actually, this code was modified by ai a long time ago, and I haven’t modified it in a year.

Sure, but the changes that it made may still have caused your issues here, since it presumably only recently came into contact with the character controller.

Either way, AI “coding” is, I think, unwise: if you don’t know what it’s doing, it’s hard to debug; if you do know what it’s doing then… why not just do it yourself?

And that’s not to mention that these things are by-and-large built on plagiarism–code fed into the training of the things without asking the developers who wrote it.

Don’t use Generative AI.

It’s true that AI makes a lot of mistakes, but I make just as many, especially since I taught myself Python two years ago and I’m still in the beginning of high school, so I haven’t studied programming yet. That’s why I use AI to generate code, especially when I don’t know how to write a complicated program for my level or when I don’t understand the documentation.

To get moving in search of a problem, you can enable debugging mode for Bullet. Evaluate the visually generated surface from the model.

1 Like

Sure, but you can learn. With time and practice, you will likely increase in skill.

And conversely, relying on AI will likely reduce the rate at which you learn, as you don’t get that practice from which learning arises, nor have to consider the answer.

It’s a bit like walking: the more you walk, the further you can walk; the more you rely on tools that allow you to avoid walking, the less you’ll gain that range of walking.

(Not to mention that, when you make mistakes, you at least may know what you were thinking with your mistaken attempt, from which again you might learn, and which may help you to find the problem. With a mistake produced by AI, you won’t have that understanding, and thus again may learn less, and have more trouble finding the issue.)

2 Likes