Cross-cutting concepts
Before you can sensibly read or modify any Panda3D cluster, you need to recognize five patterns that recur in nearly every header in the tree.
1. TypeHandle / RTTI — Panda’s hand-rolled type system
What it is. Panda does not trust C++ typeid/dynamic_cast. Instead, every class that needs runtime type identification carries a static TypeHandle, which is just an integer index into a single process-wide TypeRegistry. The registry stores the name, the parent/child links, and (optionally) the associated Python type for each registered class. This is the foundation the other four patterns lean on: serialization dispatch, downcasting, and Python wrapping all key off the TypeHandle.
Key files / classes / macros.
dtool/src/dtoolbase/typeHandle.h—class TypeHandle final; it wraps a singleint _indexand offersget_name(),is_derived_from(),get_index(), plus the sentinel valuesTypeHandle::none()(index 0) andTypeHandle::invalid()(index -1).dtool/src/dtoolbase/typeRegistry.h—class TypeRegistry, the single global tree.TypeRegistry::ptr()returns the singleton;register_type(),record_derivation(),find_type(), andis_derived_from()live here.dtool/src/dtoolbase/typedObject.h—class TypedObject, the abstract base that adds the virtualget_type(), plus inlineis_of_type()/is_exact_type()and theforce_init_type()hook.dtool/src/dtoolbase/register_type.h— the overloadedregister_type(TypeHandle &, name, parent1…parent4)convenience functions and theget_type_handle(type)/do_init_type(type)template helpers.panda/src/express/dcast.h(+dcast.T) — theDCAST,DCAST_INTO_V,DCAST_INTO_Rmacros.
How it works. Every type follows a fixed boilerplate (the header comment in typedObject.h literally warns you to keep the formatting identical so sed scripts can rewrite it en masse). In the .h:
static TypeHandle get_class_type() { return _type_handle; }
static void init_type() {
BaseClass::init_type();
register_type(_type_handle, "MyClass", BaseClass::get_class_type());
}
virtual TypeHandle get_type() const { return get_class_type(); }
virtual TypeHandle force_init_type() { init_type(); return get_class_type(); }
private:
static TypeHandle _type_handle;
and in the .cxx exactly one line: TypeHandle MyClass::_type_handle;. The static _type_handle starts as TypeHandle::none() (index 0) because static-initialization order is unspecified — see the comment in typeHandle.h explaining the default constructor must do nothing. At startup each library’s config function (the ConfigureFn / init_libX() shown in typedObject.h) calls every class’s init_type(), which calls register_type(), which asks the TypeRegistry to hand out the next free integer and record the parent links. From then on, obj->get_type() returns that integer and is_of_type(handle) (in typedObject.I) is a near-free integer compare with a fall-through to TypeRegistry::is_derived_from() walking the recorded derivation tree.
How DCAST works. DCAST(MyType, ptr) expands (via dcast.h) to _dcast((MyType*)0, ptr). In dcast.T, _dcast calls _dcast_verify(want_handle, sizeof(WantType), ptr), which checks ptr->is_of_type(want_handle) before doing the C-style downcast; on failure it returns nullptr instead of corrupting memory. Under NDEBUG (DO_DCAST undefined) the check compiles away to a raw cast, so DCAST is safe in debug and fast in release. DCAST_INTO_V / DCAST_INTO_R additionally nassert and early-return. This is why you almost never see a bare dynamic_cast in Panda — downcasting is funneled through get_type().
Why it’s built this way. Panda predates reliable, portable C++ RTTI and still has to solve problems typeid cannot:
Cross-DLL identity.
type_infoobjects are not guaranteed comparable across shared-library boundaries on every platform; a process-wide integer registry is. (TypeRegistry’s comment even anticipates migrating to shared memory.)Python integration.
TypeRegistry::record_python_type()andTypeHandle::wrap_python()map a C++ type to itsPyTypeObject, so a returned C++ pointer can be wrapped as the correct Python subclass.typeidgives you nothing here.Serialization dispatch. The
.bamfactory (concept 3) reconstructs objects keyed byTypeHandle. You need a stable, serializable type id — an integer the registry controls, not a compiler-private symbol.Optional cost. A class can declare a
TypeHandlewithout inheritingTypedObject, avoiding even one vtable slot, when virtualget_type()isn’t needed (seeThatThingiein thetypeHandle.hexample).
Where to look / gotchas. If get_type() returns TypeHandle::none(), the class’s init_type() never ran — that’s the symptom of a missing init_type() call in a library’s config function, and it’s exactly what force_init_type() exists to repair on the fly. The TypeRegistry is mutex-guarded (MutexImpl _lock) but derivations are cached lazily (_derivations_fresh/rebuild_derivations()), so adding types after startup is legal but triggers a rebuild. See the dtool subsystem page for where these headers sit in the library stack, and the scene-graph page for how PandaNode and friends use DCAST pervasively.
2. Reference counting & PointerTo — intrusive memory management
What it is. Panda manages heap object lifetime with intrusive reference counting: the count lives inside the object (a base class ReferenceCount), and smart-pointer templates PointerTo<T> / ConstPointerTo<T> increment it on copy and decrement on destruction, deleting the object when the count hits zero. It is Panda’s shared_ptr, but hand-rolled and older.
Key files / classes / macros.
panda/src/express/referenceCount.h—class ReferenceCount : public MemoryBase; the count ismutable patomic<int> _ref_countplus apatomic<WeakReferenceList *> _weak_list. Public API:ref(),unref(),get_ref_count(),weak_ref()/weak_unref(), and the helperunref_delete().panda/src/express/pointerTo.h—template <class T> class PointerToandConstPointerTo, both deriving fromPointerToBase<T>. The abbreviation macros:#define PT(type) PointerTo< type >and#define CPT(type) ConstPointerTo< type >.panda/src/express/typedReferenceCount.h—class TypedReferenceCount : public TypedObject, public ReferenceCount. This is the everyday base for “an object that is both type-identified and refcounted.”panda/src/express/weakPointerTo.h,weakReferenceList.h—WeakPointerTo<T>and the sharedWeakReferenceList.
How it works. ref() does an atomic _ref_count.fetch_add(1, relaxed); unref() does fetch_sub(1, release) and returns whether the new count is nonzero (referenceCount.I). Critically, unref() does not delete — a member function deleting this is hazardous — so the actual delete happens in PointerTo’s destructor / unref_delete() once unref() reports zero. PointerTo<T> overloads operator*, operator->, and operator T*() so it behaves like a raw pointer (pointerTo.h), and offers .p() to skip a double-cast when downcasting. The const-ness convention is spelled out in pointerTo.h: ConstPointerTo<X> ≈ const X * (can re-point, can’t mutate), whereas const PointerTo<X> ≈ X * const (can mutate, can’t re-point).
ReferenceCount also bakes in a debug safety net: a fresh object starts at count 0; the destructor (referenceCount.I) nasserts the count is 0 or local_ref_count, then stores the poison value deleted_ref_count (-100) so a stray PointerTo to freed memory trips an assertion instead of silently corrupting. local_object() sets a huge sentinel count (local_ref_count = 10000000) so you may legally put a refcounted object on the stack and pass it to functions that ref/unref it without it being deleted out from under you.
WeakPointerTo. WeakPointerTo<T> (weakPointerTo.h) does not keep the object alive. The first weak reference lazily creates a WeakReferenceList (referenceCount.h::get_weak_list()), shared by all weak pointers to that object. On destruction ReferenceCount calls weak_list->mark_deleted(), so was_deleted() becomes true and lock() safely returns a null PointerTo. This is the tool for breaking reference cycles (e.g. a child that needs to see its parent without keeping it alive).
Why it’s built this way.
Predates C++11.
std::shared_ptrdidn’t exist when this was written; the design is from the late 1990s (@date 1998-10-23).Cache locality / size. An intrusive count is one word inside the object — no separate control block, no second allocation, and the pointer is exactly one machine word (vs.
shared_ptr’s two). Sorting apvector<PT(X)>doesn’t touch any control blocks.Cross-language. The same count that C++ uses is the count Python participates in (see below), so an object can be co-owned by both worlds with one mechanism.
Atomic but optional.
patomic<int>makes ref/unref thread-safe for the render pipeline (concept 4) without a per-object lock.
Where to look / gotchas.
Never
new+raw-pointer aReferenceCountsubclass and let it leak or double-free — assign it to aPT()immediately. The poison-value assertions in~ReferenceCountexist precisely because people forget.Stack-allocating a
ReferenceCountsubclass and pointing aPT()at it will try todeletea stack address — calllocal_object()or just don’t.The Python footgun. When you subclass
PandaNodein Python or callnode.set_python_tag(key, value)(thePY_EXTENSION set_python_taginpandaNode.h), Python holds a reference to the C++ object and the C++ object holds a reference back to the Python object/tag. That’s a cross-language cycle the C++ refcounter and Python’s GC each only see half of, so it never collects. Break it explicitly (clear the tag, use a weak reference) — this is the single most common memory leak in Panda Python code. See the direct-python-framework and dtool pages.
3. Datagram / BamReader / BamWriter — .bam serialization & versioning
What it is. Panda’s universal binary serialization layer. A Datagram is a growable binary blob you append typed fields to; BamWriter/BamReader walk an object graph, ask each object to write/read itself, and patch up inter-object pointers by id. The same machinery backs .bam model files, the on-disk model cache, and network object transmission.
Key files / classes / macros.
panda/src/express/datagram.h—class Datagram : public TypedObject. You calladd_bool,add_int32,add_float64,add_string,add_stdfloat, etc. (default little-endian;add_be_*for big-endian). The bytes live in aPTA_uchar _data.panda/src/express/datagramIterator.h—DatagramIterator, the read cursor with matchingget_int32(),get_string(), … in the same field order.panda/src/putil/typedWritable.h—class TypedWritable : public TypedObject. The two virtuals every serializable class overrides:write_datagram(BamWriter *, Datagram &)andfillin(DatagramIterator &, BamReader *). Pluscomplete_pointers()for the pointer-patching pass andfinalize().panda/src/putil/bamReader.h,bamWriter.h— the graph walkers.BamReaderdefinestypedef Factory<TypedWritable> WritableFactoryand the staticregister_factory()/get_factory().panda/src/putil/bam.h— the magic number and version constants (see below).panda/src/putil/factory.h— the genericFactory<Type>keyed byTypeHandle.
How it works (the round trip). Writing: BamWriter::write_object() looks at an object’s TypeHandle, writes it (once) along with a fresh object id, then calls the object’s write_datagram(). Inside write_datagram() the object serializes its own scalar fields into the Datagram and, for any pointer to another TypedWritable, calls manager->write_pointer() — which records the referenced object’s id rather than the raw pointer. PandaNode is the canonical example (pandaNode.cxx):
void PandaNode::write_datagram(BamWriter *manager, Datagram &dg) {
TypedWritable::write_datagram(manager, dg); // chain to base
dg.add_string(get_name());
manager->write_cdata(dg, _cycler); // its CycleData (concept 4)
}
Reading is the mirror image driven by the factory. Each class registers itself once:
void PandaNode::register_with_read_factory() {
BamReader::get_factory()->register_factory(get_class_type(), make_from_bam);
}
TypedWritable *PandaNode::make_from_bam(const FactoryParams ¶ms) {
PandaNode *node = new PandaNode("");
DatagramIterator scan; BamReader *manager;
parse_params(params, scan, manager);
node->fillin(scan, manager); // read fields back in the same order
return node;
}
When BamReader hits an object id in the stream it reads the TypeHandle, looks up the registered make_from_bam in the WritableFactory, and calls it. fillin() reads the scalars and registers each pointer-by-id with manager->read_pointer(). Because objects may be read before the objects they point to, the actual pointers are resolved in a second pass: once all referenced objects exist, BamReader calls complete_pointers(p_list, manager) so each object can store the now-resolved pointers, and finally finalize(). This two-phase (read fields → complete pointers) design is what lets a .bam file encode an arbitrary cyclic object graph as a flat list.
Versioning. bam.h holds the format identity, and these are the current real values in this tree:
inline const std::string _bam_header{"pbj\0\n\r", 6}; // magic number
inline constexpr unsigned short _bam_major_ver = 6;
inline constexpr unsigned short _bam_first_minor_ver = 14;
inline constexpr unsigned short _bam_last_minor_ver = 46;
inline constexpr unsigned short _bam_minor_ver = 46;
The magic pbj\0\n\r includes CR/LF so corruption from text-mode file transfers is detected. Major version bumps are breaking (major 6 since 2006-02-11, “factor out PandaNode::CData”); minor bumps are backward-compatible additions. The file records the writer’s version; fillin() methods branch on manager->get_file_minor_ver() to read old layouts. The long changelog comments in bam.h (minor 14 → 46, the latest being 2025-08-03 adding ModelRoot::_loader_type) document exactly which field each minor version introduced — read them when you add a serialized field, and bump _bam_minor_ver accordingly.
Why it’s built this way. A single serialization format for files, cache, and network means model loading, the disk cache, and TypedWritable::encode_to_bam_stream() (in-memory pickling, also the basis of Python __reduce__) all share one tested codepath. Keying reconstruction off TypeHandle (concept 1) rather than C++ constructors is what makes it extensible across DLLs and from custom classes — register a factory and your type is .bam-serializable.
Where to look / gotchas. Field order in write_datagram() and fillin() must match exactly, including the chained base-class call first. If you add a field, you must (a) bump _bam_minor_ver, (b) guard the read with a version check so old files still load, and (c) leave the write unconditional. Forgetting register_with_read_factory() yields a “unknown type in bam file” failure at load. See the egg and pandatool pages (model conversion to .bam) and the devices-and-networking page (the same Datagrams over sockets).
4. PipelineCycler / Copy-on-Write — the threaded render pipeline
What it is. Panda can run its render loop as a software pipeline of stages — classically App → Cull → Draw — on separate threads. The problem: the Cull thread must read a stable, consistent snapshot of the scene graph while the App thread is busy mutating it for the next frame. The solution is the PipelineCycler: per-object state lives in a CycleData page, and the cycler keeps one copy per pipeline stage, copy-on-write, so each stage reads its own immutable snapshot. This is widely considered the hardest concept in the engine.
Key files / classes / macros.
panda/src/pipeline/cycleData.h—class CycleData. You subclass it to hold the per-stage state. Note it inheritsNodeReferenceCountonly whenDO_PIPELININGis defined; otherwise it’s a plainMemoryBasestored inline. Its key virtual ismake_copy()(used for the copy-on-write).panda/src/pipeline/pipelineCycler.h—template<class CycleDataType> struct PipelineCycler : public PipelineCyclerBase. The accessors:read(),write(),read_stage(),write_stage(),elevate_read().panda/src/pipeline/pipelineCyclerBase.h— typedef selecting the implementation by build flags:PipelineCyclerTrueImpl(THREADED_PIPELINE),PipelineCyclerDummyImpl(DO_PIPELINING, no threads — self-validating), orPipelineCyclerTrivialImpl(neither — zero overhead).panda/src/pipeline/cycleDataReader.h,cycleDataWriter.h(and…StageReader/Writer,…LockedReader) — the RAII accessor wrappers.panda/src/putil/copyOnWriteObject.h—class CopyOnWriteObject : public CachedTypedWritableReferenceCount, the related COW base for large shared assets (e.g. geometry), withmake_cow_copy()andget_read_pointer()/get_write_pointer().
How it works. A class that needs pipelined state declares a nested CData : public CycleData, a PipelineCycler<CData> _cycler member, and a set of accessor typedefs. PandaNode is the textbook case (pandaNode.h):
class CData : public BoundsData { ... virtual CycleData *make_copy() const; ... };
PipelineCycler<CData> _cycler;
typedef CycleDataReader<CData> CDReader;
typedef CycleDataWriter<CData> CDWriter;
typedef CycleDataStageReader<CData> CDStageReader;
typedef CycleDataStageWriter<CData> CDStageWriter;
The read cycle. To read, construct a CDReader cdata(_cycler); (cycleDataReader.h). It calls _cycler.read_unlocked(current_thread), stashing a const CData * for the current thread’s pipeline stage, and overloads operator-> so cdata->_some_field reads that stage’s snapshot. It’s const throughout — a reader can never mutate. When cdata goes out of scope its destructor releases the pointer. Because each stage holds its own copy, the Cull thread reading stage 1 is completely insulated from the App thread writing stage 0.
The write cycle. To mutate, construct a CDWriter cdata(_cycler); (cycleDataWriter.h). Under the hood _cycler.write(thread) does the copy-on-write: if this stage still shares its CData page with a downstream stage, it calls CData::make_copy() to fork a private, writable copy first, so your edits don’t retroactively change a snapshot another thread is mid-read on. You then write through cdata->_some_field = …; and the destructor publishes it.
Cycling. Once per frame Pipeline::cycle() advances every cycler: stage N’s data moves to stage N+1 (App’s just-finished frame becomes Cull’s input, Cull’s becomes Draw’s), and the tail copy is retired. So at any instant App, Cull, and Draw each see a different, internally consistent generation of the scene.
DO_PIPELINING is a compile flag because all of this has a cost. When it’s off, PipelineCycler (pipelineCycler.h) stores the CycleDataType _typed_data directly inline with no pointer and no copy, the PipelineCyclerTrivialImpl is selected, and CDReader/CDWriter collapse to thin wrappers around a single pointer. The whole abstraction compiles away to near-zero overhead in a single-threaded build, which is why the accessor classes hide their internals from interrogate (#ifndef CPPPARSER) and why the code is so disciplined about going through readers/writers — that discipline is exactly what makes the no-op build correct.
Why it’s built this way. Lock-free reads. Rather than locking the scene graph while Cull traverses it (which would serialize App and Cull), each thread reads an immutable snapshot and the only synchronization is the once-per-frame cycle(). Copy-on-write means unmodified data is shared (one CData referenced by several stages) and only forked when actually written — most nodes don’t change every frame, so most pages are never copied. The NodeReferenceCount-vs-MemoryBase split in cycleData.h exists precisely so the refcounting needed to share pages between stages is present only when pipelining is compiled in.
Where to look / gotchas. Never cache a raw CData * across frames — it’s only valid for the lifetime of its CDReader/CDWriter and only for the current stage. Holding a CDWriter longer than necessary forces copies and serializes threads. To touch stages other than the current one (e.g. propagating a change upstream), use CDStageWriter/write_stage() and the OPEN_ITERATE_*_STAGES macros from pipelineCycler.h. See the scene-graph page for how PandaNode, TransformState, and RenderState use cyclers, and the display-and-gsg page for the Draw stage.
5. Interrogate — automatic Python binding generation
What it is. Almost every C++ class in Panda is usable from Python without anyone hand-writing a binding. A build-time tool called interrogate parses the C++ headers, finds everything marked for export, and emits C++ glue that exposes those classes/methods as a CPython extension module. The single most visible artifact in the headers is the PUBLISHED: access specifier.
Note on this tree. The interrogate parser tool itself (and its C++ parser) has been moved out of this repository into the separate panda3d-interrogate project — you can confirm it here: dtool/src/interrogate and dtool/src/cppparser do not exist in this checkout. What remains is the runtime support every binding links against, under dtool/src/interrogatedb/.
Key files / classes / macros.
dtool/src/dtoolbase/dtoolbase_cc.h— wherePUBLISHEDis#defined. There are two definitions: when the interrogate pass is running (CPPPARSERdefined) it becomes the custom keyword__published; in a normal compile it becomes plainpublic:#define PUBLISHED __published // during interrogate's parse #define PUBLISHED public // during the real C++ compile
So
PUBLISHED:is invisible to your C++ compiler (it’s justpublic) but is a flag interrogate keys on. Related markers in the same area:PY_EXTENSION(...),EXTENSION(...),MAKE_PROPERTY(...),MAKE_SEQ(...),BLOCKING,EXPCL_*— all noise to the compiler, all meaningful to interrogate.dtool/src/interrogatedb/interrogate_request.h— the C-level entry points by which a generated module registers its interrogate database at import time.dtool/src/interrogatedb/py_panda.h(+py_panda.I,py_compat.h) — the CPython wrapper layer (below).
How it works (build flow). During the build (driven by makepanda or CMake), interrogate is run over each library’s headers with CPPPARSER defined, so it sees __published/__extension etc. It records every PUBLISHED: class, method, property, and enum into an interrogate database and emits a generated *_igate.cxx of CPython glue plus a per-module init. Those generated files are compiled and linked into the Python extension modules (panda3d.core, etc.). At import time the module registers its database (interrogate_request.h) and builds the Python type objects. The C++ compiler proper never sees __published — PUBLISHED is just public to it — so the same headers serve both the parse pass and the real compile.
The wrapper structs. Every Panda object exposed to Python is a CPython object whose layout is Dtool_PyInstDef (py_panda.h):
struct Dtool_PyInstDef {
PyObject_HEAD
struct Dtool_PyTypedObject *_My_Type; // points to the class wrapper
void *_ptr_to_object; // the actual C++ object
unsigned short _signature; // == PY_PANDA_SIGNATURE; marks "this is a Panda object"
bool _memory_rules; // do we own/unref the pointer?
bool _is_const;
};
and each wrapped C++ class has one Dtool_PyTypedObject:
struct Dtool_PyTypedObject {
PyTypeObject _PyType; // a real Python type
TypeHandle _type; // ties it back to concept 1's registry
ModuleClassInitFunction _Dtool_ModuleClassInit;
UpcastFunction _Dtool_UpcastInterface; // up-cast in the C++ hierarchy
WrapFunction _Dtool_WrapInterface; // wrap a C++ ptr as the right Py subclass
CoerceFunction _Dtool_ConstCoerce, _Dtool_Coerce;
};
The macro DtoolInstance_Check(obj) verifies _signature == PY_PANDA_SIGNATURE; DtoolInstance_VOID_PTR(obj) recovers the C++ pointer; DtoolInstance_UPCAST walks the hierarchy. Note the explicit tie-in to the other concepts: _type is a TypeHandle (concept 1, used to wrap a returned base pointer as its true Python subclass), and _memory_rules is how the wrapper participates in ReferenceCount (concept 2) so Python’s refcount and Panda’s refcount stay in sync.
The dual camelCase / snake_case API. Panda’s C++ uses snake_case (set_pos, get_num_children). Historically the Python API was camelCase (setPos). Interrogate is what generates both names for each published method (and getNumChildren/get_num_children both resolve to the same wrapper), which is why old tutorials use nodePath.setPos(...) while the C++ and modern Python both use set_pos. That dual binding is an interrogate feature, not duplicated source.
Why it’s built this way. Hand-writing and maintaining bindings for thousands of methods across a moving API is infeasible; generating them from the headers means the Python API tracks the C++ API automatically, and only an access specifier (PUBLISHED:) decides what’s exposed. Routing wrapping through TypeHandle lets a function declared to return a base-class pointer hand Python back the most-derived wrapper type, and routing ownership through _memory_rules + ReferenceCount lets a single object be co-owned by C++ and Python safely.
Where to look / gotchas. To expose a new method to Python you put it under a PUBLISHED: specifier — that’s usually all it takes. Methods that need a Python-only signature (taking/returning PyObject *) are marked PY_EXTENSION/EXTENSION and implemented in a separate *_ext.cxx (e.g. PandaNode::set_python_tag in pandaNode.h) so the core class stays Python-free. Because the parser tool lives in panda3d-interrogate, changes to binding generation behavior (as opposed to runtime glue) are made in that repo, not here. See the dtool subsystem page for the library layout and the direct-python-framework page for how the generated panda3d modules are consumed.
How the five fit together
These concepts compose, and you’ll see them stacked in almost every header:
TypedReferenceCount= TypedObject (concept 1) + ReferenceCount (concept 2) — the workhorse base class.TypedWritable(concept 3) is aTypedObject; the.bamfactory reconstructs objects keyed by their TypeHandle (concept 1).CycleData(concept 4) becomes aNodeReferenceCount(concept 2) when pipelining is on, and itswrite_datagram/fillinplug straight into the bam system (concept 3).PUBLISHED:(concept 5) decorates classes that are usuallyTypedReferenceCounts, and interrogate’sDtool_PyTypedObjectcarries a TypeHandle (concept 1) and obeys reference counting (concept 2) via_memory_rules.
Recognize these five and the per-cluster chapters become a matter of which TypedWritables exist, what their CData holds, and which methods are PUBLISHED — the mechanics are always the same.