Python Ursina 3D Engine — Deep Dive

Under the Hood: Panda3D

Ursina is a convenience layer over Panda3D, which has been developed since 2002 (originally by Disney VR Studio, now maintained by Carnegie Mellon University’s Entertainment Technology Center). Understanding this relationship matters because any Panda3D feature is accessible from Ursina — you can drop down to the lower-level API whenever you need something Ursina does not directly expose.

Panda3D uses a scene graph — a tree of nodes where each node has a transform (position, rotation, scale) relative to its parent. Ursina’s Entity class wraps a Panda3D NodePath. When you set entity.position = (1, 2, 3), Ursina calls node_path.setPos(1, 2, 3) internally.

Entity Lifecycle and Custom Components

Creating Custom Entity Classes

from ursina import *

class Bullet(Entity):
    def __init__(self, direction, **kwargs):
        super().__init__(
            model='sphere',
            color=color.yellow,
            scale=0.2,
            collider='sphere',
            **kwargs
        )
        self.direction = direction
        self.speed = 40

    def update(self):
        self.position += self.direction * self.speed * time.dt
        if abs(self.x) > 50 or abs(self.y) > 50 or abs(self.z) > 50:
            destroy(self)

Each entity with an update method is automatically called each frame. The destroy() function removes the entity from the scene graph and frees its resources.

Component-Like Patterns

While Ursina does not have a formal ECS (Entity-Component-System), you can attach behaviors by subclassing or by assigning functions dynamically:

def wobble(self):
    self.rotation_y += 50 * time.dt

cube = Entity(model='cube')
cube.update = wobble.__get__(cube)

Procedural Mesh Generation

Ursina allows runtime mesh creation for terrain, voxels, or custom shapes:

from ursina import Mesh, Entity, Vec3

vertices = [
    Vec3(0, 0, 0), Vec3(1, 0, 0), Vec3(1, 1, 0),
    Vec3(0, 0, 0), Vec3(1, 1, 0), Vec3(0, 1, 0),
]
normals = [Vec3(0, 0, -1)] * 6
uvs = [(0,0), (1,0), (1,1), (0,0), (1,1), (0,1)]

custom_mesh = Mesh(vertices=vertices, normals=normals, uvs=uvs, mode='triangle')
Entity(model=custom_mesh, texture='brick', double_sided=True)

For voxel games, developers generate chunk meshes by iterating a 3D array and only emitting faces between solid and air blocks — a technique called greedy meshing. Ursina’s mesh system is flexible enough to handle this, and because it sits on Panda3D’s geometry nodes, the GPU handles rendering efficiently.

Custom Shaders

Ursina entities can use custom GLSL shaders:

lit_shader = Shader(
    vertex='''
    #version 140
    uniform mat4 p3d_ModelViewProjectionMatrix;
    in vec4 p3d_Vertex;
    in vec2 p3d_MultiTexCoord0;
    out vec2 uv;
    void main() {
        gl_Position = p3d_ModelViewProjectionMatrix * p3d_Vertex;
        uv = p3d_MultiTexCoord0;
    }
    ''',
    fragment='''
    #version 140
    uniform sampler2D p3d_Texture0;
    in vec2 uv;
    out vec4 fragColor;
    void main() {
        vec4 tex = texture(p3d_Texture0, uv);
        fragColor = tex * vec4(1.0, 0.8, 0.8, 1.0);  // warm tint
    }
    '''
)
Entity(model='cube', shader=lit_shader, texture='white_cube')

Panda3D’s shader system supports vertex, fragment, and geometry shaders. You can pass custom uniforms via entity.set_shader_input('time_val', time.time()) for animated effects like water ripples or dissolve transitions.

Collision System Details

Ursina supports three collider types:

ColliderUse CaseCost
'box'Rectangular objects, wallsCheapest
'sphere'Balls, projectiles, proximity triggersCheap
'mesh'Complex terrain, irregular shapesExpensive — use sparingly

Collision detection uses Panda3D’s CollisionTraverser. For performance-critical scenarios, keep mesh colliders on static geometry only and use box or sphere colliders for moving entities.

Raycasting for Game Mechanics

from ursina import raycast

hit_info = raycast(
    origin=player.position,
    direction=player.forward,
    distance=100,
    ignore=[player]
)
if hit_info.hit:
    print(f"Hit {hit_info.entity.name} at distance {hit_info.distance}")
    hit_info.entity.color = color.red

