WebGL & Three.js

Three.jsでオブジェクトを複製する方法|clone・Material共有・SkinnedMesh対応

Three.jsでMesh・Group・Material・Geometry・glTFを複製する方法を解説します。Object3D.cloneのrecursive、共有されるGeometry・Material・Texture、独立copy、SkeletonUtils、InstancedMesh、dispose時の注意まで実装例で確認できます。

この記事の目次
  1. clone()でMeshを複製する基本
  2. 代入・add・cloneの違い
  3. Mesh.clone()で独立・共有されるもの
  4. clone(true)とclone(false)の違い
  5. Groupを階層ごと複製する
  6. copy()で既存Object3Dへ値を複製する
  7. Materialを独立させる
  8. Material配列を複製する
  9. Geometryを独立させる
  10. Textureを独立させる
  11. Object全体を独立resourceにするhelper
  12. glTFの通常modelを複製する
  13. SkinnedMeshはSkeletonUtils.clone()を使う
  14. cloneごとにanimationを再生する
  15. userDataとcustom stateの注意
  16. 大量複製はInstancedMeshを検討する
  17. instanceごとに色を変える
  18. cloneとdisposeの所有関係
  19. 複製方法の選び方
  20. cloneが動かないときの確認項目
  21. 元objectまで色が変わる
  22. 元objectの頂点まで変わる
  23. cloneをaddしたら元から消えた
  24. glTF characterのboneが崩れる
  25. clone削除後に別objectの表示が不安定
  26. 大量cloneで重い
  27. clone実装チェックリスト
  28. まとめ

Three.jsでMeshやGroupを複製する基本はObject3D.clone()です。

const clonedMesh = originalMesh.clone();
clonedMesh.position.x = 3;
scene.add(clonedMesh);

ただし、Mesh.clone()は「すべてのresourceを完全に独立copyするmethod」ではありません。position・rotation・scaleや子Object3Dは複製されますが、GeometryとMaterialは通常同じinstanceを共有します。

console.log(originalMesh.geometry === clonedMesh.geometry); // true
console.log(originalMesh.material === clonedMesh.material); // true

この記事では、Object3D・Mesh・Group・Material・Geometry・Texture・glTFを目的に応じて複製する方法と、SkinnedMesh・InstancedMesh・disposeの注意点を解説します。

clone()でMeshを複製する基本

最小例です。

const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x38bdf8 });

const original = new THREE.Mesh(geometry, material);
original.position.x = -1.5;

const clone = original.clone();
clone.position.x = 1.5;

scene.add(original, clone);

cloneには新しいObject3Dとして別のuuidが付きます。position・rotation・scaleも別instanceなので、cloneの位置を変えてもoriginalの位置は変わりません。

Scene、Camera、Rendererを含む最小構成から確認したい場合はThree.jsの始め方を先に参照してください。

代入・add・cloneの違い

JavaScriptの代入は複製ではなく、同じobjectへのreferenceを増やします。

const meshA = original;
const meshB = original;

meshB.position.x = 5;
console.log(meshA.position.x); // 5

meshAmeshBは同じMeshです。

また、Object3Dは同時に1つのparentだけを持ちます。同じMeshを別Groupへaddすると、元のparentから外れて新しいparentへ移動します。

groupA.add(original);
groupB.add(original); // originalはgroupAから外れてgroupBへ移る

同じ見た目のobjectを複数parentへ同時表示したい場合はcloneを作ります。

Mesh.clone()で独立・共有されるもの

Mesh cloneの設計を表で整理します。

data default 片方の変更
Object3D本体・uuid 独立 もう片方へ影響しない
position / rotation / scale 値をcopyした独立instance もう片方へ影響しない
children recursive=trueなら子もclone scene graph nodeは独立
Geometry 同じreferenceを共有 頂点変更・disposeが他へ影響
Material 同じreferenceを共有 色・opacity変更が他へ影響
Texture Material経由で共有 wrap・offset・disposeが他へ影響
AnimationClip 共有可能 Mixer・action stateはcloneごとに管理

recursiveはObject3D階層を再帰的にcloneする指定であり、Geometry・Material・Textureまでdeep cloneする意味ではありません。

clone(true)とclone(false)の違い

Object3D.clone()の引数は、子孫も複製するかを決めます。defaultはtrueです。

