render-scene3d
Import from @pierre/ecs/modules/render-scene3d.
Keeps a scene graph in step with the world: one scene object per selected
entity, created the first frame the entity is selected, updated every frame,
and removed from the graph once the entity stops matching. It works with any
scene graph that can add and remove an object — a three.js Scene is passed in
directly.
It manages which objects exist. What each object looks like, the camera, the canvas and the GPU resources stay with your code.
interface SceneGraph<TObject> { add: (object: TObject) => void; remove: (object: TObject) => void;}
interface Scene3DRenderContext<TObject> { graph: SceneGraph<TObject>; world: EcsWorld;}
type Scene3DEntry<TRow extends unknown[]> = [EntityId, ...TRow];
interface Scene3DRendererOptions<TObject, TRow extends unknown[]> { select: (world: EcsWorld) => Iterable<Scene3DEntry<TRow>>; create: (entry: Scene3DEntry<TRow>, world: EcsWorld) => TObject; sync?: (object: TObject, entry: Scene3DEntry<TRow>, world: EcsWorld) => void; remove?: (object: TObject, entityId: EntityId, world: EcsWorld) => void;}
class Scene3DRenderer<TObject, TRow extends unknown[] = unknown[]> implements Renderer<Scene3DRenderContext<TObject>> { constructor(options: Scene3DRendererOptions<TObject, TRow>); render(ctx: Scene3DRenderContext<TObject>): void; get(entityId: EntityId): TObject | undefined; dispose(graph: SceneGraph<TObject>): void;}selectbuilds the selection this pass draws — usually aworld.query(...)narrowed with.withTag(...)and.without(...). It is called once per world, and the query it returns is iterated every frame, so return something that reflects the world on each iteration (a query does; an array snapshot does not). Its component columns become the entrycreateandsyncreceive, typed.createbuilds the object for an entity the first frame it is selected.syncruns every frame the entity is selected, including the frame it was created: copy position, scale, material, visibility.remove(optional) runs once per object, right after it is removed from the graph: when its entity leaves the selection, and for every held object ondispose. Recycle a pooled object or dispose its geometry and material here.worldis the one given to the most recentrender.getreturns the object held for an entity, for code that adjusts it after the pass.disposeremoves every object this pass created from the graph, callsremovefor each, and forgets them. Without aremoveoption it does not dispose geometries, materials or textures — those were built by yourcreateand stay yours to release.
One renderer per kind of object, run in sequence like any array of renderers.
With three.js, the graph is the Scene:
import type { EcsWorld } from '@pierre/ecs';
import { Scene3DRenderer } from '@pierre/ecs/modules/render-scene3d';import { Position3DDef } from '@pierre/ecs/modules/transform-3d';
// Stand-ins for `THREE.Mesh` and `THREE.Scene`.interface Mesh { position: { set: (x: number, y: number, z: number) => void } }declare const scene: { add: (m: Mesh) => void; remove: (m: Mesh) => void };declare function makeCrateMesh(): Mesh;declare const world: EcsWorld;
const crates = new Scene3DRenderer({ select: w => w.query(Position3DDef).withTag(w.getTag({ name: 'crate' })), create: () => makeCrateMesh(), sync: (mesh, [, p]) => mesh.position.set(p.x, p.y, p.z),});
// Each frame, before drawing the scene:crates.render({ graph: scene, world });Overlapping tags
Section titled “Overlapping tags”When an entity carries two tags that each have a pass, both passes draw it. Exclude the more specific tags from the general pass so it gets one object:
const statics = new Scene3DRenderer({ select: w => w.query(Position3DDef) .withTag(w.getTag(StaticBodyTag)) .without(w.getTag(DoorTag)), create: () => new THREE.Mesh(box, wallMaterial),});Resetting the world
Section titled “Resetting the world”world.clearAll() and world.loadJSON(...) start entity ids again from the
beginning, so a new entity can arrive with the id of an old one. The renderer
cannot tell the two apart and keeps the old object. Either call
dispose(graph) after resetting the world, so the next render builds
everything fresh, or set every per-entity property (material, sprite) in sync
rather than create, so an object never depends on which entity it was built
for.
- An entity leaves a pass for any reason
selectstops yielding it: a removed tag or component, an excluded tag added, or the entity destroyed. Its object is removed from the graph that frame and passed toremove; if it comes back,createbuilds a new one. selectcan return any iterable of[id, ...values]entries, not only a query.- No cross-module source dependency. The pose components a
selectreads usually come frommodules/transform-3d, but this module never reads a component itself.
Not included (by design)
Section titled “Not included (by design)”- Drawing. It never calls the backend’s render: you draw the scene with your camera after the passes run.
- The canvas, resizing and the graphics context. Those belong to the code that owns the canvas; a pass has none.
- A camera. Position it in your own code.
- A 3D drawable component. Whether an entity becomes a box, a sprite or a
loaded model is decided by your
create. - Copying
Rotation3D/Scale3Donto objects. Set them insyncwhen your entities store them. - Draw order. Set the backend’s own property (three.js
renderOrder) insync.