Monograph · Tree · ← Module hub · Components
scene
Components
Mesh, Material, Light, Transform, factory packs.
The type map is the whole design
An `Actor` stores its components twice over: a `std::vector<std::shared_ptr<Component>>` that owns them in insertion order, and an `unordered_map<std::type_index, shared_ptr<Component>>` that answers every lookup. Nothing walks the vector on a hot path — the GBuffer pass, the RT builder and both light uploads all begin with a hashed `getComponent<T>()`. The map is keyed on `typeid(T)`: the *static* template argument at the call site, not the object's dynamic type.
componentsByType[std::type_index(typeid(T))] = component;ohao/scene/actor/actor.hpp:93Two consequences follow, both sharp. There is exactly one component per type per actor: `addComponent<MeshComponent>()` twice appends both to the vector but leaves only the second in the map, and a later `removeComponent<MeshComponent>()` unlinks that second one — the first stays alive, owned, and invisible to every renderer. And subclassing would not survive the map: a component added as a derived type keys under *that* `type_index`, so `getComponent<MeshComponent>()` returns null and the actor silently never draws. That hazard is latent rather than live only because the hierarchy is flat — transform, mesh, material, light and physics all derive straight from `Component`, and nothing in the tree derives from them. The free helpers next to `Component` bake the same assumption in: `componentCast<T>` is a `typeid` equality test, not a `dynamic_cast`.
return component != nullptr && typeid(*component) == typeid(T);ohao/scene/component/component.hpp:64The rejected alternative is registering each component under every base in its hierarchy, or scanning the vector with `dynamic_cast`. Either makes `getComponent<MeshComponent>()` polymorphic at the cost of a linear scan plus RTTI walk per actor per pass — and the deferred path already pays three `getComponent` calls per actor per pass (mesh, transform, material). OHAO buys the O(1) lookup and pays with a rule you have to know.
Transform is added for you, and its world matrix is lazy
`Actor`'s constructor adds a `TransformComponent` before anything else, so every actor is born with one.
addComponent<TransformComponent>();ohao/scene/actor/actor.cpp:22That is a convention, not an enforcement. `removeComponent<T>()` is public and carries no exemption for the transform, and `removeAllComponents()` empties the owning vector and the type map outright; after either, `getTransform()` returns null. Nothing in the tree does that today, but the engine does not trust the invariant either — `Actor::getWorldMatrix()` and the GBuffer pass both null-check the transform before dereferencing it, while `uploadLightBuffer` does not.
componentsByType.clear();ohao/scene/actor/actor.cpp:214The component stores local TRS plus two dirty flags. `setDirty()` marks itself and recurses into every child transform, so moving a parent invalidates a whole subtree in one call. `getWorldMatrix()` is declared `const` and then `const_cast`s itself to rebuild on demand — a read that mutates, which is why transforms must not be sampled from two threads at once.
const_cast<TransformComponent*>(this)->updateWorldMatrix();ohao/scene/component/transform_component.cpp:97Two details a cleanup pass would get wrong. `clearDirty()` clears only the local flag and deliberately leaves `worldDirty` set, with the reason in the source — an external "I consumed this" acknowledgement must not be able to freeze a stale world matrix.
// Note: We don't clear worldDirty here as it might still need updatingohao/scene/component/transform_component.cpp:212And `getLocalMatrix()` has no dirty check at all: `updateLocalMatrix()` is private and reached only through `updateWorldMatrix()`, so the local matrix stays identity until somebody asks for the world matrix. It has no callers outside the class, which is the only reason this is latent rather than a bug. Nothing in `setParent()` rejects making a transform its own ancestor either, and `setDirty()`'s recursion has no visit guard.
Registering a mesh with the scene, twice
`Scene` keeps two component registries, one of raw `MeshComponent*` and one of raw `PhysicsComponent*`, both filled from `Actor::onComponentAdded` and both dedup-guarded by a linear `std::find`.
std::vector<PhysicsComponent*> physicsComponents;ohao/scene/scene.hpp:155Mesh is the one that arrives twice. Its own `initialize()` walks owner → scene and registers:
scene->onMeshComponentAdded(this);ohao/scene/component/mesh_component.cpp:63while `Actor::onComponentAdded` separately `dynamic_pointer_cast`s the fresh component and registers it again:
scene->onMeshComponentAdded(meshComponent.get());ohao/scene/actor/actor.cpp:223Both fire when a mesh joins an actor that is already in a scene, because `addComponent` calls `initialize()` on an active actor and then invokes the hook. The `std::find` inside `Scene::onMeshComponentAdded` is what stops the registry growing a duplicate per mesh — though nothing downstream would notice if it did. The vector is never iterated — its accessor `getMeshComponents()` has no callers and `Scene::destroy` only clears it; removal goes through `std::erase`, which would take both copies anyway; and the sole effect of a redundant insert is re-raising the already-set `needsBufferUpdate`.
if (std::find(meshComponents.begin(), meshComponents.end(), component) == meshComponents.end()) {ohao/scene/scene.cpp:155The physics registry has the same guard around more work. `Scene::onPhysicsComponentAdded` also hands the component the physics world and re-runs `initialize()`, and those two calls sit *outside* the guard — which is what makes physics work at all. The `initialize()` that `addComponent` fires finds `m_physicsWorld` still null and creates nothing; this second one is where the Jolt body is built.
component->setPhysicsWorld(physicsWorld.get());ohao/scene/scene.cpp:183Swapping the model on an existing component takes a third route: `setModel` raises the scene's buffer-dirty flag directly, because a new model changes the packed vertex and index totals the renderer allocated for.
scene->onMeshComponentChanged(this);ohao/scene/component/mesh_component.cpp:26The buffer offsets on MeshComponent are not the ones the GPU uses
`MeshComponent` carries a `MeshBufferInfo` — vertex offset, index offset, index count, vertex count — and exposes setters for the renderer to fill in. No renderer does. `VulkanRenderer` builds its own map keyed by actor ID while packing the combined buffers:
m_meshBufferMap[actor->getID()] = MeshBufferInfo{ohao/gpu/vulkan/scene_upload.cpp:68and both the deferred draw loop and `renderSceneObjects` look up *that* map. The component's copy is written in exactly two places: `setModel`, which zeroes both offsets and refills the two counts from the model, and `setBufferInfo`, whose only caller is `tests/engine/engine_tests.cpp`. `setBufferOffsets` — the setter whose signature reads like the renderer's entry point — has no callers anywhere, and neither do the three narrow accessors beside it (`getVertexOffset`, `getIndexOffset`, `getBufferIndexCount`).
void MeshComponent::setBufferOffsets(std::uint32_t vOffset, std::uint32_t iOffset, std::uint32_t iCount) {ohao/scene/component/mesh_component.cpp:39The same holds one level up: `Scene` maintains a `meshComponents` vector on every add and remove, but its `getMeshComponents()` accessor has no callers in the tree — the renderers iterate `getAllActors()` and hash-lookup per actor instead. Neither store is harmful; neither is a source of truth. Reading the component's offsets to decide what to draw gives you zeros.
LightComponent is a shader ABI wearing a class
`LightType`'s enumerator values are not an internal detail. The RT upload casts the enum straight into the GPU struct's type channel, and `GPULight`'s header comment restates the same numbering as the shader contract.
Sphere = 0, // point light with radius (soft shadows)ohao/scene/component/light_component.hpp:11gl.positionAndType = glm::vec4(pos, static_cast<float>(lc->getLightType()));ohao/gpu/vulkan/light_upload.cpp:163Renumbering the enum therefore re-lights every path-traced frame with no compiler complaint. The deferred pipeline does *not* share that numbering: `deferred_lighting.frag` treats type 0 as directional and type 1 as point, the reverse of `LightType`, so `VulkanRenderer::updateLightBuffer` renumbers on the way out.
L = normalize(-light.direction.xyz);shaders/core/deferred_lighting.frag:231It renumbers twice, because the frame-indexed overload is a copy of the unindexed one — and the copy has already drifted. Both end with a fallback that inserts a default light when the scene has none, and the two fallbacks disagree: the original emits a warm tint at intensity `glm::pi<float>()`, with a comment naming that as compensation for the energy-conserving divide-by-pi in the diffuse BRDF; the copy emits pure white at intensity 1.
defaultLight.color = glm::vec4(1.0f, 0.98f, 0.95f, glm::pi<float>());ohao/gpu/vulkan/buffer_setup.cpp:319defaultLight.color = glm::vec4(1.0f, 1.0f, 1.0f, 1.0f);ohao/gpu/vulkan/buffer_setup.cpp:407Both overloads are live — `renderDeferred` and `renderLegacy` take the unindexed one, `renderMultiFrame` the frame-indexed one — so an empty scene gets a different key light, in tint and by a factor of pi in intensity, depending on which entry point ran.
The scalar fields are overloaded per type as well. `dirAndParam.w` is `LightComponent::radius` for every type *except* spot, which overrides it with the inner cone angle and displaces the outer angle into `extra.w`; area rectangles put their two edge vectors and a CPU-precomputed `length(cross(e1, e2))` into `extra`/`extra2`.
float dirParam = lc->getRadius();ohao/gpu/vulkan/light_upload.cpp:165Angles travel in degrees; both consumers convert (`cos(radians(...))` in the raygen, `glm::cos(glm::radians(...))` in the deferred packer). The same component set also meets two different ceilings: the deferred UBO is a fixed array of 8, while the RT storage buffer is sized from the vector at upload time.
constexpr uint32_t MAX_LIGHTS = 8;ohao/gpu/vulkan/renderer.hpp:85One honest gap. Both uploads read the actor's *local* position rather than its world position, while the mesh path uses the full world matrix — parent a light under a moving actor and the mesh follows while the light stays behind. The deferred variable is even named `worldPos`.
auto pos = actor->getTransform()->getPosition();ohao/gpu/vulkan/light_upload.cpp:162glm::mat4 modelMatrix = transformComp ? transformComp->getWorldMatrix() : glm::mat4(1.0f);ohao/render/deferred/gbuffer_pass.cpp:217AreaRect is half a light
`LightType::AreaRect` has the geometry and not the radiometry. The path tracer's NEE block samples the rectangle uniformly in the edge basis — `lightCenter + edge1 * u + edge2 * v` with `u, v` in the unit square, so the uploaded position is really a *corner* — takes the normal from `cross(edge1, edge2)`, and weights by cosine times the uploaded area over squared distance. All of that is right.
// ==== Analytic direct NEE at bounce 0 ====shaders/rt/pt_raygen.rgen:304The radiance that weight multiplies is not. `Le` is computed once, above the type branch, as intensity divided by the area of a *sphere* of radius `dirAndParam.w` — and for an `AreaRect`, `dirAndParam.w` is `LightComponent::radius`, a field the area path never touches. So a rectangle light is divided by `4·pi·r²` of an unrelated radius (0.5 unless someone calls `setRadius`, giving a divisor of pi) no matter what its edges are: `setAreaEdges` changes the emitter's shape and its geometric falloff and leaves its brightness alone. That block is pasted three times in `pt_raygen.rgen` — primary hit plus both bounce loops, differing only in the hit-point variable — so the same divisor applies at every depth.
The deferred pipeline does not represent area lights at all. Its type remap is a two-branch ternary: `Sphere` → 1, `Directional` → 0, *everything else* → 2. `AreaRect` (3) therefore lands in the UBO as a spot light and is shaded through `calculateSpotAttenuation` against a cone it does not have, using whatever `innerConeAngle`/`outerConeAngle` the constructor happened to leave behind.
attenuation *= calculateSpotAttenuation(L, normalize(light.direction.xyz),shaders/core/deferred_lighting.frag:240`PrimitiveType` also stops at `SpotLight`, so an area light exists at all only if you call `setLightType` by hand. Exactly one caller in the tree does: `examples/turntable.cpp`, in its Cornell mode.
The defaults in the header are not the defaults you get
`LightComponent` declares default member initialisers and then re-initialises a subset of them in its constructor's member-init list. The constructor wins: a fresh `LightComponent` has intensity 1.0 and range 10.0, not the 10.0 and 50.0 the header advertises — while `radius = 0.5f` and the area edge vectors, absent from the init list, do come from the header.
float lightIntensity = 10.0f;ohao/scene/component/light_component.hpp:84, lightIntensity(1.0f)ohao/scene/component/light_component.cpp:11The intensity half of that divergence is currently unobservable: `ComponentFactory::setupLightComponent` always calls `setIntensity` from its `ComponentSet`, and every hand-constructed `LightComponent` in the tree — the three examples, `SceneFramer`, `inverse/scene_builder.hpp` — sets intensity explicitly too. Nothing ever reads either default.
The range half is the one that can bite, because no hand-built light calls `setRange`. Those lights carry the constructor's 10.0, not the header's 50.0, and the deferred attenuation windows on that value with a fourth-power cutoff — so a hand-authored point light goes fully black past ten units in the deferred pipeline while the RT path, which never uploads `range` at all, keeps lighting.
float windowing = clamp(1.0 - pow(distance / range, 4.0), 0.0, 1.0);shaders/core/deferred_lighting.frag:96Packs, factories, and the API that never shipped
`ComponentPack` is a variadic fold over `addComponent`, guarded by `hasComponent<T>()` so that applying two overlapping packs cannot double-add and clobber the type map — the aliasing failure described at the top of this page.
(addComponent<Components>(actor), ...);ohao/scene/component/component_pack.hpp:32`ComponentFactory` maps a `PrimitiveType` to a `ComponentSet` in a switch, then configures each component from it. Lights get `LightOnlyPack` and are deliberately mesh-less and material-less; every other type — `Cube`, `Sphere`, `Platform`, `Cylinder`, `Cone` and `Empty` — falls into the else branch, which applies `StandardObjectPack` and then calls `setupPhysicsComponent` unconditionally. `config.needsMesh` and `config.needsMaterial` gate their setup calls two lines later; `config.needsPhysics` gates nothing. Its only reader anywhere is `ComponentManager::validateComponentSetup`, which has no callers.
LightOnlyPack::applyTo(actor);ohao/scene/component/component_factory.cpp:51setupPhysicsComponent(physicsComponent.get(), config, type);ohao/scene/component/component_factory.cpp:64For the five solids that is what the switch asked for anyway. For `Empty` it is the opposite of what the switch asked for: `getComponentSet` leaves every flag false under the comment "Empty object - only has transform", and the actor still ends up with a mesh component, a material component, a physics component, and — through the `default:` arm of `setupPhysicsShape` — a 1 kg dynamic box collider of half-extent 0.5. Latent rather than live: no caller in the tree passes `Empty`, so the `default:` arm is reached only from that enumerator.
// Empty object - only has transformohao/scene/component/component_factory.cpp:165The designated-initialiser helpers on `ComponentSet` — `visualOnly()`, `physicsObject()`, `staticCollider()` — have no callers; `getComponentSet()` hand-builds equivalent field sets per case instead. The same is true of `LightComponent`'s `applyDirectionalSun` and `applySphereKey` presets. The sun preset agrees with the factory's `DirectionalLight` case on intensity 3.0 and differs only in tint and default direction; the sphere preset defaults to intensity 10.0 against the factory's 1.0 for `PointLight`. Two descriptions of "the default sphere light" that disagree by a factor of ten is the drift an uncalled API accumulates.
[[nodiscard]] static constexpr ComponentSet staticCollider() {ohao/scene/component/component_factory.hpp:77float intensity = 10.0f,ohao/scene/component/light_component.hpp:73config.intensity = 1.0f;ohao/scene/component/component_factory.cpp:139A component is visible only as the exact type it was added as, and only one instance of that type survives per actor. Every renderer entry point is a `getComponent<T>()` on that map — what the map cannot see does not exist.
Contracts
- `addComponent<T>()` on a type the actor already has silently orphans the previous instance: it stays in the owning vector, drops out of the type map, and no renderer finds it again.
- The constructor's `TransformComponent` is a convention, not a protected slot: `removeAllComponents()` and `removeComponent<TransformComponent>()` both clear it, after which `uploadLightBuffer` dereferences a null transform.
- Transforms must be sampled from one thread: `getWorldMatrix()` is `const` but `const_cast`s to rebuild lazily.
- `getLocalMatrix()` is stale unless `getWorldMatrix()` was called since the last `setDirty()`; only the world accessor triggers a rebuild.
- `LightType`'s numeric values are the path tracer's wire format — reordering the enum changes shading with no build error. The deferred UBO uses a different numbering and remaps at upload, in two hand-maintained copies whose zero-light fallbacks have already diverged by a factor of pi.
- An `AreaRect` light's radiance ignores its rectangle: the raygen normalises every non-spot light by the sphere area implied by `LightComponent::radius`. Resizing the edges changes falloff, not brightness.
- `AreaRect` has no deferred branch at all — the type remap collapses it onto the spot path, which then applies a cone attenuation from angles nobody set.
- Lights are positioned from the actor's local transform, meshes from the world matrix, so parented lights are placed wrong in both pipelines.
- `ComponentFactory` calls `setupPhysicsComponent` on every non-light primitive without consulting `config.needsPhysics` — the one flag in `ComponentSet` that gates nothing — so `PrimitiveType::Empty` would receive a dynamic box collider its own `ComponentSet` says it should not have.
- `Component::componentID` comes from a plain non-atomic static counter, unlike `SceneObject`'s `std::atomic` object ID, so components must be constructed on one thread. Nothing reads `componentID` or `Component::guid` today.
Source files
ohao/scene/actor/actor.hppohao/scene/component/component.hppohao/scene/actor/actor.cppohao/scene/component/transform_component.cppohao/scene/scene.hppohao/scene/component/mesh_component.cppohao/scene/scene.cppohao/gpu/vulkan/scene_upload.cppohao/scene/component/light_component.hppohao/gpu/vulkan/light_upload.cppshaders/core/deferred_lighting.fragohao/gpu/vulkan/buffer_setup.cppohao/gpu/vulkan/renderer.hppohao/render/deferred/gbuffer_pass.cppshaders/rt/pt_raygen.rgenohao/scene/component/component_factory.hppohao/scene/component/light_component.cppohao/scene/component/component_pack.hppohao/scene/component/component_factory.cppohao/scene/component/mesh_component.hppohao/scene/component/material_component.hppohao/scene/component/transform_component.hppParent hub for the full pipeline narrative; this page is the file-level design unit. Sitemap · hover glossary terms anywhere.