Visual LabINTERACTIVE LEARNING
OpenGL 与 3D第 3 / 4 章

旋转、平移与缩放算法

把画布输入转换为相机空间和世界空间中的具体动作。

OrbitControls相机交互
专题预计 52 分钟查看源文 ↗

camera-actions

    function getAutoRotationAngle(deltaTime) {
      if (deltaTime !== null) {
        return ((2 * Math.PI) / 60) * scope.autoRotateSpeed * deltaTime
      } else {
        return ((2 * Math.PI) / 60 / 60) * scope.autoRotateSpeed
      }
    }

    function rotateLeft(angle) {
      spherical.theta -= angle * 0.6
    }

    function rotateUp(angle) {
      spherical.phi -= angle * 0.6
    }

    //实际dolly的函数
    function dollyToCanvasPos(optionalTargetWorldPos, targetCanvasPos, dollyDelta) {
      const camera = scope.object;
      let dolliedThroughSurface = false
      if (optionalTargetWorldPos) {
        const eyeToWorldPosVec = new Vector3().subVectors(optionalTargetWorldPos, camera.position);
        const eyeWorldPosDist = eyeToWorldPosVec.length();
        dolliedThroughSurface = (eyeWorldPosDist < dollyDelta);
      }
      if (camera.type === "PerspectiveCamera") {

          const unprojectedWorldPos= _unproject(targetCanvasPos)
          const offset = new Vector3().subVectors(unprojectedWorldPos, camera.position);
          const moveVec = offset.clone().normalize().multiplyScalar( -dollyDelta);
          camera.position.sub(moveVec)
          scope.target.sub(moveVec)
          //if (optionalTargetWorldPos) {
           //   const eyeTargetVec = new Vector3().subVectors(optionalTargetWorldPos, camera.position);
             // const lenEyeTargetVec = eyeTargetVec.length();
              //const eyeLookVec = eyeTargetVec.normalize().multiplyScalar(lenEyeTargetVec);
              //scope.target.add(eyeLookVec)
          //}
      }
      return dolliedThroughSurface
    }
    //将画布坐标转换为世界坐标
    function _unproject(canvasPos, worldPos = new Vector3()) {

      const camera = scope.object;

        // 获取投影矩阵
        const projMatrix = camera.projectionMatrix;

        // 获取转置矩阵的第三行和第四行
        // Three.js中矩阵是列主序4x4矩阵,存储在Float32Array中
        const Pt3 = [
            projMatrix.elements[2],  // m20
            projMatrix.elements[6],  // m21
            projMatrix.elements[10], // m22
            projMatrix.elements[14]  // m23
        ];

        const Pt4 = [
            projMatrix.elements[3],  // m30
            projMatrix.elements[7],  // m31
            projMatrix.elements[11], // m32
            projMatrix.elements[15]  // m33
        ];

        const D = [0, 0, -1.0, 1];

        // 计算screenZ
        const screenZ = (
            D[0] * Pt3[0] + D[1] * Pt3[1] + D[2] * Pt3[2] + D[3] * Pt3[3]
        ) / (
            D[0] * Pt4[0] + D[1] * Pt4[1] + D[2] * Pt4[2] + D[3] * Pt4[3]
        );

        // 将画布坐标转换为标准化设备坐标 (NDC)
        const ndcX = (canvasPos.x / scope.domElement.clientWidth) * 2 - 1;
        const ndcY = -(canvasPos.y / scope.domElement.clientHeight) * 2 + 1;
        // 创建NDC坐标
        const ndcPos = new Vector3(ndcX, ndcY, screenZ);

        // 创建投影矩阵和视图矩阵的逆矩阵
        const projInverse = camera.projectionMatrix.clone().invert();
        const viewInverse = camera.matrixWorld; // 相机的世界矩阵就是视图矩阵的逆

        // 应用逆变换
        ndcPos.applyMatrix4(projInverse);
        ndcPos.applyMatrix4(viewInverse);

        // 将结果复制到worldPos
        worldPos.set(ndcPos.x, ndcPos.y, ndcPos.z);

        return worldPos;
    }
    //
    // event callbacks - update the object state
    //
    function handleMouseDownDolly(event) {
      dollyStart.set(event.clientX, event.clientY)
    }

    /**
     * 鼠标点击平移
     * @param {*} event
     */
    function handleMouseDownPan(event) {
      panStart.set(event.clientX, event.clientY)
      const mouseDownPicked = getIntersectPoint(event)
      dollyState.changePos = false
      if(mouseDownPicked){
        panTargrt.copy(intersectPoint)
      } else {
        panTargrt.copy(scope.target)
      }
    }

    /**
     * 获取鼠标点击的射线与场景的交点
     * @param {*} event
     * @returns
     */
    function getIntersectPoint(event) {
      const intersects = scope.three.setMouseRayCaster(event.clientX, event.clientY)
      if (intersects.length) {
        intersectPoint.copy(intersects[0].point)
        return true
      }
      return false
    }
    
     function genViewMat4(pos, target, up,dest=tempMatrix4){
      const zAxis = new Vector3().subVectors(pos, target).normalize()
      const xAxis = new Vector3().crossVectors(up, zAxis).normalize()
      const yAxis = new Vector3().crossVectors(zAxis, xAxis).normalize()

      //得到一个正交矩阵,这个矩阵是相机坐标系下的lookat矩阵
      dest.makeBasis(xAxis, yAxis, zAxis, dest)

      //求逆矩阵,得到view矩阵,但是由于正交矩阵的逆矩阵就是其转置矩阵,所以直接转置即可,避免计算逆矩阵
      dest.transpose()

      dest.elements[12] = -pos.dot(xAxis)
      dest.elements[13] = -pos.dot(yAxis)
      dest.elements[14] = -pos.dot(zAxis)

      return dest

    }

    /**
     * 鼠标点击旋转
     * @param {*} event
     */
    function handleMouseDownRotate(event) {
      rotateStart.set(event.clientX, event.clientY)
      const position = scope.object.position
      const hasIntersectPoint = getIntersectPoint(event)
      if(hasIntersectPoint){
        scope.pivot.copy(intersectPoint)
      }
      // 相机的lookat矩阵,即相机的视图矩阵
      let lookat = scope.object.matrixWorldInverse.clone()

      // 将pivot点转换到相机坐标系下
      cameraOffset.copy(scope.pivot).applyMatrix4(lookat)
      // 计算相机位置到pivot点的距离
      const distance = position.distanceTo(scope.pivot);
      // 将距离加到相机偏移量的z轴上
      cameraOffset.z += distance;
      tempMatrix3.setFromMatrix4(scope.object.matrixWorld)

      // 将pivot点在相机坐标系下的偏移后的坐标转换到世界坐标系下
      const offset = cameraOffset.clone().applyMatrix3(tempMatrix3)

      tempDiff.subVectors(position, scope.pivot).add(offset);
      spherical.setFromVector3(tempDiff)

    }

    // 鼠标移动旋转
    function handleMouseMoveRotate(event) {
      rotateEnd.set(event.clientX, event.clientY)

      rotateDelta.subVectors(rotateEnd, rotateStart).multiplyScalar(scope.rotateSpeed)

      const element = scope.domElement

      const rotateX = (2 * Math.PI * rotateDelta.x) / element.clientHeight // yes, height
      const rotateY = (2 * Math.PI * rotateDelta.y) / element.clientHeight

      rotateLeft(rotateX)
      rotateUp(rotateY)

      // 确保球面坐标系中的角度在安全范围内
      spherical.makeSafe()
      // pos是pivotPos的坐标
      const pos = new Vector3().setFromSpherical(spherical);
      // 计算当前相机视点(eye)到观察点(look)的距离,这个距离在旋转过程中需要保持不变
      const eyeLookLen = scope.object.position.distanceTo(scope.target);
      // 获取当前的pivot点位置(旋转中心)
      const pivotPos = scope.pivot;

      // 将之前计算的球面坐标pos(相对于pivot点的偏移)加上pivot点的位置,得到新的相机位置
      pos.add(pivotPos);
      // 使用新的相机位置(pos)、pivot点位置和世界坐标系的up向量创建视图矩阵
      // 这个矩阵定义了相机的新姿态
      let lookat = lookAtMat4v(pos, pivotPos, new Vector3(0, 1, 0));
      // 将视图矩阵求逆,得到相机的世界变换矩阵
      // 这个矩阵包含了相机的位置和方向信息
      lookat.invert()
      // 将相机偏移量从相机空间转换到世界空间

      const offset = cameraOffset.clone().applyMatrix3(tempMatrix3.setFromMatrix4(lookat))

      // 应用偏移量,调整相机的最终位置

      lookat.setPosition(lookat.elements[12] - offset.x, lookat.elements[13] - offset.y, lookat.elements[14] - offset.z)
      // 从lookat矩阵中提取相机的前向方向(z轴),也就是相机空间的z轴,在世界空间下的表示
      const zAxis = new Vector3().setFromMatrixColumn(lookat, 2).normalize()
      // 设置新的相机位置(position)
      const position = new Vector3().setFromMatrixPosition(lookat)
      scope.object.position.copy(position);

    // 计算新的观察点(look)位置
    // 通过从相机位置沿着视线方向(zAxis)前进eyeLookLen距离
      scope.target.subVectors(scope.object.position, zAxis.multiplyScalar(eyeLookLen));
      // 设置相机的up向量

      scope.object.up.set(lookat.elements[4], lookat.elements[5], lookat.elements[6])

      rotateStart.set(event.clientX, event.clientY)

    }

    function handleMouseMoveDolly(event) {
      dollyEnd.set(event.clientX, event.clientY)
      dollyDelta.subVectors(dollyEnd, dollyStart)
      if (dollyDelta.y > 0) {
        calculatePanDelta(-1,event)
      } else if (dollyDelta.y < 0) {
        calculatePanDelta(1,event)
      }
      dollyStart.copy(dollyEnd)
    }
    /**
     * 新的pan函数,平移
     * @param {*} pan
     */
    function newPan(pan) {
        const eye2 = tempVec3.subVectors(scope.object.position, scope.target);
        const vec = new Vector3()
        if (pan.x !== 0) {
            const left = tempVec3b.crossVectors(eye2.normalize(), scope.object.up.normalize());
            vec.add(left.multiplyScalar(pan.x))
        }
        if (pan.y !== 0) {
            vec.add(tempVec3b.copy(scope.object.up).multiplyScalar(pan.y))
        }
        if (pan.z !== 0) {
            vec.add(tempVec3b.copy(eye2).multiplyScalar(pan.z))
        }
        scope.object.position.add(vec)
        scope.target.add(vec)
    }

    function panLeftUp(panOffset) {
      const  panDepth = scope.object.position.distanceTo(panTargrt)
      const targetDistance = panDepth * Math.tan((scope.object.fov / 2) * Math.PI / 180.0);
      panDelta.x += (panOffset.x * targetDistance / scope.domElement.clientHeight);
      panDelta.y += ( panOffset.y * targetDistance / scope.domElement.clientHeight);
      if (Math.abs(panDelta.x) < EPSILON) {
        panDelta.x = 0;
      }

      if (Math.abs(panDelta.y) < EPSILON) {
        panDelta.y = 0;
      }

      if (Math.abs(panDelta.z) < EPSILON) {
        panDelta.z = 0;
      }
      newPan(panDelta)
      panDelta.set(0,0,0)

    }
    // 鼠标移动平移
    function handleMouseMovePan(event) {
      panEnd.set(event.clientX, event.clientY)
      panOffset.subVectors(panEnd, panStart).multiplyScalar(scope.panSpeed)
      panOffset.z = 0
      panLeftUp(panOffset)
      panStart.set(event.clientX, event.clientY)
    }

    // 响应dolly操作,delta为放大或缩小
    function calculatePanDelta(delta,event){
      const secsNow = performance.now() / 1000.0;
      let secsElapsed = (secsNowLast !== null) ? (secsNow - secsNowLast) : 0;
      secsNowLast = secsNow;
      if (secsElapsed > maxElapsed) {
          secsElapsed = maxElapsed;
      }
      if (secsElapsed < minElapsed) {
          secsElapsed = minElapsed;
      }
      if (Math.abs(dollyDelta1) < EPSILON) {
        dollyDelta1 = 0;
      }

      if (delta === 0) {
          return;
      }
      const normalizedDelta = delta / Math.abs(delta);
      dollyDelta1 += -normalizedDelta * secsElapsed * mouseWheelDollyRate;

      let target = null

      if(dollyState.changePos ){
        const hasIntersectPoint = getIntersectPoint(event)
        if(hasIntersectPoint){
          target = intersectPoint
          dollyState.changePos = false
        } else {
          dollyDistFactor = 1.0
          target = null
        }
      }
      if(target){
        const dist = Math.abs(scope.object.position.distanceTo(target));
        dollyDistFactor = dist / dollyProximityThreshold;

      }

      dollyDistFactor = Math.max(Math.min(dollyDistFactor, dollyMaxSpeed), dollyMinSpeed)

      let dollyDelta = dollyDelta1 * dollyDistFactor

      const isPass = dollyToCanvasPos(target, new Vector2(event.clientX, event.clientY), -dollyDelta)

      if(isPass){
        dollyState.changePos = true
      }
      dollyDelta1 = 0
    }

    function handleMouseWheel(event) {

      //放大delta >0 缩小delta <0
      const delta = Math.max(-1, Math.min(1, -event.deltaY * 40));
      calculatePanDelta(delta,event)

    }

    function handleKeyDown(event) {
      let needsUpdate = false
      let speed = 1
      if(event.shiftKey){
        //加速
        speed *= scope.shiftSpeed
      }

      const filterRes = Object.entries(scope.keys).filter(([_, value]) => value.includes(event.code))
      if (filterRes.length) {
        const [aspect] = filterRes[0]
        switch(aspect){
          case 'UP':
            panLeftUp(new Vector3(0, -speed*scope.keyPanSpeed, 0))
            break
          case 'BOTTOM':
            panLeftUp(new Vector3(0, speed*scope.keyPanSpeed, 0))
            break
          case 'LEFT':
            panLeftUp(new Vector3(-speed*scope.keyPanSpeed,0,  0))
            break
          case 'RIGHT':
            panLeftUp(new Vector3(speed*scope.keyPanSpeed,0,  0))
            break
          case 'ZOOM_IN':
            dollyToCanvasPos(null, new Vector2(scope.domElement.clientWidth/2, scope.domElement.clientHeight/2), speed*scope.keyDollySpeed)
            break
          case 'ZOOM_OUT':
            dollyToCanvasPos(null, new Vector2(scope.domElement.clientWidth/2, scope.domElement.clientHeight/2), -speed*scope.keyDollySpeed)
            break

        }
        needsUpdate = true
      }

      if (needsUpdate) {
        // prevent the browser from scrolling on cursor keys
        event.preventDefault()
      }
    }

    //
    // event handlers - FSM: listen for events and reset state
    //