Scaled Colliders

one shape resource, many sizes, and the collider matches the mesh.

▶ Run in browser

Tags: physics 3d scale

A body’s scale reaches its collider. Three crates share one BoxShape3D with half-extents of 0.5, and their bodies are scaled 1, 2 and 3.5, so they land and rest at heights of 0.5, 1.0 and 1.75: the size you see is the size the simulation uses. Each crate’s MeshInstance3D is a child of its body, so it inherits the same scale and the one unit cube mesh draws all three sizes.

Scale belongs to the body, not to the shape. A shape resource is shared, so it cannot hold one node’s size; the body passes its world_scale to the physics world with its pose, and every backend applies it to the geometry. That is why one resource can serve any number of bodies of any size.

A live rescale works the same way: assign body.scale and the collider follows at once, and the body wakes so it can settle at its new size.

Not every collider can take every scale. A box scales per axis, so it can be squashed on Y alone. A sphere has one radius, so a non-uniform scale on a SphereShape3D body raises ValueError explaining why, rather than choosing a radius that no longer matches the mesh. A mirrored scale (a negative component) still counts as uniform.

Controls, each also a button on screen:

  • 1: grow the highlighted middle crate

  • 2: shrink it

  • 3: squash it on Y only, which a box can do

  • 4: try the same squash on the ball, which is refused with the reason

  • R: reset the scene

  • Left and Right arrows, or drag: orbit the camera; mouse wheel or pinch: zoom

Source

  1# /// simvx
  2# tags = ["3d", "physics", "scale"]
  3# ///
  4"""Scaled Colliders: one shape resource, many sizes, and the collider matches the mesh.
  5
  6A body's `scale` reaches its collider. Three crates share one `BoxShape3D` with
  7half-extents of 0.5, and their bodies are scaled 1, 2 and 3.5, so they land and
  8rest at heights of 0.5, 1.0 and 1.75: the size you see is the size the simulation
  9uses. Each crate's `MeshInstance3D` is a child of its body, so it inherits the same
 10scale and the one unit cube mesh draws all three sizes.
 11
 12Scale belongs to the body, not to the shape. A shape resource is shared, so it
 13cannot hold one node's size; the body passes its `world_scale` to the physics world
 14with its pose, and every backend applies it to the geometry. That is why one
 15resource can serve any number of bodies of any size.
 16
 17A live rescale works the same way: assign `body.scale` and the collider follows
 18at once, and the body wakes so it can settle at its new size.
 19
 20Not every collider can take every scale. A box scales per axis, so it can be
 21squashed on Y alone. A sphere has one radius, so a non-uniform scale on a
 22`SphereShape3D` body raises `ValueError` explaining why, rather than choosing a
 23radius that no longer matches the mesh. A mirrored scale (a negative component)
 24still counts as uniform.
 25
 26Controls, each also a button on screen:
 27
 28- 1: grow the highlighted middle crate
 29- 2: shrink it
 30- 3: squash it on Y only, which a box can do
 31- 4: try the same squash on the ball, which is refused with the reason
 32- R: reset the scene
 33- Left and Right arrows, or drag: orbit the camera; mouse wheel or pinch: zoom
 34"""
 35
 36from simvx.core import (
 37    Anchor,
 38    Axis1D,
 39    BodyMode,
 40    BoxShape3D,
 41    Button,
 42    CanvasLayer,
 43    DirectionalLight3D,
 44    HBoxContainer,
 45    Input,
 46    Key,
 47    Label,
 48    Material,
 49    Mesh,
 50    MeshInstance3D,
 51    Node,
 52    OrbitCamera3D,
 53    PhysicsBody3D,
 54    SphereShape3D,
 55    Vec3,
 56    on_input,
 57)
 58from simvx.graphics import App
 59
 60CRATES = ((-4.0, 1.0), (0.0, 2.0), (4.0, 3.5))  # (x, uniform scale) of each crate
 61BALL_RADIUS = 0.6
 62SQUASH = Vec3(2.0, 0.5, 2.0)
 63BUTTONS = {
 64    "grow": "Grow",
 65    "shrink": "Shrink",
 66    "squash_box": "Squash box",
 67    "squash_ball": "Squash ball",
 68    "reset": "Reset",
 69}
 70
 71CRATE_MAT = Material(colour=(0.85, 0.62, 0.32, 1.0), roughness=0.7)
 72PICKED_MAT = Material(colour=(0.35, 0.80, 0.95, 1.0), emissive_colour=(0.1, 0.4, 0.5, 0.5), roughness=0.4)
 73BALL_MAT = Material(colour=(0.75, 0.35, 0.45, 1.0), roughness=0.5)
 74GROUND_MAT = Material(colour=(0.22, 0.24, 0.28, 1.0), roughness=1.0)
 75
 76
 77def body_with_mesh(shape, mesh, material, position, scale=1.0, mode=BodyMode.DYNAMIC) -> PhysicsBody3D:
 78    """A body and the mesh that draws it, as its child so it inherits the body's scale."""
 79    body = PhysicsBody3D(mode=mode, shape=shape, position=position, scale=Vec3(scale))
 80    body.add_child(MeshInstance3D(mesh=mesh, material=material))
 81    return body
 82
 83
 84class ColliderScaleScene(Node):
 85    input_context = {
 86        "grow": [Key.KEY_1],
 87        "shrink": [Key.KEY_2],
 88        "squash_box": [Key.KEY_3],
 89        "squash_ball": [Key.KEY_4],
 90        "reset": [Key.R],
 91        "orbit": [Axis1D(low=Key.LEFT, high=Key.RIGHT)],
 92    }
 93
 94    def on_ready(self):
 95        self.camera = self.add_child(OrbitCamera3D(pivot=(2, 2, 0), distance=23.0, yaw=0.4, pitch=-0.3))
 96        sun = self.add_child(DirectionalLight3D(position=(6, 14, 10), colour=(1.0, 0.96, 0.88), intensity=3.0))
 97        sun.look_at((0, 0, 0))
 98
 99        ground = BoxShape3D((30.0, 0.5, 30.0))