const withChildren = group.clone(true);
const withoutChildren = group.clone(false);
  • clone(true): Group以下の子Object3Dを再帰的にclone
  • clone(false): root Object3Dだけをclone

Group内のMeshは新しいMesh nodeになりますが、そのMeshが持つGeometry・Materialは元Meshと共有されます。

Groupを階層ごと複製する

複数partで構成したobjectはGroup rootをcloneします。

const robot = new THREE.Group();

const body = new THREE.Mesh(bodyGeometry, bodyMaterial);
body.name = "body";

const head = new THREE.Mesh(headGeometry, headMaterial);
head.name = "head";
head.position.y = 1.5;

robot.add(body, head);

const robotClone = robot.clone(true);
robotClone.position.x = 4;

scene.add(robot, robotClone);

clone後の子はgetObjectByName()で取得できます。

const clonedHead = robotClone.getObjectByName("head");
clonedHead.rotation.y = Math.PI / 4;

nameはcopyされるため、同じScene内でnameが重複します。uuidは別ですが、nameをunique identifierとして使う設計ではclone後に付け直します。

copy()で既存Object3Dへ値を複製する

clone()は新しいinstanceを作ります。copy()は既存instanceへsourceの値をcopyします。

const target = new THREE.Object3D();
target.copy(source, true);

独自classでconstructor処理を保ちながらObject3D propertyをcopyしたい場合などに使えます。

class Marker extends THREE.Object3D {
  constructor(label) {
    super();
    this.label = label;
  }

  copy(source, recursive = true) {
    super.copy(source, recursive);
    this.label = source.label;
    return this;
  }
}

custom propertyはThree.jsが自動で理解しないため、必要に応じてcopy / clone設計を追加します。

Materialを独立させる

cloneごとに色・透明度・wireframeなどを変える場合はMaterialもcloneします。

const clone = original.clone();
clone.material = original.material.clone();
clone.material.color.set(0xf97316);

scene.add(clone);

元記事のようにmaterial.color = new THREE.Color(...)と置き換えるより、Color instanceのset()を使うとproperty型を維持できます。色のcopy・色空間はThree.js Materialの色設定で詳しく解説しています。

Material種類・PBR property・透明度の違いはThree.js Materialの種類を参照してください。

Material配列を複製する

Geometry groupごとに複数Materialを使うMeshではmesh.materialが配列です。

const clone = original.clone();

clone.material = Array.isArray(original.material)
  ? original.material.map((material) => material.clone())
  : original.material.clone();

Mesh.clone()がMaterial array自体を別配列にしても、配列内のMaterial instanceは共有されます。個別変更するなら各Materialをcloneします。

Geometryを独立させる

頂点・normal・uvなどをcloneだけ別に変更する場合はGeometryもcloneします。

const clone = original.clone();
clone.geometry = original.geometry.clone();

const position = clone.geometry.getAttribute("position");
position.setY(0, position.getY(0) + 0.5);
position.needsUpdate = true;
clone.geometry.computeVertexNormals();

Geometryを変更しないclone同士は、同じGeometryを共有するほうがmemory効率に優れます。Objectの大きさ・向き・位置だけ変えるならMeshのscale・rotation・positionを使います。

BufferGeometryの頂点変更、normal、bounding volume、disposeはThree.js Geometryの変更方法で解説しています。

Textureを独立させる

Material.clone()を呼んでも、map・normalMap・roughnessMapなどのTextureは通常共有されます。

Textureのoffset・repeat・rotationをcloneだけ変える場合は、対象Textureもcloneします。

const clone = original.clone();
clone.material = original.material.clone();

if (original.material.map) {
  clone.material.map = original.material.map.clone();
  clone.material.map.offset.set(0.5, 0);
  clone.material.map.needsUpdate = true;
}

Texture cloneはTexture objectのsampling・transform設定を独立させる用途です。元画像などsource dataまで別copyになるとは限らないため、画像data自体を変更する要件は別途設計します。

Textureを変えないなら共有したほうがGPU memoryとload costを抑えられます。

Object全体を独立resourceにするhelper

通常Mesh・Group向けに、cloneした階層のMaterialとGeometryを独立させる例です。

