Loading Models
Building a Scene built everything from primitives. Real projects load models, and this page is about the workflow around that: getting a GLB onto the page, finding out what is actually inside it, and then adjusting it — without opening a 3D tool.
The model used throughout is the Porsche 911 Carrera 4S by Lionsharp Studios (CC BY 4.0), the same asset behind the Car Configurator example. It is a normal Sketchfab download, warts and all, which turns out to be the point.
What to Export
Both glTF flavors work: .gltf (JSON, with textures and geometry alongside it) and .glb (everything in one binary file). Prefer .glb for the web — one request, no broken relative paths.
Two things are worth caring about at export time, because they become the vocabulary you use later:
- Node names.
<pc-node>finds parts of the model by name. If your exporter emitsObject_12, that is what you will be typing. - Material names. They are how you target materials for replacement, and they often survive meaningful even when node names do not.
Neither is fatal if it goes wrong — hierarchy() below tells you what you actually got — but a few minutes spent naming things in Blender saves more than that later.
Loading and Instantiating
Loading takes two tags. <pc-asset> declares the file, and <pc-model> instantiates it into the scene:
<pc-app>
<pc-asset id="car" type="container" src="assets/porsche-911-carrera-4s.glb"></pc-asset>
<pc-scene>
<pc-entity name="camera" position="3.4 1 3.8" rotation="-10 42 0">
<pc-camera clear-color="#dfe4ea"></pc-camera>
</pc-entity>
<pc-entity name="light" rotation="52 30 0">
<pc-light type="directional" cast-shadows></pc-light>
</pc-entity>
<pc-model asset="car"></pc-model>
</pc-scene>
</pc-app>

The type="container" matters: a GLB is a container asset, holding meshes, materials, textures, skins and animations together, and <pc-model> instantiates its hierarchy from that container. Point it at an asset of another type and the load fails at instantiation rather than with a tidy message, so it is worth getting right. <pc-model> behaves like a <pc-entity> otherwise, so it takes position, rotation and scale, and it can be nested inside another entity.
A model becomes ready once its content is in the scene, and it fires load. A failed load also settles readiness — with a null contentEntity — and fires an error event, so that is the one to listen for if the file might not arrive:
document.querySelector('pc-model').addEventListener('error', (event) => {
console.warn(`the model did not load: ${event.message}`);
});
Nothing normalizes a model's pivot or scale. This car's origin sits at the middle of the body, so its wheels are below y=0 and it will sink through a ground plane placed at the origin. Set position on the <pc-model> to lift it, or move your ground — but expect to do this per asset rather than assume a convention.
Compressed Meshes
Most models from asset sites are Draco-compressed, and this one is. Draco needs a WebAssembly decoder, declared with <pc-wasm> as a child of <pc-app>:
<pc-wasm name="DracoDecoderModule"
glue="modules/draco/draco.wasm.js"
wasm="modules/draco/draco.wasm.wasm"
fallback="modules/draco/draco.js"></pc-wasm>
The three files are a Draco decoder build — the glue script, the .wasm binary, and a pure-JavaScript fallback for browsers without WebAssembly. They are not part of the engine's npm package, so you serve them yourself: take them from a Draco release or copy the set vendored in the Web Components examples, then point the attributes at wherever you put them.
<pc-app> waits for every module declared beneath it before it starts, so by the time your scene runs the decoder is in place. Without it, a Draco-compressed model fails to load and the console says so.
The same mechanism supplies Basis for transcoding compressed textures, which models using KHR_texture_basisu need.
Seeing What You Loaded
Here is the awkward truth about model files: the names in your 3D tool are frequently not the names that reach the engine. Exporters rename things, and the engine's parser then synthesizes names for unnamed nodes and suffixes identically named siblings apart as it builds the hierarchy.
So do not guess. <pc-model> has a hierarchy() method that reports the tree as it actually exists, and printing it is one line:
import { whenReady } from '@playcanvas/web-components';
const model = await whenReady('pc-model');
console.log(String(model.hierarchy()));
Sketchfab_model
└─ Root
├─ window_rear
│ └─ window_rear_0 (render) {window}
├─ windshield
│ ├─ windshield_0 (render) {window}
│ └─ windshield_1 (render) {plastic}
├─ Plane.002
│ └─ Plane.002_0 (render) {paint}
├─ boot
│ └─ boot_0 (render) {full_black}
├─ underbody
│ └─ underbody_0 (render) {full_black}
├─ Cylinder.000
│ ├─ Cylinder.000_0 (render) {silver}
│ ├─ Cylinder.000_1 (render) {plastic}
│ ├─ Cylinder.000_2 (render) {rubber}
│ └─ Cylinder.000_3 (render) {Material.001}
├─ Plane
│ └─ Plane_0 (render) {Material}
⋮
├─ bumper_front.004
│ ├─ bumper_front.004_0 (render) {silver}
│ ├─ bumper_front.004_1 (render) {lights}
│ └─ bumper_front.004_2 (render) {plastic}
⋮
├─ boot.001
│ └─ boot.001_0 (render) {paint}
⋮
└─ boot.011
├─ boot.011_0 (render) {coat}
└─ boot.011_01 (render) {coat}
That is abridged at the ⋮ — Root really has 32 children — but otherwise it is verbatim, and the nodes the recipes below use are all in it.
Each line is a node: its name, (render) and any other components in parentheses, and the materials of a render component in braces. Read the real output above and several things become obvious that no amount of guessing would have told you:
- The node names are meaningless.
boot.011,Plane.002,Cylinder.000— this is what the export produced. The material names, though, are meaningful:paint,glass,rubber,silver,window,lights. On this model, materials are the better handle, and that is common. - Render components live on the leaves.
windshielditself has no geometry; its childwindshield_0does. A<pc-node>that wants to change a material has to bind the node the(render)marker is on, not the friendly-looking parent. Cylinder.000is a wheel — four child nodes covering rim, plastic, tyre and brake.boot.011_01was renamed by the engine. The GLB has two children both calledboot.011_0; identically named siblings get suffixed apart as the hierarchy is built.
hierarchy() returns plain data — name, path, index, components, materials, children — so you can also search it rather than read it. The full field reference is in Inspecting the Hierarchy.
// Every node that has geometry painted with the 'paint' material
const painted = [];
const walk = (node) => {
if (node.materials.some(m => m.name === 'paint')) painted.push(node.name);
node.children.forEach(walk);
};
walk(model.hierarchy());
console.log(painted); // ['Plane.002_0', 'Plane.003_0', 'Plane.004_0', ...]
Adjusting What You Loaded
<pc-node> binds to a node inside the loaded hierarchy and declares overrides against it. Nest one inside <pc-model> for each part you want to change. It is a lookup, never a rename, and an attribute you leave off keeps whatever the model was authored with.
Hide a Part
Sketchfab models routinely ship with a baked shadow plane, and this one also has the artist's watermark baked into it. Both are one attribute away from gone:
<pc-model asset="car">
<pc-node name="Plane" enabled="false"></pc-node>
</pc-model>

