Bläddra i källkod

Merge branch 'dev' into prod

bill 8 månader sedan
förälder
incheckning
5a0c7bc204
6 ändrade filer med 326 tillägg och 101 borttagningar
  1. 2 0
      package.json
  2. 43 0
      pnpm-lock.yaml
  3. 5 5
      src/store/scene.ts
  4. 14 1
      src/view/pano/env.ts
  5. 120 95
      src/view/pano/pano.vue
  6. 142 0
      src/view/pano/three-env.ts

+ 2 - 0
package.json

@@ -12,6 +12,7 @@
   "dependencies": {
     "@element-plus/icons-vue": "^2.3.1",
     "@types/node": "^20.12.2",
+    "@types/three": "^0.170.0",
     "element-plus": "^2.6.3",
     "gl-matrix": "^3.4.3",
     "js-base64": "^3.7.7",
@@ -23,6 +24,7 @@
     "pinia": "^2.1.7",
     "proj4": "^2.11.0",
     "qrcode": "^1.5.3",
+    "three": "^0.170.0",
     "vite-svg-loader": "^5.1.0",
     "vue": "^3.4.21",
     "vue-router": "^4.3.0",

+ 43 - 0
pnpm-lock.yaml

@@ -5,6 +5,7 @@ specifiers:
   '@types/node': ^20.12.2
   '@types/proj4': ^2.5.5
   '@types/qrcode': ^1.5.5
+  '@types/three': ^0.170.0
   '@vitejs/plugin-vue': ^5.0.4
   element-plus: ^2.6.3
   gl-matrix: ^3.4.3
@@ -18,6 +19,7 @@ specifiers:
   proj4: ^2.11.0
   qrcode: ^1.5.3
   sass: ^1.72.0
+  three: ^0.170.0
   typescript: ^5.2.2
   vite: ^5.2.0
   vite-svg-loader: ^5.1.0
@@ -29,6 +31,7 @@ specifiers:
 dependencies:
   '@element-plus/icons-vue': 2.3.1_vue@3.4.27
   '@types/node': 20.14.2
+  '@types/three': 0.170.0
   element-plus: 2.7.5_vue@3.4.27
   gl-matrix: 3.4.3
   js-base64: 3.7.7
@@ -40,6 +43,7 @@ dependencies:
   pinia: 2.1.7_lku3umiefploqzvdryeqiyorrq
   proj4: 2.11.0
   qrcode: 1.5.3
+  three: 0.170.0
   vite-svg-loader: 5.1.0_vue@3.4.27
   vue: 3.4.27_typescript@5.4.5
   vue-router: 4.3.3_vue@3.4.27
@@ -460,6 +464,10 @@ packages:
     engines: {node: '>=10.13.0'}
     dev: false
 
+  /@tweenjs/tween.js/23.1.3:
+    resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
+    dev: false
+
   /@types/estree/1.0.5:
     resolution: {integrity: sha512-/kYRxGDLWzHOB7q+wtSUQlFrtcdUccpfy+X+9iMBpHK8QLLhx2wIPYuS5DYtR9Wa/YlZAbIovy7qVdB1Aq6Lyw==}
     dev: true
@@ -489,10 +497,29 @@ packages:
       '@types/node': 20.14.2
     dev: true
 
+  /@types/stats.js/0.17.3:
+    resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
+    dev: false
+
+  /@types/three/0.170.0:
+    resolution: {integrity: sha512-CUm2uckq+zkCY7ZbFpviRttY+6f9fvwm6YqSqPfA5K22s9w7R4VnA3rzJse8kHVvuzLcTx+CjNCs2NYe0QFAyg==}
+    dependencies:
+      '@tweenjs/tween.js': 23.1.3
+      '@types/stats.js': 0.17.3
+      '@types/webxr': 0.5.20
+      '@webgpu/types': 0.1.51
+      fflate: 0.8.2
+      meshoptimizer: 0.18.1
+    dev: false
+
   /@types/web-bluetooth/0.0.16:
     resolution: {integrity: sha512-oh8q2Zc32S6gd/j50GowEjKLoOVOwHP/bWVjKJInBwQqdOYMdPrf1oVlelTlyfFK3CKxL1uahMDAr+vy8T7yMQ==}
     dev: false
 
+  /@types/webxr/0.5.20:
+    resolution: {integrity: sha512-JGpU6qiIJQKUuVSKx1GtQnHJGxRjtfGIhzO2ilq43VZZS//f1h1Sgexbdk+Lq+7569a6EYhOWrUpIruR/1Enmg==}
+    dev: false
+
   /@vitejs/plugin-vue/5.0.5_vite@5.2.13+vue@3.4.27:
     resolution: {integrity: sha512-LOjm7XeIimLBZyzinBQ6OSm3UBCNVCpLkxGC0oWmm2YPzVZoxMsdvNVimLTBzpAnR9hl/yn1SHGuRfe6/Td9rQ==}
     engines: {node: ^18.0.0 || >=20.0.0}
@@ -635,6 +662,10 @@ packages:
       - vue
     dev: false
 
+  /@webgpu/types/0.1.51:
+    resolution: {integrity: sha512-ktR3u64NPjwIViNCck+z9QeyN0iPkQCUOQ07ZCV1RzlkfP+olLTeEZ95O1QHS+v4w9vJeY9xj/uJuSphsHy5rQ==}
+    dev: false
+
   /adler-32/1.3.1:
     resolution: {integrity: sha512-ynZ4w/nUUv5rrsR8UUGoe1VC9hZj6V5hU9Qw1HlMDJGEJw5S7TfTErWTjMys6M7vr0YWcPqs3qAr4ss0nDfP+A==}
     engines: {node: '>=0.8'}
@@ -947,6 +978,10 @@ packages:
   /estree-walker/2.0.2:
     resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==}
 
+  /fflate/0.8.2:
+    resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
+    dev: false
+
   /fill-range/7.1.1:
     resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==}
     engines: {node: '>=8'}
