Skip to content

SkinnedMesh ​


WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

ts
import { AnimatorComponent, AtmosphericComponent, CameraUtil, Color, ComponentBase, DirectLight, Engine3D, HoverCameraController, KelvinUtil, MeshDeformer, MeshRenderer, Object3D, Object3DUtil, Raycaster, RaycastHit, Scene3D, SphereGeometry, UnLitMaterial, Vector3, View3D } from '@orillusion/core';
import dat from 'dat.gui';

/**
 * Picking skinned and morph-target meshes.
 *
 * Two animated soldiers (glTF skin) and a lion with blend shapes. Every frame a Raycaster is cast
 * through the mouse position: the GPU deforms these meshes in the vertex shader, so the raycaster
 * replays the deformation on the CPU and the red marker sticks to the surface as drawn, even while
 * it animates under a still mouse. The blue marker is the same ray against the bind pose
 * (deformation = 'bindPose'). The yellow dot points along the hit normal.
 */
class Sample_SkinnedMeshPick {
    private scene: Scene3D;
    private view: View3D;
    private characters: Object3D;
    private animators: AnimatorComponent[] = [];
    private lionAnimator: AnimatorComponent;
    private hitMarker: Object3D;
    private normalMarker: Object3D;
    private bindMarker: Object3D;
    private readonly raycaster = new Raycaster();
    private readonly bindRaycaster = new Raycaster();
    private readonly info = {
        animate: true,
        clip: 'Run',
        showBindPose: true,
        lastHit: '-',
        faceIndex: -1,
        distance: 0,
        bindPoseOffset: '-',
        deformedVertices: 0,
        pickMs: 0
    };
    private readonly morph: { [key: string]: number } = {};

    async run() {
        const engine = await Engine3D.init();

        this.scene = new Scene3D();
        this.scene.addComponent(AtmosphericComponent);

        const camera = CameraUtil.createCamera3DObject(this.scene);
        camera.perspective(45, engine.aspect, 0.1, 1000);
        camera.object3D.addComponent(HoverCameraController).setCamera(0, -12, 150, new Vector3(20, 18, 0));

        const light = new Object3D();
        light.rotationX = 35;
        light.rotationY = 160;
        const dl = light.addComponent(DirectLight);
        dl.lightColor = KelvinUtil.color_temperature_to_rgb(5355);
        dl.intensity = 2;
        dl.castShadow = true;
        dl.enableCSM = true;
        this.scene.addChild(light);

        this.view = new View3D();
        this.view.scene = this.scene;
        this.view.camera = camera;

        this.scene.addChild(Object3DUtil.GetSingleCube(400, 1, 400, 0.45, 0.45, 0.48));
        this.characters = new Object3D();
        this.scene.addChild(this.characters);
        await this.loadSoldiers(engine);
        await this.loadLion(engine);
        this.hitMarker = this.createDot(1.1, new Color(1, 0.15, 0.1));
        this.normalMarker = this.createDot(0.6, new Color(1, 0.95, 0.2));
        this.bindMarker = this.createDot(0.9, new Color(0.2, 0.5, 1));
        // The comparison ray ignores the animation and hits the undeformed mesh
        this.bindRaycaster.deformation = 'bindPose';

        engine.startRenderView(this.view);
        this.initGUI();
        // Pick every frame: the characters move under a still mouse
        this.scene.addComponent(PickEveryFrame).sample = this;
    }

    private async loadSoldiers(engine: Engine3D) {
        const soldier = await engine.res.loadGltf('https://cdn.orillusion.com/gltfs/glb/Soldier.glb');
        soldier.scaleX = soldier.scaleY = soldier.scaleZ = 20;
        soldier.rotationY = 200;
        soldier.x = -20;
        this.characters.addChild(soldier);
        const walker = soldier.clone();
        walker.x = 15;
        walker.rotationY = 160;
        this.characters.addChild(walker);
        this.animators = [soldier, walker].map((s) => s.getComponentsInChild(AnimatorComponent)[0]);
        this.animators[0].playAnim('Run');
        this.animators[1].playAnim('Walk');
    }

