WebGL实现soul星球效果

WebGL实现soul星球效果

最近在研究webGL,觉得soul app的星球挺有意思的,于是就实现了一下,中间涉及的细节和知识点挺多的,写篇博客分享一下

soul原版

WebGL实现的

主要技术要点

1.自由转动

因为要解决万向锁的问题,所以不能使用rotateXrotateYrotateZ来旋转,应当使用四元数THREE.Quaternion

2.背面小球变暗

这里通过内部放置了一个半透明的黑色小球来实现

js 复制代码
// 创建半透明球体
const sphereGeometry = new THREE.SphereGeometry(4.85, 16, 16)

为了使小球从正面转动的背面的过程中可以平滑的变暗,这里还需要把半透明小球的边沿处理成高斯模糊,具体实现就是使用GLSL的插值函数smoothstep

js 复制代码
fragmentShader: `
  uniform vec3 color;
  uniform float opacity;
  varying vec3 vNormal;
  void main() {
      float alpha = opacity * smoothstep(0.5, 1.0, vNormal.z);
      gl_FragColor = vec4(color, alpha);
  }

但是需要注意的是需要关闭小球的深度测试,否则会遮挡小球

js 复制代码
side: THREE.FrontSide,
depthWrite: false,
3.使用THREE.Sprite创建小球标签
4.标签位置计算
js 复制代码
for (let i = 0; i < numPoints; i++) {
    const y = 1 - (i / (numPoints - 1)) * 2
    const radiusAtY = Math.sqrt(1 - y * y)

    const theta = (2 * Math.PI * i) / goldenRatio

    const x = Math.cos(theta) * radiusAtY
    const z = Math.sin(theta) * radiusAtY
    const smallBallMaterial = new THREE.MeshBasicMaterial({
      color: getRandomBrightColor(),
      depthWrite: true,
      depthTest: true,
      side: THREE.FrontSide,
    })
    const smallBall = new THREE.Mesh(smallBallGeometry, smallBallMaterial)
    smallBall.position.set(x * radius, y * radius, z * radius)
5.超出长度的标签采用贴图采样位移来实现跑马灯效果
6.滚动阻尼,鼠标转动球体之后速度能衰减到转动旋转的速率
7.自动旋转需要保持上一次滚动的方向
8.使用射线拾取来实现点击交互

完整代码

html 复制代码
<!DOCTYPE html>
<html lang="zh">
  <head>
    <meta charset="UTF-8" />
    <title>3D 半透明球体与可交互小球</title>
    <style>
      body {
        margin: 0;
        background-color: black;
        touch-action: none;
      }
      canvas {
        display: block;
      }
    </style>
  </head>
  <body>
    <script type="module">
      import * as THREE from 'https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js'

      // 创建场景
      const scene = new THREE.Scene()

      // 创建相机
      const camera = new THREE.PerspectiveCamera(
        75,
        window.innerWidth / window.innerHeight,
        0.1,
        1000
      )
      camera.position.set(0, 0, 14)
      camera.lookAt(0, 0, 0)

      // 创建渲染器
      const renderer = new THREE.WebGLRenderer({ antialias: true, alpha: true })
      renderer.setSize(window.innerWidth, window.innerHeight)
      renderer.setPixelRatio(window.devicePixelRatio)
      renderer.setClearColor(0x000000, 0)
      document.body.appendChild(renderer.domElement)

      // 创建半透明球体
      const sphereGeometry = new THREE.SphereGeometry(4.85, 16, 16)
      const sphereMaterial = new THREE.ShaderMaterial({
        uniforms: {
          color: { value: new THREE.Color(0x000000) },
          opacity: { value: 0.8 },
        },
        vertexShader: `
          varying vec3 vNormal;
          void main() {
              vNormal = normalize(normalMatrix * normal);
              gl_Position = projectionMatrix * modelViewMatrix * vec4(position,1.0);
          }
        `,
        fragmentShader: `
          uniform vec3 color;
          uniform float opacity;
          varying vec3 vNormal;
          void main() {
              float alpha = opacity * smoothstep(0.5, 1.0, vNormal.z);
              gl_FragColor = vec4(color, alpha);
          }
        `,
        transparent: true,
        side: THREE.FrontSide,
        depthWrite: false,
      })

      const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial)
      scene.add(sphere)

      // 创建小球体和标签数组
      const smallBallGeometry = new THREE.SphereGeometry(0.15, 16, 16)
      const smallBalls = []
      const labelSprites = []

      const radius = 5
      const numPoints = 88
      const goldenRatio = (1 + Math.sqrt(5)) / 2
      const maxWidth = 160
      const textSpeed = 0.002

      // 创建射线投射器
      const raycaster = new THREE.Raycaster()
      const mouse = new THREE.Vector2()

      function createTextTexture(text, parameters = {}) {
        const {
          fontSize = 24,
          fontFace = 'PingFang SC, Microsoft YaHei, Noto Sans, Arial, sans-serif',
          textColor = 'white',
          backgroundColor = 'rgba(0,0,0,0)',
          maxWidth = 160,
        } = parameters

        const canvas = document.createElement('canvas')
        const context = canvas.getContext('2d')
        context.font = `${fontSize}px ${fontFace}`

        const textMetrics = context.measureText(text)
        const textWidth = Math.ceil(textMetrics.width)
        const textHeight = fontSize * 1.2

        const needMarquee = textWidth > maxWidth

        let canvasWidth = maxWidth
        if (needMarquee) {
          canvasWidth = textWidth + 60
        }

        canvas.width = canvasWidth
        canvas.height = textHeight
        context.font = `${fontSize}px ${fontFace}`
        context.clearRect(0, 0, canvas.width, canvas.height)

        context.fillStyle = backgroundColor
        context.fillRect(0, 0, canvas.width, canvas.height)

        context.fillStyle = textColor
        context.textAlign = needMarquee ? 'left' : 'center'
        context.textBaseline = 'middle'

        if (needMarquee) {
          context.fillText(text, 0, canvas.height / 2)
        } else {
          context.fillText(text, maxWidth / 2, canvas.height / 2)
        }

        const texture = new THREE.CanvasTexture(canvas)
        texture.needsUpdate = true

        if (needMarquee) {
          texture.wrapS = THREE.RepeatWrapping
          texture.wrapT = THREE.ClampToEdgeWrapping
          texture.repeat.x = maxWidth / canvas.width
        } else {
          texture.wrapS = THREE.ClampToEdgeWrapping
          texture.wrapT = THREE.ClampToEdgeWrapping
        }

        texture.minFilter = THREE.LinearFilter
        texture.magFilter = THREE.LinearFilter
        texture.generateMipmaps = false
        return { texture, needMarquee, HWRate: textHeight / maxWidth }
      }

      for (let i = 0; i < numPoints; i++) {
        const y = 1 - (i / (numPoints - 1)) * 2
        const radiusAtY = Math.sqrt(1 - y * y)

        const theta = (2 * Math.PI * i) / goldenRatio

        const x = Math.cos(theta) * radiusAtY
        const z = Math.sin(theta) * radiusAtY
        const smallBallMaterial = new THREE.MeshBasicMaterial({
          color: getRandomBrightColor(),
          depthWrite: true,
          depthTest: true,
          side: THREE.FrontSide,
        })
        const smallBall = new THREE.Mesh(smallBallGeometry, smallBallMaterial)
        smallBall.position.set(x * radius, y * radius, z * radius)
        sphere.add(smallBall)
        smallBalls.push(smallBall)

        const labelText = getRandomNickname()
        const { texture, needMarquee, HWRate } = createTextTexture(labelText, {
          fontSize: 28,
          fontFace: 'PingFang SC, Microsoft YaHei, Noto Sans, Arial, sans-serif',
          textColor: '#bbbbbb',
          maxWidth: maxWidth,
        })

        const spriteMaterial = new THREE.SpriteMaterial({
          map: texture,
          transparent: true,
          depthWrite: true,
          depthTest: true,
          blending: THREE.NormalBlending,
        })

        const sprite = new THREE.Sprite(spriteMaterial)
        sprite.scale.set(1, HWRate, 1)
        labelSprites.push({ sprite, smallBall, texture, needMarquee, labelText })
        scene.add(sprite)
      }

      // 添加灯光
      const light = new THREE.AmbientLight(0xffffff, 0.5)
      scene.add(light)
      const directionalLight = new THREE.DirectionalLight(0xffffff, 1)
      directionalLight.position.set(5, 5, 5)
      scene.add(directionalLight)

      // 定义自动旋转速度和轴
      const autoRotationSpeed = 0.0005
      let autoRotationAxis = new THREE.Vector3(0, 1, 0).normalize()
      let currentAngularVelocity = autoRotationAxis.clone().multiplyScalar(autoRotationSpeed)

      let isDragging = false
      let previousMousePosition = { x: 0, y: 0 }
      let lastDragDelta = { x: 0, y: 0 }

      const decayRate = 0.92
      const increaseRate = 1.02

      // 鼠标事件处理
      const onMouseDown = (event) => {
        isDragging = true
        previousMousePosition = {
          x: event.clientX,
          y: event.clientY,
        }
      }

      const onMouseMove = (event) => {
        if (isDragging) {
          const deltaX = event.clientX - previousMousePosition.x
          const deltaY = event.clientY - previousMousePosition.y

          lastDragDelta = { x: deltaX, y: deltaY }

          const rotationFactor = 0.005

          const angleY = deltaX * rotationFactor
          const angleX = deltaY * rotationFactor

          const quaternionY = new THREE.Quaternion().setFromAxisAngle(
            new THREE.Vector3(0, 1, 0),
            angleY
          )
          const quaternionX = new THREE.Quaternion().setFromAxisAngle(
            new THREE.Vector3(1, 0, 0),
            angleX
          )

          const deltaQuat = new THREE.Quaternion().multiplyQuaternions(quaternionY, quaternionX)

          sphere.quaternion.multiplyQuaternions(deltaQuat, sphere.quaternion)

          const dragRotationAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
          const dragRotationSpeed = Math.sqrt(deltaX * deltaX + deltaY * deltaY) * rotationFactor

          if (dragRotationAxis.length() > 0) {
            currentAngularVelocity.copy(dragRotationAxis).multiplyScalar(dragRotationSpeed)
          }

          previousMousePosition = {
            x: event.clientX,
            y: event.clientY,
          }
        }
      }

      const onMouseUp = () => {
        if (isDragging) {
          isDragging = false

          const deltaX = lastDragDelta.x
          const deltaY = lastDragDelta.y

          if (deltaX !== 0 || deltaY !== 0) {
            const newAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
            if (newAxis.length() > 0) {
              autoRotationAxis.copy(newAxis)
            }

            const dragSpeed = currentAngularVelocity.length()
            if (dragSpeed > autoRotationSpeed) {
              // 维持当前旋转速度
            } else {
              currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
            }
          }
        }
      }

      // 触摸事件处理
      const onTouchStart = (event) => {
        isDragging = true
        const touch = event.touches[0]
        previousMousePosition = {
          x: touch.clientX,
          y: touch.clientY,
        }
      }

      const onTouchMove = (event) => {
        event.preventDefault()
        if (isDragging) {
          const touch = event.touches[0]
          const deltaX = touch.clientX - previousMousePosition.x
          const deltaY = touch.clientY - previousMousePosition.y

          lastDragDelta = { x: deltaX, y: deltaY }

          const rotationFactor = 0.002

          const angleY = deltaX * rotationFactor
          const angleX = deltaY * rotationFactor

          const quaternionY = new THREE.Quaternion().setFromAxisAngle(
            new THREE.Vector3(0, 1, 0),
            angleY
          )
          const quaternionX = new THREE.Quaternion().setFromAxisAngle(
            new THREE.Vector3(1, 0, 0),
            angleX
          )

          const deltaQuat = new THREE.Quaternion().multiplyQuaternions(quaternionY, quaternionX)

          sphere.quaternion.multiplyQuaternions(deltaQuat, sphere.quaternion)

          const dragRotationAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
          const dragRotationSpeed = Math.sqrt(deltaX * deltaX + deltaY * deltaY) * rotationFactor

          if (dragRotationAxis.length() > 0) {
            currentAngularVelocity.copy(dragRotationAxis).multiplyScalar(dragRotationSpeed)
          }

          previousMousePosition = {
            x: touch.clientX,
            y: touch.clientY,
          }
        }
      }

      const onTouchEnd = (event) => {
        if (isDragging) {
          isDragging = false

          const deltaX = lastDragDelta.x
          const deltaY = lastDragDelta.y

          if (deltaX !== 0 || deltaY !== 0) {
            const newAxis = new THREE.Vector3(deltaY, deltaX, 0).normalize()
            if (newAxis.length() > 0) {
              autoRotationAxis.copy(newAxis)
            }

            const dragSpeed = currentAngularVelocity.length()
            if (dragSpeed > autoRotationSpeed) {
              // 维持当前旋转速度
            } else {
              currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
            }
          }
        }

        // 检查点击事件
        if (event.changedTouches.length > 0) {
          const touch = event.changedTouches[0]
          mouse.x = (touch.clientX / window.innerWidth) * 2 - 1
          mouse.y = -(touch.clientY / window.innerHeight) * 2 + 1
          checkIntersection()
        }
      }

      // 事件监听
      window.addEventListener('mousedown', onMouseDown)
      window.addEventListener('mousemove', onMouseMove)
      window.addEventListener('mouseup', onMouseUp)
      window.addEventListener('touchstart', onTouchStart)
      window.addEventListener('touchmove', onTouchMove)
      window.addEventListener('touchend', onTouchEnd)
      document.addEventListener('gesturestart', function (e) {
        e.preventDefault()
      })

      // 添加点击事件监听
      window.addEventListener('click', onMouseClick)

      // 处理窗口大小调整
      window.addEventListener('resize', () => {
        camera.aspect = window.innerWidth / window.innerHeight
        camera.updateProjectionMatrix()
        renderer.setSize(window.innerWidth, window.innerHeight)
      })

      function onMouseClick(event) {
        event.preventDefault()
        mouse.x = (event.clientX / window.innerWidth) * 2 - 1
        mouse.y = -(event.clientY / window.innerHeight) * 2 + 1
        console.log(event.clientX, mouse.x, mouse.y)

        checkIntersection()
      }

      function checkIntersection() {
        raycaster.setFromCamera(mouse, camera)
        const intersects = raycaster.intersectObjects(smallBalls)

        if (intersects.length > 0) {
          const intersectedBall = intersects[0].object
          const index = smallBalls.indexOf(intersectedBall)
          if (index !== -1) {
            const labelInfo = labelSprites[index]
            showLabelInfo(labelInfo)
          }
        }
      }

      function showLabelInfo(labelInfo) {
        alert(`点击的小球标签:${labelInfo.labelText}`)
      }

      // 动画循环
      function animate() {
        requestAnimationFrame(animate)

        if (!isDragging) {
          const deltaQuat = new THREE.Quaternion().setFromEuler(
            new THREE.Euler(
              currentAngularVelocity.x,
              currentAngularVelocity.y,
              currentAngularVelocity.z,
              'XYZ'
            )
          )
          sphere.quaternion.multiplyQuaternions(deltaQuat, sphere.quaternion)

          const currentSpeed = currentAngularVelocity.length()

          if (currentSpeed > autoRotationSpeed) {
            currentAngularVelocity.multiplyScalar(decayRate)

            if (currentAngularVelocity.length() < autoRotationSpeed) {
              currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
            }
          } else if (currentSpeed < autoRotationSpeed) {
            currentAngularVelocity.multiplyScalar(increaseRate)

            if (currentAngularVelocity.length() > autoRotationSpeed) {
              currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
            }
          } else {
            currentAngularVelocity.copy(autoRotationAxis).multiplyScalar(autoRotationSpeed)
          }
        }

        // 更新标签的位置和跑马灯效果
        labelSprites.forEach(({ sprite, smallBall, texture, needMarquee }) => {
          smallBall.updateMatrixWorld()
          const smallBallWorldPos = new THREE.Vector3()
          smallBall.getWorldPosition(smallBallWorldPos)

          const upOffset = new THREE.Vector3(0, 0.3, 0)

          sprite.position.copy(smallBallWorldPos).add(upOffset)

          if (needMarquee) {
            texture.offset.x += textSpeed

            if (texture.offset.x > 1) {
              texture.offset.x = 0
            }
          }
        })

        renderer.render(scene, camera)
      }

      animate()

      function getRandomBrightColor() {
        const hue = Math.floor(Math.random() * 360)
        const saturation = Math.floor(Math.random() * 40 + 10)
        const lightness = Math.floor(Math.random() * 40 + 40)

        const rgb = hslToRgb(hue, saturation, lightness)

        return (rgb.r << 16) | (rgb.g << 8) | rgb.b
      }

      function hslToRgb(h, s, l) {
        s /= 100
        l /= 100

        const c = (1 - Math.abs(2 * l - 1)) * s
        const x = c * (1 - Math.abs(((h / 60) % 2) - 1))
        const m = l - c / 2

        let r, g, b
        if (h >= 0 && h < 60) {
          r = c
          g = x
          b = 0
        } else if (h >= 60 && h < 120) {
          r = x
          g = c
          b = 0
        } else if (h >= 120 && h < 180) {
          r = 0
          g = c
          b = x
        } else if (h >= 180 && h < 240) {
          r = 0
          g = x
          b = c
        } else if (h >= 240 && h < 300) {
          r = x
          g = 0
          b = c
        } else {
          r = c
          g = 0
          b = x
        }

        return {
          r: Math.round((r + m) * 255),
          g: Math.round((g + m) * 255),
          b: Math.round((b + m) * 255),
        }
      }

      function getRandomNickname() {
        const adjectives = [
          'Cool',
          'Crazy',
          'Mysterious',
          'Happy',
          'Silly',
          'Brave',
          'Smart',
          'Swift',
          'Fierce',
          'Gentle',
        ]
        const nouns = [
          'Tiger',
          'Lion',
          'Dragon',
          'Wizard',
          'Ninja',
          'Pirate',
          'Hero',
          'Ghost',
          'Phantom',
          'Knight',
        ]

        const randomAdjective = adjectives[Math.floor(Math.random() * adjectives.length)]
        const randomNoun = nouns[Math.floor(Math.random() * nouns.length)]

        const nickname = `${randomAdjective} ${randomNoun}`

        if (nickname.length < 2) {
          return getRandomNickname()
        } else if (nickname.length > 22) {
          return nickname.slice(0, 22)
        }

        return nickname
      }
    </script>
  </body>
</html>
相关推荐
Myli_ing28 分钟前
HTML的自动定义倒计时,这个配色存一下
前端·javascript·html
dr李四维1 小时前
iOS构建版本以及Hbuilder打iOS的ipa包全流程
前端·笔记·ios·产品运营·产品经理·xcode
雯0609~1 小时前
网页F12:缓存的使用(设值、取值、删除)
前端·缓存
℘团子এ1 小时前
vue3中如何上传文件到腾讯云的桶(cosbrowser)
前端·javascript·腾讯云
学习前端的小z1 小时前
【前端】深入理解 JavaScript 逻辑运算符的优先级与短路求值机制
开发语言·前端·javascript
彭世瑜2 小时前
ts: TypeScript跳过检查/忽略类型检查
前端·javascript·typescript
FØund4042 小时前
antd form.setFieldsValue问题总结
前端·react.js·typescript·html
Backstroke fish2 小时前
Token刷新机制
前端·javascript·vue.js·typescript·vue
小五Five2 小时前
TypeScript项目中Axios的封装
开发语言·前端·javascript
小曲程序2 小时前
vue3 封装request请求
java·前端·typescript·vue