enabled="false" disables the node and everything under it, which is the declarative way to drop content you did not want without editing the file.
Re-pose a Part
position, rotation and scale on a <pc-node> replace the authored transform rather than adding to it:
<pc-model asset="car">
<!-- Lift one painted body panel clear of the car, leaving its rotation and scale as exported -->
<pc-node name="boot.001_0" position="0 0.4 0"></pc-node>
</pc-model>
Which node is the part you had in mind is a question for hierarchy() — on this export, boot.001_0 is simply one of the nodes carrying the paint material.
Because these are replacements, removing the attribute at runtime — or assigning null to the matching JavaScript property — puts the authored value back, which makes them convenient to flip between two states.
Reskin a Part
material-overrides maps selectors to <pc-material> ids. Give it a name: selector and it replaces every mesh instance on that node whose material carries that name:
<pc-app>
<pc-material id="candy-red" name="Candy Red" diffuse="#c8102e" metalness="1" roughness="0.25"></pc-material>
<pc-scene>
<pc-model asset="car">
<pc-node name="Plane" enabled="false"></pc-node>
<pc-node name="Plane.002_0" material-overrides='{"name:paint": "candy-red"}'></pc-node>
<pc-node name="boot.001_0" material-overrides='{"name:paint": "candy-red"}'></pc-node>
</pc-model>
</pc-scene>
</pc-app>

