Vue implements reading local model files and previewing its animation online

demo address

1. Demand background

The company used to need its own internal model library and the function of model preview, so it found a model preview plug-in, but found that the animation could not be previewed, so it specially solved this problem.

Disadvantages: After developing this function, I feel that it is redundant because it is packaged and exported together with the scene, so a single fbx is useless, and the network loading of a larger fbx consumes cloud storage traffic and the loading speed is slow, which is not ideal. Reading locally fastest. Finally I localized the backend.

This method is limited to fbx because I only modified the reading code of fbx

2. Implementation method

Install the model preview plug-in

cnpm i online-3d-viewer -S

Plug-in usage documentation

Encapsulate a model component

The implementation principle has been written below. The bottom layer of this plug-in is threejs, so my first reaction should be that it can play animations like mixamo. However, the obtained model object does not have the animations attribute. In fact, the plug-in has re-adjusted the imported model. If the animation is lost, you only need to retrieve the parsed fbx model when reading and use threejs to play the animation.

<!--
 * @Author: alpaca
 * @Date: 2023-10-31 14:21:49
 * @LastEditors: alpaca
 * @LastEditTime: 2023-10-31 15:08:56
 * @Description: file content
-->
<template>
  <div
    class="root"
    style="width: 100%; height: 100%; margin-top:10px"
  >
    <div
      class="box"
      id="model-viewer"
      :class="fullScreen ? 'fullbox' : 'exitfullbox'"
    >
      <span
        v-if="viewer != 'init'"
        class="fullscreen"
        @click="fullScreenTable"
      >{<!-- -->{
        fullScreen ? 'Exit' : 'Full screen'
      }}</span>
      <div
        class="animations-group"
        v-if="animations.length>0 & amp; & amp;!hide"
      >
        <el-button @click="stopAnimation">Stop</el-button>
        <el-button
          v-for="item in animations"
          :key="item.name"
          @click="playAnimation(item)"
        >{<!-- -->{item.name}}</el-button>
      </div>
      <p
        v-if="viewer == 'init'"
        style="line-height: 70vh; text-align: center"
      >3D model loading...</p>
    </div>
  </div>
</template>

<script>
// The bottom layer of this library is actually threeJS
import * as OV from "online-3d-viewer";
import * as THREE from "three";

export default {
  props: ["type", "file", "url"],
  data() {
    return {
      viewer: null,
      fullScreen: false,
      interval: null,
      detaultAspect: 0,
      // width and height of Div before full screen
      originalWidth: null,
      originalHeight: null,
      // animation clip
      animations: [],
      mixer: null,
      hide: false,
      // update function
      update: null,
      clock: new THREE.Clock(),
    };
  },
  methods: {
    // full screen function
    fullScreenTable() {
      let element = document.querySelector("#model-viewer");
      this.originalWidth = element.clientWidth;
      this.originalHeight = element.clientHeight;
      this.launchIntoFullscreen(element);
    },
    // Compatible with all browsers
    launchIntoFullscreen(element) {
      // Exit Full Screen
      if (this.fullScreen) {
        if (document.exitFullscreen) {
          document.exitFullscreen();
        } else if (document.webkitCancelFullScreen) {
          document.webkitCancelFullScreen();
        } else if (document.mozCancelFullScreen) {
          document.mozCancelFullScreen();
        } else if (document.msExitFullscreen) {
          document.msExitFullscreen();
        }
        this.fullScreen = false;
      } else {
        // Check whether it is still in full screen. If not, return the viewer's canvas size, otherwise the field of view position will change.
        this.interval = setInterval(() => {
          if (
            document.fullscreenElement !=
            document.querySelector("#model-viewer")
          ) {
            this.fullScreen = false;
            this.$nextTick(() => {
              //Reset canvas size
              this.viewer.viewer.Resize(
                this.originalWidth,
                this.originalHeight
              );
            });
            clearInterval(this.interval);
          }
        }, 100);
        // full screen
        if (element.requestFullscreen) {
          element.requestFullscreen();
        } else if (element.mozRequestFullScreen) {
          element.mozRequestFullScreen();
        } else if (element.webkitRequestFullscreen) {
          element.webkitRequestFullscreen();
        } else if (element.msRequestFullscreen) {
          element.msRequestFullscreen();
        }
        this.fullScreen = true;
      }
    },
    //Model initialization
    init() {
      //Set the model code to determine whether it is local or url
      const setFbx = () => {
        this.hide = true;
        switch (this.type) {
          case "file":
            this.viewer.LoadModelFromFileList([this.file]);
            break;
          default:
            this.viewer.LoadModelFromUrlList([this.url]);
            break;
        }
      };
      // If the scene has been generated, you only need to remove the original fbx on the scene to reduce the initialization overhead.
      if (this.viewer != null) {
        this.viewer.viewer.cameraMode = 2;
        let { scene } = this.viewer.viewer;
        if (scene.children.length > 3) {
          scene.remove(scene.children[3]);
        }
        return setFbx();
      }

      this.viewer = "init";

      setTimeout(() => {
        // Under which div it is generated?
        let parentDiv = document.getElementById("model-viewer");

        //Initialize the previewer
        this.viewer = new OV.EmbeddedViewer(parentDiv, {
          // background color
          backgroundColor: new OV.RGBAColor(51, 51, 51, 255),
          // When the model is loaded
          onModelLoaded: () => {
            let { renderer, camera, scene } = this.viewer.viewer;
            // After the model is processed by the plug-in, the model material display will change and the animation will be lost.
            let model = this.viewer.viewer.mainModel.mainModel.rootObject;
            // Unprocessed model Only from this can we get the animation of the model
            let original = this.viewer.model.originalModel;

            // We only use the original model when there is animation on the model, otherwise we use the processed model.
            if (original.animations.length > 0) {
              //Essentially, they are all objects generated by threejs, and you can use its methods.
              // Remove the rendered model and add the animated model back to the scene
              scene.remove(model);
              scene.add(original);
              // Get the animation clip of the model
              const clips = original.animations;
              this.animations = clips;
              // Generate an animation mixer on this model, otherwise the animation cannot be played
              const mixer = new THREE.AnimationMixer(original);
              //Record this mixer to facilitate subsequent changes to animations and stopping animations
              this.mixer = mixer;
              // Render and update the scene to ensure animation playback
              this.update = () => {
                if (!this.mixer) return;
                requestAnimationFrame(this.update);
                mixer.update(this.clock.getDelta());
                renderer.render(scene, camera);
              };
              this.update();
            }
            this.hide = false;
          },
        });
        ///In changing the source code, it turns out that our sentence = this.viewer.model.originalModel cannot get the originally loaded model object.
        // Modify the fbx loader. When reading the model, save a parsed original model to the model loader so that we can get whether there is animation later.
        let original =
          this.viewer.modelLoader.importer.importers[11].OnThreeObjectsLoaded;
        this.viewer.modelLoader.importer.importers[11].OnThreeObjectsLoaded =
          function (loadedObject, onFinish) {
            this.GetMainObject = (loadedObject) => {
              return loadedObject;
            };
            this.model.originalModel = loadedObject;
            original.call(this, loadedObject, onFinish);
          };
        // This sentence is probably useless. I forgot what I used it for after I wrote it.
        this.detaultAspect = this.viewer.viewer.camera.aspect;
        setFbx();
      }, 100);
    },
    // Stop animation
    stopAnimation() {
      this.mixer & amp; & amp; this.mixer.stopAllAction();
    },
    // play animation
    playAnimation(clip) {
      this.mixer & amp; & amp; this.mixer.clipAction(clip).play();
    },
    // clear
    clear() {
      clearInterval(this.interval);
      if (this.viewer) {
        this.viewer.Destroy();
      }
      this.viewer = null;
      this.mixer = null;
      this.animations = [];
      this.interval = null;
    },
  },
  destroyed() {
    this.clear();
  },
};
</script>