@@ -1130,6 +1165,10 @@ packages:
     resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==}
     dev: false
 
+  /meshoptimizer/0.18.1:
+    resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
+    dev: false
+
   /mgrs/1.0.0:
     resolution: {integrity: sha512-awNbTOqCxK1DBGjalK3xqWIstBZgN6fxsMSiXLs9/spqWkF2pAhb2rrYCFSsr1/tT7PhcDGjZndG8SWYn0byYA==}
     dev: false
@@ -1447,6 +1486,10 @@ packages:
       picocolors: 1.0.1
     dev: false
 
+  /three/0.170.0:
+    resolution: {integrity: sha512-FQK+LEpYc0fBD+J8g6oSEyyNzjp+Q7Ks1C568WWaoMRLW+TkNNWmenWeGgJjV105Gd+p/2ql1ZcjYvNiPZBhuQ==}
+    dev: false
+
   /to-fast-properties/2.0.0:
     resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==}
     engines: {node: '>=4'}

+ 5 - 5
src/store/scene.ts

@@ -31,21 +31,21 @@ export const relicsId = computed(() => relics.value!.relicsId);
 
 // https://4dkankan.oss-cn-shenzhen.aliyuncs.com/scene_view_data/KJ-t-OgSx9XIrvNQ/images/panoramas/22.jpg?x-oss-process=image/resize,m_fixed,w_6144&171342528615
 