function cloneWithIndependentResources(source) {
  const clone = source.clone(true);

  clone.traverse((object) => {
    if (!object.isMesh) return;

    object.geometry = object.geometry.clone();
    object.material = Array.isArray(object.material)
      ? object.material.map((material) => material.clone())
      : object.material.clone();
  });

  return clone;
}

このhelperはTextureまでcloneしません。また、すべてを独立させるとmemory使用量が増えます。「変更するresourceだけcloneする」のが基本です。

SkinnedMeshを含むmodelにはこの単純helperを使わず、SkeletonUtilsを使います。

glTFの通常modelを複製する

GLTFLoaderで読み込んだscene rootもObject3Dです。skin・boneを含まないstatic modelならsceneをcloneできます。

loader.load("/models/tree.glb", (gltf) => {
  const treeA = gltf.scene;
  const treeB = gltf.scene.clone(true);

  treeA.position.x = -2;
  treeB.position.x = 2;

  scene.add(treeA, treeB);
});

static glTFでもGeometry・Materialは共有されます。見た目を共通にしたい大量のpropsには効率的ですが、片方だけMaterialを変えるなら対象MeshのMaterialをcloneします。

SkinnedMeshはSkeletonUtils.clone()を使う

bone・skeletonを含むcharacterはObject3D.clone()だけではbone associationが正しく独立しない場合があります。Three.js addonのSkeletonUtilsを使います。

import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";

loader.load("/models/character.glb", (gltf) => {
  const characterA = SkeletonUtils.clone(gltf.scene);
  const characterB = SkeletonUtils.clone(gltf.scene);

  characterA.position.x = -1.5;
  characterB.position.x = 1.5;

  scene.add(characterA, characterB);
});

SkeletonUtils.clone()はSkinnedMeshとboneを正しく対応させてcloneします。GeometryとMaterialなどはreferenceで再利用されます。

渡すsourceのdescendantにSkinnedMeshと必要なboneの両方が含まれる必要があります。個別SkinnedMeshだけではなく、通常はgltf.sceneなど共通rootを渡します。

cloneごとにanimationを再生する

AnimationClipは共有できますが、再生時刻・weight・loopなどのstateはcloneごとにAnimationMixerを作ります。

const characterA = SkeletonUtils.clone(gltf.scene);
const characterB = SkeletonUtils.clone(gltf.scene);

const clip = gltf.animations[0];
const mixerA = new THREE.AnimationMixer(characterA);
const mixerB = new THREE.AnimationMixer(characterB);

mixerA.clipAction(clip).play();
mixerB.clipAction(clip).play();

function update(delta) {
  mixerA.update(delta);
  mixerB.update(delta * 0.8);
}

同じclipを使いながら、cloneごとにspeedや開始時刻を変えられます。page破棄時はaction停止、rootのuncache、resource ownershipを整理します。

userDataとcustom stateの注意

Object3DのuserDataはapplication metadataを置くobjectですが、function referenceを入れないことが公式docsで推奨されています。functionはclone対象になりません。

mesh.userData = {
  itemId: "item-42",
  category: "furniture",
};

DOM event listener、application store、physics body、audio nodeなどThree.js外部のstateはObject3D.clone()だけでは複製されません。clone作成後のfactory処理で紐付けます。

大量複製はInstancedMeshを検討する

同じGeometry・Materialを数百・数千個表示する場合、Meshを大量cloneするとdraw callが増えます。InstancedMeshは1つのGeometry・Materialを使い、instanceごとのtransformで描画します。

const count = 500;
const geometry = new THREE.BoxGeometry(1, 1, 1);
const material = new THREE.MeshStandardMaterial({ color: 0x38bdf8 });
const instances = new THREE.InstancedMesh(geometry, material, count);
const helper = new THREE.Object3D();

for (let i = 0; i < count; i += 1) {
  helper.position.set(
    (Math.random() - 0.5) * 40,
    (Math.random() - 0.5) * 20,
    (Math.random() - 0.5) * 40,
  );
  helper.rotation.set(0, Math.random() * Math.PI, 0);
  helper.updateMatrix();

  instances.setMatrixAt(i, helper.matrix);
}

instances.instanceMatrix.needsUpdate = true;
scene.add(instances);