Raycasting is essential for shooting mechanics, line-of-sight AI, and terrain interaction (placing blocks, mining).

Networking and Multiplayer

Ursina includes an experimental UrsinaNetworking module (and there are community libraries like ursina-networking). The pattern uses a client-server model:

  1. Server maintains authoritative game state.
  2. Clients send input events, receive state updates.
  3. Entity synchronization maps server-side IDs to client-side entities.

For production multiplayer, most developers use external libraries like enet (low-level UDP) or WebSockets, using Ursina only for rendering. The engine’s single-threaded nature means network I/O should be async or on a separate thread to avoid frame drops.

Performance Optimization

Batching and Instancing

When rendering many identical objects (trees, grass, particles), combine them into a single mesh or use Panda3D’s instancing:

# Combine static entities into one mesh
from ursina import combine

trees = [Entity(model='tree', position=(x, 0, z))
         for x in range(-50, 50, 5) for z in range(-50, 50, 5)]
combined = combine(trees)  # single draw call

Level of Detail (LOD)

Swap high-poly models for low-poly versions at distance. Ursina does not have built-in LOD, but you can implement it:

def update():
    dist = distance(player, tree)
    if dist > 100 and tree.model != 'tree_low':
        tree.model = 'tree_low'
    elif dist <= 100 and tree.model != 'tree_high':
        tree.model = 'tree_high'

Profiling

Panda3D includes PStats, a real-time profiling tool that shows frame time broken down by render, collision, animation, and Python logic. Enable it with:

from panda3d.core import loadPrcFileData
loadPrcFileData('', 'want-pstats 1')

Common performance pitfalls:

IssueCauseSolution
Low FPS with many entitiesToo many draw callsCombine static meshes, use instancing
Slow collisionsMesh colliders on moving entitiesSwitch to box/sphere colliders
Memory spikesLoading full-res texturesCompress textures, use mipmaps
Lag spikes every few secondsPython garbage collectionTune gc thresholds or pool objects

Building and Distributing

Ursina games can be packaged with:

  • ursina.build() — the engine’s own packaging tool that bundles assets and creates an executable.
  • PyInstaller — works with Panda3D; requires adding data files to the spec.
  • Panda3D’s deploy-ng — the official Panda3D deployment system that produces standalone builds for Windows, macOS, and Linux.

For web deployment, Panda3D is experimenting with WebGL/WebAssembly builds, but this remains in early stages.

Real-World Project Structure

my_game/
├── main.py                 # App setup, view switching
├── settings.py             # Window size, controls, constants
├── entities/
│   ├── player.py           # Player entity + controller
│   ├── enemy.py            # Enemy AI entities
│   └── projectile.py       # Bullets, arrows
├── world/
│   ├── terrain.py          # Procedural or loaded terrain
│   ├── chunk_manager.py    # Voxel chunk loading/unloading
│   └── lighting.py         # Sun, ambient, point lights
├── ui/
│   ├── hud.py              # Health bar, ammo counter
│   └── menus.py            # Main menu, settings
├── shaders/
│   ├── water.glsl
│   └── fog.glsl
└── assets/
    ├── models/
    ├── textures/
    └── sounds/

When to Choose Ursina

ScenarioUrsinaAlternative
Quick 3D prototypeExcellent
Game jam (48-72 hours)GreatGodot, Unity
Teaching 3D programmingBest in Python ecosystem
AAA-quality production gameNot suitableUnreal, Unity, Godot
VR applicationLimitedPanda3D directly, Godot

The one thing to remember: Ursina gives you Panda3D’s full 3D pipeline — shaders, scene graph, collision, networking — wrapped in an API so concise that a complete game prototype fits in a single Python file.

pythonursina3d-game-engine

See Also

  • Python Arcade Library Think of a magical art table that draws your game characters, listens when you press buttons, and cleans up the mess — that's Python Arcade.
  • Python Audio Fingerprinting Ever wonder how Shazam identifies a song from just a few seconds of noisy audio? Audio fingerprinting is the magic behind it, and Python can do it too.
  • Python Barcode Generation Picture the stripy labels on grocery items to understand how Python can create those machine-readable barcodes from numbers.
  • Python Cellular Automata Imagine a checkerboard where each square follows simple rules to turn on or off — and suddenly complex patterns emerge like magic.
  • Python Godot Gdscript Bridge Imagine speaking English to a friend who speaks French, with a translator in the middle — that's how Python talks to the Godot game engine.