<style>
.animations-group {
  position: absolute;
  right: 10px;
  top: 10px;
  display: flex;
  flex-direction: column;
}
.animations-group .el-button {
  margin-left: 0 !important;
  margin-bottom: 10px;
  width: 200px;
}
@media (max-width: 767.98px) {
  .fbx-box {
    width: 96% !important;
    height: 60vh !important;
    margin-bottom: 20px;
  }

  .root,
  .box {
    height: 70vh !important;
  }
}

.box {
  background-color: #333;
  font-size: 30px;
  font-weight: bold;
  color: #fff;
  position: relative;
  flex: 1;
}

.fullscreen {
  font-size: 16px;
  font-weight: normal;
  position: absolute;
  left: 30px;
  bottom: 30px;
  user-select: none;
  cursor: pointer;
}

.fullscreen:hover {
  color: greenyellow;
}

.fullbox {
  width: 100vw !important;
  height: 100% !important;
}

.exitfullbox {
  width: 100% !important;
  height: 100% !important;
}
</style>

Use this packaged component

<!--
 * @Author: alpaca
 * @Date: 2023-10-27 16:15:23
 * @LastEditors: alpaca
 * @LastEditTime: 2023-10-31 14:45:43
 * @Description: file content
-->
<template>
  <div id="app">
    <el-upload
      class="upload-demo"
      action="https://jsonplaceholder.typicode.com/posts/"
      :auto-upload="false"
      :limit="1"
      :on-change="initModel"
      accept=".fbx"
      :show-file-list="false"
    >
      <el-button
        size="small"
        type="primary"
      >Click to upload model file</el-button>
    </el-upload>
    <fbx-viewer
      :type="type"
      :file="file.raw"
      ref="model"
      v-if="file"
    />
  </div>
</template>

<script>
import FbxViewer from "./components/FbxViewer.vue";
export default {
  components: { FbxViewer },
  name: "App",
  data() {
    return {
      type: "file",
      file: null,
    };
  },
  methods: {
    initModel(file) {
      if (file != this.file) {
        this.file = file;
        this.$nextTick(() => {
          this.$refs.model.init();
        });
      }
    },
  },
};
</script>

<style>
#app {
  font-family: Avenir, Helvetica, Arial, sans-serif;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  color: #2c3e50;
}
</style>

The function of previewing the model and playing animation is completed.