Note the shape of that: one <pc-node> per node that carries the paint. material-overrides applies to the render component of the node it is on, and on this model the paint material is spread across seven different nodes — so a full respray is seven bindings. That is fine when you know the list (hierarchy() gave it to you above), and the ids let several nodes share one material declaration.
If you would rather sweep a whole model in one go, or cross-fade between finishes, that is a job for a script — which is what the Car Configurator example does. The declarative route is for the fixed set of parts you know up front.
Set name on your replacement <pc-material> if you want to recognize it later: it is the label hierarchy() reports, and an unnamed material reads as Untitled there. The full selector grammar, including index: for multi-material meshes and how invalid rules are reported, is in Overriding Materials.
Attach Something to a Part
A <pc-node> can have <pc-entity> children, which are created and parented under the bound node. That turns any node into an attachment point, inheriting its transform:
<pc-model asset="car">
<pc-node name="bumper_front.004">
<pc-entity position="0 0 0.3">
<pc-light type="spot" color="#fff6e0" intensity="12" outer-cone-angle="34"></pc-light>
</pc-entity>
</pc-node>
</pc-model>
The child entity's transform is local to the node, so it follows the part if the part moves. Remember that lights shine along their entity's negative Y axis, and that a node deep inside a glTF hierarchy usually carries inherited rotations — so expect to tune the child's rotation against what you see rather than reason it out.
Give a Part a Component
A <pc-node> takes the same component tags a <pc-entity> does, adding that component to the bound node. The common case is physics — a mesh collider takes its shape from the node's own render component, so a rigid body plus a collider makes exported geometry solid:
<pc-model asset="car">
<pc-node name="underbody_0">
<pc-rigid-body type="static"></pc-rigid-body>
<pc-collision type="mesh"></pc-collision>
</pc-node>
</pc-model>
Physics needs the Ammo module declared the same way Draco was — see <pc-wasm>.
Make a Part Interactive
Binding a node is what makes it a pick target, so pointer events are available on any <pc-node> — which is how one part of a model becomes clickable while the rest is inert:
<pc-model asset="car">
<pc-node name="boot.001_0" onpointerdown="this.setAttribute('position', '0 0.4 0')"></pc-node>
</pc-model>
In an inline handler this is the <pc-node> element, and going through setAttribute keeps the markup and the scene in agreement. The equivalent JavaScript properties are typed — position and rotation take a Vec3, not a string — so prefer attributes from inline handlers and properties from real script files.
The events and their inline attribute forms are listed in the <pc-node> reference.
Animation
A container's animations are played by a <pc-anim> nested inside the model. Empty, it assigns every animation the container holds — each named after its track — and starts the first one, which covers "play what the file came with" in a single tag:
<pc-asset id="robot" type="container" src="assets/walking-robot.glb"></pc-asset>
<pc-scene>
<pc-model name="robot" asset="robot">
<pc-anim></pc-anim>
</pc-model>
</pc-scene>

No wrapper entity is involved. <pc-model> is an entity host in its own right, so a component placed inside it attaches to the model — the same way it would attach to a <pc-entity>. That also means the model can carry its own name, transform and pointer handlers, and can host child entities alongside its content.
To see which animations came through the export, ask the component:
const anim = await whenReady('pc-anim');
console.log(anim.clips); // ['Walk', 'Idle', 'Wave']
Naming the clips yourself — rather than living with whatever the exporter called them — means declaring them, one <pc-anim-clip> each. That is also where per-clip speed and looping live, and how clips from other files get mixed in:
<pc-model name="robot" asset="robot">
<pc-anim clip="idle" transition-time="0.3">
<pc-anim-clip name="idle"></pc-anim-clip>
<pc-anim-clip name="walk" speed="1.2"></pc-anim-clip>
<pc-anim-clip name="wave" asset="wave-glb" loop="false"></pc-anim-clip>
</pc-anim>
</pc-model>
Switching clips is then a matter of setting clip, which cross-fades over transition-time:
document.querySelector('pc-anim').setAttribute('clip', 'walk');
Tracks bind to nodes by name, which is why a clip from a separate file only animates a model whose node names match it — and why a plain hierarchy of rigid parts animates just as well as a skinned skeleton. See <pc-anim> for the full picture, including cross-fades, pausing and the fact that the engine reports no clip completion.
Troubleshooting
Nothing appears, and the console mentions Draco or Basis. The model is compressed and the decoder module is missing — see Compressed Meshes.
Nothing appears, and there is no warning at all. Check the model's scale and origin. A model exported in centimetres arrives a hundred times too big, and one whose origin is far from its geometry can sit entirely outside the camera's view.
A <pc-node> warns that the name is ambiguous. Two or more nodes share that name, so the element refuses to guess. The warning lists the candidates; pick one with index.
A <pc-node> warns that the name matched nothing. The warning includes the closest name it did find, which is usually enough to spot the typo. If not, print hierarchy() — the name you want may have been renamed on export.
material-overrides says the node has no authored render component. You bound a grouping node rather than the leaf that holds the geometry. Look for the (render) marker in the hierarchy() output.
A material name appears as Untitled or defaultGlbMaterial. Those are engine defaults for an unnamed glTF material and for a primitive exported with no material at all. Neither is a unique handle, so select those by index: instead.
The model loads but nothing animates. A model plays nothing until a <pc-anim> inside it asks it to, as Animation describes. If one is there, check anim.clips: an empty list with a warning about the model having no animations means they did not survive the export.
A clip from a separate file animates nothing. Its tracks bind by node name, so the clip and the model have to agree on those names. Print the model's hierarchy() and compare.
Next Steps
<pc-model>and<pc-node>— the full attribute and method reference for both tags.<pc-anim>and<pc-anim-clip>— clip libraries, cross-fades and playback control.- Adding Behavior with Scripts — for logic that outgrows markup, such as sweeping materials across a whole model.
- Programmatic Access — reaching the engine objects behind these elements.
- Examples — see GLB Loader, GLB Animation, Robot Arm and Car Configurator.