-export const getPointPano = (point: ScenePoint, tile = false) => {
+export const getPointPano = (point: ScenePoint, tile = false, thumbnail = true) => {
   if (tile) {
     const fileNames = new Array(6).fill(0);
     return fileNames.map(
       (_, i) =>
-        `https://4dkk.4dage.com/scene_view_data/${point.sceneCode}/images/tiles/4k/${point.uuid}_skybox${i}.jpg`
+        `https://4dkk.4dage.com/scene_view_data/${point.sceneCode}/images/tiles/4k/${point.uuid}_skybox${i}.jpg${thumbnail ? '?x-oss-process=image/resize,w_1024' : ''}`
     );
   } else if (point.cameraType === DeviceType.VR) {
     if (point.sceneType === SceneType.VR) {
-      return `https://4dkankan.oss-cn-shenzhen.aliyuncs.com/scene_view_data/${point.sceneCode}/images/panoramas/${point.uuid}.jpg`;
+      return thumbnail 
+        ? `https://4dkankan.oss-cn-shenzhen.aliyuncs.com/scene_view_data/${point.sceneCode}/images/high/${point.uuid}.jpg` 
+        : `https://4dkankan.oss-cn-shenzhen.aliyuncs.com/scene_view_data/${point.sceneCode}/images/panoramas/${point.uuid}.jpg`;
     } else {
       return `https://4dkk.4dage.com/scene_result_data/${point.sceneCode}/caches/images/${point.uuid}.jpg`
     }
-  } else if (point.cameraType === DeviceType.CLUNT) {
-    return `https://4dkk.4dage.com/scene_view_data/${point.sceneCode}/images/pan/high/${point.uuid}.jpg`;
   }
 };
 

+ 14 - 1
src/view/pano/env.ts

@@ -13,6 +13,7 @@ const generatePreset = (gl: WebGL2RenderingContext) => {
     gl.bindTexture(gl.TEXTURE_CUBE_MAP, skyCubeTex1);
     const mapper = [2, 4, 0, 5, 1, 3];
     for (let i = 0; i < 6; i++) {
+      console.log(images[i].src, gl.TEXTURE_CUBE_MAP_POSITIVE_X + i)
       gl.texImage2D(
         gl.TEXTURE_CUBE_MAP_POSITIVE_X + i,
         0,
@@ -28,6 +29,12 @@ const generatePreset = (gl: WebGL2RenderingContext) => {
     gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
     gl.texParameteri(gl.TEXTURE_CUBE_MAP, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
     gl.generateMipmap(gl.TEXTURE_CUBE_MAP);
+    // 设置各向异性过滤参数  
+    const ext = gl.getExtension('EXT_texture_filter_anisotropic');  
+    if (ext) {  
+        const maxAnisotropy = gl.getParameter(ext.MAX_TEXTURE_MAX_ANISOTROPY_EXT);  
+        gl.texParameteri(gl.TEXTURE_CUBE_MAP, ext.TEXTURE_MAX_ANISOTROPY_EXT, maxAnisotropy); // 设置各向异性系数为6  
+    }  
     gl.bindTexture(gl.TEXTURE_CUBE_MAP, null);
   };
 
@@ -36,6 +43,12 @@ const generatePreset = (gl: WebGL2RenderingContext) => {
     gl.texImage2D(gl.TEXTURE_2D, 0, gl.RGBA, gl.RGBA, gl.UNSIGNED_BYTE, image);
     gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MAG_FILTER, gl.NEAREST);
     gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.NEAREST);
+    // 设置各向异性过滤参数  
+    const ext = gl.getExtension('EXT_texture_filter_anisotropic');  
+    if (ext) {  
+        const maxAnisotropy = gl.getParameter(ext.MAX_TEXTURE_MAX_ANISOTROPY_EXT);  
+        gl.texParameteri(gl.TEXTURE_2D, ext.TEXTURE_MAX_ANISOTROPY_EXT, maxAnisotropy); // 设置各向异性系数为6  
+    }  
     gl.bindTexture(gl.TEXTURE_2D, null);
   };
 
@@ -81,7 +94,7 @@ const getDrawVaring = (gl: WebGL2RenderingContext) => {
 
 export const init = (canvas: HTMLCanvasElement, initYaw: number) => {
   let activeTex: "skyCubeTex1" | "skyCubeTex" = "skyCubeTex1";
-  const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true })!;
+  const gl = canvas.getContext("webgl2", { preserveDrawingBuffer: true, antialias: true })!;
   const program = createProgram(gl, envVertSource, envFragSource);
   const program1 = createProgram(gl, envVertSource, envCubeFragSource);
 

+ 120 - 95
src/view/pano/pano.vue

@@ -1,6 +1,8 @@
 <template>
   <div class="pano-layout" v-loading="loading" :element-loading-text="loadingStr">
-    <canvas ref="panoDomRef"></canvas>
+    <div class="canvas-layout">
+      <canvas ref="panoDomRef"></canvas>
+    </div>
     <div class="btns">
       <el-button
         size="large"
@@ -10,14 +12,14 @@
       >
         屏幕拍照
       </el-button>
-      <el-input-number
+      <!-- <el-input-number
         style="margin-right: 20px"
         v-model="tempRadion"
         :precision="2"
         :step="0.01"
         :min="1"
         :max="3"
-      />
+      /> -->
       <el-button
         size="large"
         style="margin-right: 20px; width: 100px"
@@ -51,21 +53,34 @@ import SingleInput from "@/components/point-input.vue";
 import { router, setDocTitle } from "@/router";
 import { mergeFuns, round } from "@/util";
 import { glMatrix } from "gl-matrix";
-import { computed, nextTick, onMounted, onUnmounted, ref, watchEffect } from "vue";
-import { init } from "./env";
+import {
+  computed,
+  onActivated,
+  onMounted,
+  onUnmounted,
+  ref,
+  shallowRef,
+  watch,
+  watchEffect,
+} from "vue";
+import { init } from "./three-env";
+// import { init } from "./env";
 import {
   updateScenePointName,
   getPointPano,
   ScenePoint,
   scenePoints,
+  SceneType,
 } from "@/store/scene";
-import { copyText, toDegrees, getTextBound } from "@/util";
+import { copyText, toDegrees } from "@/util";
 import { ElMessage } from "element-plus";
 import saveAs from "@/util/file-serve";
 import { DeviceType } from "@/store/device";
 import { initRelics, relics } from "@/store/relics";
 import { noValidPoint } from "../map/install";
 import { addWatermark } from "@/util/image";
+import { inRevise } from "@/lib/board/4dmap";
+import { Texture } from "three";
 
 type Params = { pid?: string; relicsId?: string } | null;
 const params = computed(() => router.currentRoute.value.params as Params);
@@ -74,27 +89,37 @@ const destroyFns: (() => void)[] = [];
 const point = ref<ScenePoint>();
 const tempRadion = ref(3.0);
 
-watchEffect(() => {
-  if (params.value?.pid) {
-    const pid = Number(params.value!.pid);
+watch(
+  params,
+  (params) => {
+    if (!params?.pid) return;
+    const pid = Number(params!.pid);
     point.value = scenePoints.value.find((scene) => scene.id === pid);
-
+    try {
+      pano?.reset();
+    } catch {}
     if (!point.value) {
-      initRelics(Number(params.value.relicsId)).then(() => {
+      initRelics(Number(params.relicsId)).then(() => {
         point.value = scenePoints.value.find((scene) => scene.id === pid);
         if (!point.value) {
           router.replace({ name: "relics" });
         }
       });
     }
+  },
+  { immediate: true }
+);
+
+const panoUrls = ref<string | string[]>();
+watchEffect(() => {
+  if (point.value) {
+    panoUrls.value = getPointPano(
+      point.value,
+      [SceneType.CLUNT, SceneType.MESH].includes(point.value.sceneType)
+    );
   }
 });
 
-const panoUrls = computed(() => {
-  return (
-    point.value && getPointPano(point.value, point.value.cameraType === DeviceType.CLUNT)
-  );
-});
 const update = ref(false);
 const loading = ref(false);
 const loadingStr = ref("");
@@ -112,115 +137,114 @@ const copyGis = async () => {
   ElMessage.success("经纬度高程复制成功");
 };
 
-const canvas = document.createElement("canvas");
-// 水印添加函数
-const addWatermark = (imgURL: string, ration: number) => {
-  const ctx = canvas.getContext("2d");
-  const image = new Image();
-  image.src = imgURL;
-
-  return new Promise<string>((resolve, reject) => {
-    image.onload = () => {
-      canvas.width = image.width;
-      canvas.height = image.height;
-      ctx.drawImage(image, 0, 0, image.width, image.height);
-
-      const font = `${ration * 20}px Arial`;
-      const pos = point.value!.pos as number[];
-      const lines = `经度: ${toDegrees(pos[0])}\n纬度: ${toDegrees(pos[1])}`.split("\n");
-      const lineTopPadding = 5 * ration;
-      const lineBounds = lines.map((line) =>
-        getTextBound(line, font, [lineTopPadding, 0])
-      );
-      const bound = lineBounds.reduce(
-        (t, { width, height }) => {
-          t.width = Math.max(t.width, width);
-          t.height += height;
-          return t;
-        },
-        { width: 0, height: 0 }
-      );
-      const padding = 20 * ration;
-      const margin = 80 * ration;
-
-      const position = [
-        image.width - margin - bound.width,
-        image.height - margin - bound.height,
-      ];
-
-      ctx.rect(
-        position[0] - padding,
-        position[1] - padding,
-        bound.width + 2 * padding,
-        bound.height + 2 * padding
-      );
-      ctx.fillStyle = "rgba(0, 0, 0, 0.3)";
-      ctx.fill();
-
-      ctx.font = font;
-      ctx.textBaseline = "top";
-      ctx.fillStyle = "#fff";
-      let itemTop = 0;
-      lines.forEach((line, ndx) => {
-        ctx.fillText(line, position[0], position[1] + itemTop + lineTopPadding);
-        itemTop += lineBounds[ndx].height;
-      });
-      resolve(canvas.toDataURL("image/jpeg", 1));
-    };
-    image.onerror = reject;
-  });
-};
-
+let prevBigImages: string | string[];
+let prevBigTexs: Texture[];
 const photo = async () => {
   loading.value = true;
+  const bigImages = getPointPano(
+    point.value,
+    [SceneType.CLUNT, SceneType.MESH].includes(point.value.sceneType),
+    false
+  );
+  if (
+    inRevise(panoUrls.value, bigImages) &&
+    (!prevBigTexs || inRevise(prevBigImages, bigImages))
+  ) {
+    if (prevBigTexs) {
+      prevBigTexs.forEach((tex) => tex.dispose());
+    }
+    prevBigTexs = await pano.getTexs(bigImages);
+    prevBigImages = bigImages;
+    pano.changeEnv(prevBigTexs);
+    pano.redraw();
+  }
+
   loadingStr.value = "原图提取中";
   await new Promise((resolve) => setTimeout(resolve, 300));
   const ration = tempRadion.value;
-  console.log("ration", ration);
   setSize(ration, 1920, 1080);
-  let dataURL = panoDomRef.value.toDataURL("image/jpeg", 1);
+
+  let dataURL: Blob | string = panoDomRef.value.toDataURL("image/jpeg", 1);
   if (!noValidPoint(point.value)) {
     dataURL = await addWatermark(dataURL, point.value!.pos, ration);
   }
 
   await saveAs(dataURL, `${relics.value?.name}.jpg`);
   ElMessage.success("图片导出成功");
-  setSize(devicePixelRatio);
+  setSize(1);
+  pano.changeEnv(thumbnailTexs.value);
+  pano.redraw();
   loading.value = false;
+
+  prevBigTexs.forEach((tex) => tex.dispose());
+  prevBigTexs = null;
+  prevBigImages = null;
 };
 
 let pano: ReturnType<typeof init>;
 const setSize = (ration: number, w?: number, h?: number) => {
-  const canvas = panoDomRef.value!;
-  canvas.width = (w || canvas.offsetWidth) * ration;
-  canvas.height = (h || canvas.offsetHeight) * ration;
-  pano.setSize([canvas.width, canvas.height]);
+  const canvas = panoDomRef.value!.parentElement;
+
+  w = (w || canvas.offsetWidth) * ration;
+  h = (h || canvas.offsetHeight) * ration;
+  pano.setSize([w, h]);
+  pano.redraw();
 };
 
+const thumbnailTexs = shallowRef<Texture[]>([]);
 onMounted(() => {
   if (!panoDomRef.value) throw "没有canvas DOM";
-  pano = init(panoDomRef.value, 0);
+  pano = init(panoDomRef.value);
   const resizeHandler = () => {
-    setSize(devicePixelRatio);
+    setSize(1);
   };
   resizeHandler();
 
   window.addEventListener("resize", resizeHandler);
-  destroyFns.push(
-    watchEffect(() => {
-      if (panoUrls.value) {
+  const s1 = watch(
+    panoUrls,
+    (n, o) => {
+      if (inRevise(n, o) && n) {
         loading.value = true;
-        pano.changeUrls(panoUrls.value).then(() => (loading.value = false));
-        pano.setYaw(
-          point.value.cameraType === DeviceType.CLUNT ? glMatrix.toRadian(180) : 0
+        //     getPointPano(
+        //   point.value,
+        //   [SceneType.CLUNT, SceneType.MESH].includes(point.value.sceneType)
+        // )
+        pano.getTexs(n).then(
+          (texs) => {
+            loading.value = false;
+            thumbnailTexs.value.forEach((t) => t.dispose());
+            thumbnailTexs.value = texs;
+          },
+          (err) => {
+            loading.value = false;
+            panoUrls.value = getPointPano(
+              point.value,
+              [SceneType.CLUNT, SceneType.MESH].includes(point.value.sceneType),
+              false
+            );
+          }
         );
       }
-    }),
-    pano.destory,
-    () => {
-      window.removeEventListener("resize", resizeHandler);
-    }
+    },
+    { flush: "sync", immediate: true }
   );
+  const s2 = watchEffect(() => {
+    if (point.value) {
+      const yaw = point.value.cameraType === DeviceType.CLUNT ? 90 : -90;
+      pano.setYaw(glMatrix.toRadian(yaw));
+    }
+  });
+  const s3 = watchEffect(() => {
+    if (thumbnailTexs.value.length) {
+      pano.changeEnv(thumbnailTexs.value);
+    }
+  });
+
+  destroyFns.push(s1, s2, s3, pano.destory, () => {
+    thumbnailTexs.value.forEach((t) => t.dispose());
+    window.removeEventListener("resize", resizeHandler);
+  });
 });
 
 onUnmounted(() => mergeFuns(...destroyFns)());
@@ -233,6 +257,7 @@ watchEffect(() => {
 
 <style scoped lang="scss">
 .pano-layout,
+.canvas-layout,
 canvas {
   width: 100%;
   height: 100%;

+ 142 - 0
src/view/pano/three-env.ts

@@ -0,0 +1,142 @@
+import { ca } from "element-plus/es/locales.mjs";
+import {
+  BoxGeometry,
+  EquirectangularReflectionMapping,
+  Mesh,
+  MeshBasicMaterial,
+  PerspectiveCamera,
+  Raycaster,
+  Scene,
+  SphereGeometry,
+  SRGBColorSpace,
+  Texture,
+  TextureLoader,
+  Vector2,
+  Matrix4,
+  WebGLRenderer,
+  LoadingManager,
+  CubeTexture,
+  CubeTextureLoader,
+} from "three";
+import { OrbitControls } from "three/examples/jsm/Addons.js";
+
+export const init = (canvas: HTMLCanvasElement) => {
+  const manager = new LoadingManager()
+  const texLoader = new TextureLoader(manager);
+  const cubeLoader = new CubeTextureLoader(manager)
+  const scene = new Scene();
+  const camera = new PerspectiveCamera(70, 1, 1, 8000);
+  const rang = 4000;
+
+  const controls = new OrbitControls(camera, canvas.parentElement);
+  controls.target.set(0, 0, 0)
+  controls.enableDamping = true;
+  controls.enableZoom = false;
+  controls.enablePan = false;
+  controls.rotateSpeed = -0.25;
+
+  camera.position.set(0, 0, -1)
+
+  // 处理鼠标滚轮事件以实现 zoomToCursor 效果
+  function onMouseWheel(event) {
+    event.preventDefault();
+    const scaleFactor = 0.01;
+    const delta = event.deltaY;
+    camera.fov = Math.min(Math.max(30, camera.fov + scaleFactor * delta), 89)
+    camera.updateProjectionMatrix()
+
+    // // 获取当前鼠标位置
+    // const mouse = new Vector2();
+    // mouse.x = (event.clientX / window.innerWidth) * 2 - 1; // 转换为 [-1, 1]
+    // mouse.y = -(event.clientY / window.innerHeight) * 2 + 1; // 转换为 [-1, 1]
+
+    // // 使用 Raycaster 将鼠标位置转换为场景中的坐标
+    // const raycaster = new Raycaster();
+    // raycaster.setFromCamera(mouse, camera);
+    // const intersection = raycaster.intersectObject(cube); // 可以修改为场景中的其他对象
+
+    // if (intersection.length > 0) {
+    //   const scaleFactor = 0.1;
+    //   const delta = -event.deltaY;
+    //   const moveLen = delta * scaleFactor
+    //   const dire = intersection[0].point.sub(camera.position).normalize()
+    //   const origin = camera.position.clone()
+    //   origin.add(dire.clone().multiplyScalar(moveLen))
+
+    //   if (origin.length() < rang / 10) {
+    //     const targetDire = controls.target.clone().sub(camera.position)
+    //     camera.position.copy(origin)
+    //     controls.target.copy(origin.add(targetDire))
+    //     controls.update()
+    //   }
+    // }
+  }
+
+  // 监听滚轮事件
+  canvas.parentElement.addEventListener("wheel", onMouseWheel, { passive: false });
+
+
+  const redraw = () => {
+    renderer.clear()
+    controls.update();
+    renderer.render(scene, camera);
+  }
+  
+  const renderer = new WebGLRenderer({ antialias: true, canvas });
+  renderer.setPixelRatio(1);
+  renderer.autoClear = false
+  renderer.setAnimationLoop(redraw);
+
+  const setSize = ([w, h]: [number, number]) => {
+    camera.aspect = w / h;
+    camera.updateProjectionMatrix();
+    renderer.setSize(w, h);
+  };
+
+  let mesh: Mesh;
+  let prevMat: Matrix4
+  return {
+    setSize,
+    redraw,
+    async getTexs(urls: string | string[]) {
+      urls = Array.isArray(urls) ? urls : [urls]
+      const texs: Texture[] = []
+      if (urls.length === 1) {
+        texs.push(texLoader.load(urls[0]))
+        texs[0].mapping = EquirectangularReflectionMapping;
+      } else {
+        const mapper = [2, 4, 0, 5, 1, 3];
+        texs.push(cubeLoader.load(mapper.map(i => urls[i])))
+      }
+      texs.forEach(tex => tex.colorSpace = SRGBColorSpace)
+      
+      await new Promise<void>((resolve, reject) => {
+        manager.onLoad = resolve
+        manager.onError = reject
+      })
+      return texs;
+    },
+    async changeEnv(texs: Texture[]) {
+      mesh && scene.remove(mesh);
+      scene.background = texs[0]
+    },
+    reset() {
+      camera.fov = 70
+      camera.updateProjectionMatrix()
+    },
+    setYaw(yaw: number) {
+      const mat = new Matrix4().makeRotationY(-yaw)
+      if (prevMat) {
+        mat.multiply(prevMat.invert())
+      }
+      prevMat = mat
+      camera.position.applyMatrix4(mat)
+      console.log({...camera.position.clone()})
+      controls.update()
+    },
+    destory() {
+      window.removeEventListener("wheel", onMouseWheel);
+      renderer.dispose();
+    },
+  };
+};