通过模板复用场景
场景经常重复结构,例如生成箱子、发射投射物或添加列表行。可将子树声明到原生 HTML <template>,按需克隆。浏览器让模板内容保持惰性,克隆加入页面后,Web Components 生命周期将其转为活动引擎层级,无需专用预制体 API。
声明模板
模板包含普通 pc-* 标记:
<template id="crate-template">
<pc-entity name="crate">
<pc-render type="box" material="crate"></pc-render>
<pc-collision></pc-collision>
<pc-rigid-body type="dynamic"></pc-rigid-body>
</pc-entity>
</template>
<template> 内的内容不会初始化,不创建实体或组件,也不渲染。只有克隆加入 <pc-app> 下的 <pc-scene> 或其中实体后才生效。模板元素本身可位于文档任意位置,通常放在对应 </pc-app> 后。
用单一根节点管理实例
模板应只有一个顶层元素:自行组合子树使用 <pc-entity>,围绕模型构建预制体则使用 <pc-model>。根节点同时承担三项职责:
- 实例句柄。 保存它以配置、等待并最终移除实例。
- 生命周期所有者。 移除根节点就销毁其创建的整个引擎层级,一次操作释放实例。
- 名称解析范围。 克隆内部的裸名称实体引用先在最近的实体宿主范围内解析,单一根节点使内部引用保持在实例内。
多个顶层元素仍可渲染,但没有统一所有者,不同子树之间的引用也需要跨到文档查找。因此始终使用单一根节点。
以 <pc-node> 为根的是挂接模板,不是独立预制体。它绑定模型已有节点,克隆必须加入所属 <pc-model> 下;放到其他位置会警告、不绑定,ready() 永不完成。移除时也不同:节点属于模型,因此仅恢复原始状态,销毁克隆添加的后代。
创建实例
完整流程有六步,顺序很重要:
const template = document.getElementById('crate-template');
const scene = document.querySelector('pc-scene');
// 1. Clone the template's content
const clone = template.content.cloneNode(true);
// 2. Capture the root now - appending will empty the fragment
const crate = clone.querySelector('pc-entity');
// 3. Configure the instance while it is still disconnected
crate.setAttribute('position', '0 5 0');
// 4. Append the fragment once - the lifecycle builds the engine hierarchy
scene.appendChild(clone);
// 5. Await the specific elements you are about to use
await Promise.all([crate, crate.querySelector('pc-rigid-body')].map((el) => el.ready()));
// 6. Later: remove the root to dispose of the whole instance
crate.remove();
第二步容易出错。template.content 及其克隆都是 DocumentFragment,追加后会把子项转交新父项,自身变空。因此必须在追加前查找并保存根元素;第四步后片段内已无内容。
第三步在克隆尚未连接时配置位置、旋转和脚本属性等,作为实例启动初始状态,避免场景中出现半配置对象,也避免创建后立即重配。
此时使用 attribute,而非 JavaScript property。追加前,cloneNode 生成的是普通 HTML 元素,写入 crate.position 会创建自身属性,升级后遮蔽库定义的属性,值无法进入引擎。第四步以后才可用 property。
指针事件监听器例外:模板克隆只有追加后才升级为活动元素。在此之前通过 addEventListener 添加的监听器不会被 <pc-app> 感知,因此不会分发事件。应在第四步后添加,如示例。onclick 等内联属性则可在第三步配置。
上述限制来自 cloneNode()。若希望追加前就设置 property 和监听器,可在第一步改用 document.importNode(template.content, true),它返回已升级的元素,之后同样保存根节点并追加。
名称限定在克隆内部
模板内部引用使用裸名称:
<template id="pendulum-template">
<pc-entity name="pendulum">
<pc-entity name="anchor">
<pc-collision half-extents="0.05 0.05 0.05"></pc-collision>
<pc-rigid-body type="static"></pc-rigid-body>
</pc-entity>
<pc-entity name="bob" position="0 -1.5 0">
<pc-render type="sphere"></pc-render>
<pc-collision type="sphere" radius="0.5"></pc-collision>
<pc-rigid-body type="dynamic"></pc-rigid-body>
</pc-entity>
<!-- Bare names resolve inside this clone before anywhere else -->
<pc-joint type="ball" entity-a="anchor" entity-b="bob"></pc-joint>
</pc-entity>
</template>
裸引用按实体名称从近到远解析:关节先搜索最近实体宿主,再逐层向外,在离开克隆前会先到达 pendulum 根。因此各实例连接自己的 anchor 与 bob,克隆十次也得到十个独立摆锤。脚本 entity: 属性,例如 target="entity:bob",采用相同范围。
id 在整个文档中全局有效
按 id 寻址时,不受克隆边界限制:
- 以 # 开头的实体引用搜索整个文档。
- asset 属性和 asset: 前缀通过全局 id 查找
<pc-asset>。 - material 属性通过全局 id 查找
<pc-material>。
这适合共享资源:前述箱子引用模板外声明一次的 material="crate",所有克隆共享材质。但不适合实例内部结构,因此避免在模板内部元素设置 id,克隆会产生重复,# 引用和 getElementById 将找到最先出现的副本,通常不是目标。确实需要 id 时,克隆后为各实例生成唯一值。
每个元素分别就绪
追加后开始异步初始化,就绪状态逐元素追踪。根就绪仅表示 Entity 存在,不代表子组件就绪,应等待实际要访问的元素:
const body = crate.querySelector('pc-rigid-body');
await body.ready();
body.component.applyImpulse(0, 0, -10); // the component exists now
所有异步元素都有返回自身的 ready(),whenReady(element) 提供同样功能。Promise.all 可同时等待多个。不要用 requestAnimationFrame 简单等一帧,资源和 WASM 可能仍在加载;就绪 Promise 才能确定时机。
移除实例
移除克隆根节点即可释放实例,生命周期自动销毁引擎层级,与删除标记相同:
crate.remove();
移除可能与初始化竞争,例如敌人尚在加载就被清空。移除未初始化完的克隆是安全的,但其 ready() 不会结束。等待代码应与自己的销毁信号竞争,并在使用前检查元素仍连接到文档:
await Promise.race([
Promise.all(parts.map((part) => part.ready())),
teardown // a promise you resolve when discarding in-flight instances
]);
if (!crate.isConnected) return; // removed while initializing - nothing to do
示例
每个箱子都来自应用后的同一模板:克隆、离线配置、追加、等待,再在刚体存在后施加冲量。点击落稳的箱子可移除。试着增大冲量,或给箱子和地面都设 restitution="0.8" 增强弹跳。碰撞恢复系数是两个刚体系数之积,只改一个不够:
<pc-app>
<pc-wasm name="Ammo" glue="https://developer.playcanvas.com/assets/modules/ammo/ammo.wasm.js" wasm="https://developer.playcanvas.com/assets/modules/ammo/ammo.wasm.wasm" fallback="https://developer.playcanvas.com/assets/modules/ammo/ammo.js"></pc-wasm>
<!-- Materials are document-global - every clone shares this one -->
<pc-material id="crate" diffuse="#c98a4b"></pc-material>
<pc-material id="floor" diffuse="#3a3f4b"></pc-material>
<pc-scene>
<pc-entity name="camera" position="0 5 12" rotation="-18 0 0">
<pc-camera clear-color="#1d1f2b"></pc-camera>
</pc-entity>
<pc-entity name="light" rotation="45 30 0">
<pc-light cast-shadows normal-offset-bias="0.05" shadow-bias="0.2"></pc-light>
</pc-entity>
<pc-entity name="ground" position="0 -0.5 0" scale="14 1 14">
<pc-render type="box" material="floor"></pc-render>
<pc-collision half-extents="7 0.5 7"></pc-collision>
<pc-rigid-body type="static"></pc-rigid-body>
</pc-entity>
</pc-scene>
</pc-app>
<!-- The prefab: one entity-fronting root owning the whole instance -->
<template id="crate-template">
<pc-entity name="crate">
<pc-render type="box" material="crate"></pc-render>
<pc-collision></pc-collision>
<pc-rigid-body type="dynamic" friction="0.6"></pc-rigid-body>
</pc-entity>
</template>
<div class="controls">
<button id="spawn">Spawn crate</button>
<button id="clear">Clear</button>
</div>
<style>
.controls {
position: absolute;
top: 12px;
left: 12px;
display: flex;
gap: 8px;
}
</style>
<script type="module">
import { whenReady } from '@playcanvas/web-components';
const template = document.getElementById('crate-template');
const scene = await whenReady('pc-scene');
async function spawn() {
// Clone, and capture the root before appending empties the fragment
const clone = template.content.cloneNode(true);
const crate = clone.querySelector('pc-entity');
// Configure this instance while it is still disconnected
crate.setAttribute('position', `${(Math.random() - 0.5) * 4} 6 ${(Math.random() - 0.5) * 4}`);
crate.setAttribute('rotation', `${Math.random() * 360} ${Math.random() * 360} ${Math.random() * 360}`);
// Append once - the lifecycle builds the engine hierarchy
scene.appendChild(clone);
// Await the element we are about to use, not a frame count
const body = crate.querySelector('pc-rigid-body');
await body.ready();
// The component exists now - hurl the crate sideways
body.component.applyImpulse((Math.random() - 0.5) * 4, 0, (Math.random() - 0.5) * 4);
// Removing the root disposes of the whole instance
crate.addEventListener('click', () => crate.remove());
}
document.getElementById('spawn').onclick = spawn;
document.getElementById('clear').onclick = () => {
scene.querySelectorAll('[name="crate"]').forEach((crate) => crate.remove());
};
for (let i = 0; i < 3; i++) spawn();
</script>
示例中的模板
以下示例按复杂程度递增,均采用此模式:
- 物理簇:最简单案例,通过 importNode 将一个模板克隆为四十个球体,放入引力井。
- 界面布局:将方块克隆到
<pc-layout-group>,自动排列新子项。 - 滚动视图:将条目克隆到可滚动内容,通过自身按钮移除。
- AR 热狗风暴:完整案例,使用带关节和物理的
<pc-model>预制体,裸名称引用在各克隆内连接,并处理就绪与销毁竞争。