100        self.add_child(body_with_mesh(ground, Mesh.box((60, 1, 60)), GROUND_MAT, (0, -0.5, 0), mode=BodyMode.STATIC))
101
102        # One shape resource and one mesh for every crate: the sizes live on the bodies.
103        crate_shape, cube = BoxShape3D((0.5, 0.5, 0.5)), Mesh.box()
104        self.crates = [self.add_child(body_with_mesh(crate_shape, cube, CRATE_MAT, (x, 8, 0), s)) for x, s in CRATES]
105        self.picked = self.crates[1]
106        self.picked.find(MeshInstance3D).material = PICKED_MAT
107        ball = body_with_mesh(SphereShape3D(BALL_RADIUS), Mesh.sphere(BALL_RADIUS), BALL_MAT, (8, 8, 0), 2)
108        self.ball = self.add_child(ball)
109        self.message = "One BoxShape3D, three sizes"
110
111        hud = self.add_child(CanvasLayer())
112        self.status = hud.add_child(
113            Label(anchor=Anchor.TOP_WIDE, inset=16, size=(0, 56), font_size=20, autoshrink=True)
114        )
115        buttons = hud.add_child(HBoxContainer(anchor=Anchor.CENTER_BOTTOM, inset=20))
116        for action, text in BUTTONS.items():
117            buttons.add_child(Button(text, action=action))
118        buttons.autosize_to_content()
119
120    def rescale_picked(self, scale):
121        self.picked.scale = scale
122        self.message = f"Middle crate scale: {tuple(round(float(c), 2) for c in self.picked.scale)}"
123
124    @on_input("grow")
125    def grow(self, event):
126        self.rescale_picked(Vec3(float(self.picked.scale.x) + 0.5))
127
128    @on_input("shrink")
129    def shrink(self, event):
130        self.rescale_picked(Vec3(max(0.5, float(self.picked.scale.x) - 0.5)))
131
132    @on_input("squash_box")
133    def squash_box(self, event):
134        self.rescale_picked(SQUASH)
135
136    @on_input("squash_ball")
137    def squash_ball(self, event):
138        before = Vec3(self.ball.scale)
139        try:
140            self.ball.scale = SQUASH
141        except ValueError as exc:
142            # The node keeps a scale its collider refused, so the two disagree until it is put back.
143            self.ball.scale = before
144            self.message = str(exc)
145
146    @on_input("reset")
147    def reset(self, event):
148        self.tree.change_scene(ColliderScaleScene())
149
150    def on_update(self, dt):
151        self.camera.orbit(Input.value("orbit") * dt, 0.0)
152        heights = "  ".join(f"{float(crate.world_position.y):.2f}" for crate in self.crates)
153        self.status.text = f"{self.message}\nCrate rest heights: {heights}"
154
155
156if __name__ == "__main__":
157    App(title="Scaled Colliders", width=1280, height=720).run(ColliderScaleScene())