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

WebGL实现的

主要技术要点
1.使用黄金分割数螺旋分配使小球在球表面均匀分布
使用不同的goldenRatio可以得到非常多分布效果,采用黄金分割数在视觉上最匀称、舒服
const goldenRatio = (1 + Math.sqrt(5)) / 2 
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
2.自由转动
因为要解决万向锁的问题,所以不能使用rotateX、rotateY、rotateZ来旋转,应当使用四元数THREE.Quaternion
3.背面小球变暗
这里通过内部放置了一个半透明的黑色小球来实现
// 创建半透明球体
const sphereGeometry = new THREE.SphereGeometry(4.85, 16, 16)
为了使小球从正面转动的背面的过程中可以平滑的变暗,这里还需要把半透明小球的边沿处理成高斯模糊,具体实现就是使用GLSL的插值函数smoothstep
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);
  }
但是需要注意的是需要关闭小球的深度测试,否则会遮挡小球
side: THREE.FrontSide,
depthWrite: false,
4.使用THREE.Sprite创建小球标签
5.标签位置计算
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)
6.超出长度的标签采用贴图采样位移来实现跑马灯效果
7.滚动阻尼,鼠标转动球体之后速度能衰减到转动旋转的速率
8.自动旋转需要保持上一次滚动的方向
9.使用射线拾取来实现点击交互
完整代码
<!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>
作者:前端小趴蔡
来源:juejin.cn/post/7425249244850913280
                            来源:juejin.cn/post/7425249244850913280