InstancedMeshはdraw callを減らせますが、instanceごとに異なるGeometry・Material・複雑なchild hierarchyは持てません。独立objectとしてraycast・animation・lifecycleを管理したい場合は通常cloneとのtradeoffを確認します。

多数Meshをrandom配置する基本はThree.jsでMeshをランダム配置する方法も参照してください。

instanceごとに色を変える

setColorAt()でinstance colorを設定できます。

const color = new THREE.Color();

for (let i = 0; i < count; i += 1) {
  color.setHSL(i / count, 0.8, 0.5);
  instances.setColorAt(i, color);
}

instances.instanceColor.needsUpdate = true;

base Material colorとinstance colorは最終表示へ組み合わされるため、元色を保つならbase colorをwhiteにするなど確認します。

cloneとdisposeの所有関係

Mesh.clone()がGeometry・Materialを共有する場合、1つのcloneを削除しただけで共有resourceをdisposeしてはいけません。

scene.remove(clone);

// originalが同じresourceを使っている間は実行しない
// clone.geometry.dispose();
// clone.material.dispose();

Three.jsはSceneからremoveしてもGeometry・Material・Textureを自動disposeしません。applicationがresourceのownerと利用数を管理します。

resource disposeする時点
shared Geometry 利用する全Meshが不要になった後
independent Geometry 所有cloneが不要になった後
shared Material / Texture 利用する全objectが不要になった後
Skeleton 共有有無を確認し、最後のSkinnedMeshが不要になった後

dispose済みresourceを別cloneが使い続けると、次のrenderで再生成によるcostや意図しないlifecycleになります。

複製方法の選び方

要件 方法
位置・回転だけ別にする mesh.clone()でresource共有
色・opacityを別にする Mesh clone + Material clone
頂点・uvを別にする Mesh clone + Geometry clone
Texture transformを別にする Material clone + Texture clone
Group階層を複製する group.clone(true)
bone付きglTF SkeletonUtils.clone()
同形状を大量表示 InstancedMesh

cloneが動かないときの確認項目

元objectまで色が変わる

Materialを共有しています。clone.materialをcloneし、Material arrayなら各要素もcloneします。

元objectの頂点まで変わる

Geometryを共有しています。頂点を個別変更する前にclone.geometryへGeometry cloneを割り当てます。

cloneをaddしたら元から消えた

同じObject3D referenceを別parentへaddしています。clone()で新しいObject3Dを作ったか確認します。

glTF characterのboneが崩れる

SkinnedMeshをObject3D.clone()だけで複製している、またはSkeletonUtils.clone()へboneを含まないsubtreeを渡している可能性があります。SkinnedMeshとboneを含むgltf.sceneなどのrootを渡します。

clone削除後に別objectの表示が不安定

共有Geometry・Material・Textureをdisposeしていないか確認します。Scene graphのremoveとGPU resourceのdisposeは別操作です。

大量cloneで重い

renderer.infoでdraw call・Geometry・Textureを確認し、同じGeometry・MaterialならInstancedMeshを検討します。すべてのcloneでMaterial・Geometryを独立させるとmemoryも増えます。

clone実装チェックリスト

  • 代入ではなくclone()で新しいObject3Dを作った
  • childrenが必要ならrecursive=trueを使った
  • Geometry・Materialがdefaultで共有されると理解した
  • 変更するresourceだけcloneした
  • Material arrayの各Materialも必要に応じてcloneした
  • Texture objectとsource image dataの独立範囲を確認した
  • SkinnedMeshはSkeletonUtils.clone()を使った
  • animation state用にcloneごとのAnimationMixerを作った
  • 大量同形状ではInstancedMeshを検討した
  • shared resourceを最後の利用者より前にdisposeしていない
  • custom state・event・physicsは別途factoryで設定した

まとめ

Three.jsのObject3D.clone()は、Mesh・Group・子階層を新しいscene graph nodeとして複製します。ただし、MeshのGeometry・Materialはdefaultで共有されるため、完全なdeep copyではありません。

見た目だけ同じで位置を変えるならresource共有が効率的です。色を変えるならMaterial、頂点を変えるならGeometry、Texture transformを変えるならTextureも必要な範囲だけcloneします。

bone付きglTFはSkeletonUtils.clone()、同じ形を大量表示する場合はInstancedMeshを使い、disposeはresourceの最後の利用者がなくなった時点で行ってください。