    private async loadLion(engine: Engine3D) {
        const lion = await engine.res.loadGltf('https://cdn.orillusion.com/gltfs/glb/lion.glb');
        lion.scaleX = lion.scaleY = lion.scaleZ = 0.4;
        this.characters.addChild(lion);
        // Stand it on the ground at x = 55 using its bounds
        let minY = Infinity,
            cx = 0,
            n = 0;
        for (const r of lion.getComponentsInChild(MeshRenderer)) {
            const b = r.geometry.bounds,
                m = r.transform.worldMatrix;
            for (const p of [b.min, b.max]) {
                const w = m.transformPoint(p.clone());
                minY = Math.min(minY, w.y);
                cx += w.x;
                n++;
            }
        }
        lion.y -= minY;
        lion.x += 55 - cx / n;
        this.lionAnimator = lion.addComponent(AnimatorComponent);
        for (const key in this.lionAnimator.cloneMorphRenderers()) this.morph[key] = 0;
    }

    private createDot(radius: number, color: Color): Object3D {
        const obj = new Object3D();
        const mr = obj.addComponent(MeshRenderer);
        mr.geometry = new SphereGeometry(radius, 16, 16);
        const mat = new UnLitMaterial();
        mat.baseColor = color;
        mr.material = mat;
        mr.castShadow = false;
        obj.transform.enable = false;
        this.scene.addChild(obj);
        return obj;
    }

    /** Called every frame by PickEveryFrame. */
    public pick() {
        const input = this.view.engine3D.inputSystem;
        const camera = this.view.camera;
        this.raycaster.setFromCamera(camera, input.mouseX, input.mouseY);
        const t0 = performance.now();
        const hit = this.raycaster.intersectFirst(this.characters);
        this.info.pickMs = +(performance.now() - t0).toFixed(2);
        this.place(hit);

        let bind: RaycastHit | null = null;
        if (this.info.showBindPose) {
            this.bindRaycaster.setFromCamera(camera, input.mouseX, input.mouseY);
            bind = this.bindRaycaster.intersectFirst(this.characters);
        }
        this.bindMarker.transform.enable = !!bind;
        if (bind) this.bindMarker.localPosition = bind.point;
        this.info.bindPoseOffset = hit && bind ? Vector3.distance(hit.point, bind.point).toFixed(2) : '-';
    }

    private place(hit: RaycastHit | null) {
        this.hitMarker.transform.enable = this.normalMarker.transform.enable = !!hit;
        if (!hit) {
            this.info.lastHit = '-';
            return;
        }
        this.hitMarker.localPosition = hit.point;
        const n = hit.normal;
        this.normalMarker.localPosition = new Vector3(hit.point.x + n.x * 3, hit.point.y + n.y * 3, hit.point.z + n.z * 3);
        this.info.lastHit = hit.object3D.name || '(unnamed)';
        this.info.faceIndex = hit.faceIndex;
        this.info.distance = +hit.distance.toFixed(2);
        // The CPU-deformed copy the raycaster tested (cached per renderer)
        const deformed = MeshDeformer.deform(hit.renderer);
        this.info.deformedVertices = deformed ? deformed.geometry.getAttribute('position').data.length / 3 : 0;
    }

    private initGUI() {
        const gui = new dat.GUI();
        const a = gui.addFolder('Animation');
        a.add(this.info, 'animate').onChange((v: boolean) => this.animators.forEach((an) => (an.timeScale = v ? 1 : 0)));
        a.add(this.info, 'clip', ['Idle', 'Walk', 'Run', 'TPose']).onChange((v: string) => this.animators[0].playAnim(v));
        const m = gui.addFolder('Lion blend shapes');
        for (const key in this.morph) {
            m.add(this.morph, key, 0, 1, 0.01).onChange((v: number) => {
                this.lionAnimator.updateBlendShape(['blendShape', 'blendShape', key], 'blendShape.blendShape.' + key, v);
            });
        }
        const p = gui.addFolder('Pick (mouse)');
        p.add(this.raycaster, 'deformation', ['cpu', 'bindPose', 'skip']);
        p.add(this.info, 'showBindPose');
        p.add(this.info, 'lastHit').listen();
        p.add(this.info, 'faceIndex').listen();
        p.add(this.info, 'distance').listen();
        p.add(this.info, 'bindPoseOffset').listen();
        p.add(this.info, 'deformedVertices').listen();
        p.add(this.info, 'pickMs').listen();
        p.open();
        // Start collapsed so the panel does not cover the scene
        gui.close();
    }
}

class PickEveryFrame extends ComponentBase {
    public sample: Sample_SkinnedMeshPick;
    onUpdate() {
        this.sample?.pick();
    }
}

new Sample_SkinnedMeshPick().run();