卷曲动画 ·Curl Animate· ▶ 在线运行案例
- 案例合集:三维可视化功能案例(threehub.cn)
- 开源仓库github地址:https://github.com/z2586300277/three-cesium-examples
- 400个案例代码:网盘链接
你将学到什么
- 相机交互控制器
- 实时阴影 ShadowMap
- 天空盒与环境贴图
- requestAnimationFrame 渲染循环
- Clock 帧间隔计时
效果说明
本案例演示卷曲动画效果:基于 WebGL 实现「卷曲动画」可视化效果,附完整可运行源码;核心用到 OrbitControls、BufferGeometry。建议先打开文首在线案例查看动态画面,再对照下方源码逐步理解。
核心概念
- OrbitControls轨道旋转缩放;开
enableDamping时每帧需controls.update()。 - 阴影四步:
renderer.shadowMap.enabled、光源castShadow、物体castShadow、地面receiveShadow。 - CubeTexture六面贴图作
scene.background;scene.environment供 PBR 材质反射。
实现步骤
- 搭建 Scene / Camera / Renderer 与 OrbitControls
- rAF 循环中 update 并 render
代码要点
import * as THREE from 'three'import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js' import { GUI } from 'dat.gui'
const box = document.getElementById('box') const scene = new THREE.Scene() scene.background = new THREE.Color(0x1a1a2e)
const camera = new THREE.PerspectiveCamera(50, box.clientWidth / box.clientHeight, 0.1, 1000) camera.position.set(0, 12, 20)
const renderer = new THREE.WebGLRenderer({ antialias: true }) renderer.setSize(box.clientWidth, box.clientHeight) // renderer.shadowMap.enabled = true box.appendChild(renderer.domElement)
new OrbitControls(camera, renderer.domElement)
scene.add(new THREE.AmbientLight(0xffffff, 0.8)) const dirLight = new THREE.DirectionalLight(0xffffff, 1.5) dirLight.position.set(5, 10, 5) dirLight.castShadow = true scene.add(dirLight) scene.add(new THREE.DirectionalLight(0x4488ff, 0.5).position.set(-5, 5, -5))
// 钢带材质 const stripMat = new THREE.MeshStandardMaterial({ color: 'white', metalness: 0.7, roughness: 0.15, side: THREE.DoubleSide, // envMapIntensity: 1.0 })
// 参数 const params = { speed: 1.0, stripWidth: 4, thickness: 0.15, coreRadius: 1.5, maxTurns: 8, feedLength: 12, playing: true, reset() { totalAngle = 0 } }
let totalAngle = 0 // 已卷绕的总角度(弧度) let stripMesh = null
// 构建钢带几何体:水平进料段 + 螺旋卷绕段 function buildStripGeometry(angle, coreRadius, stripThickness, width, feedLen) { const segsPerRad = 10 const coilSegs = Math.max(Math.floor(angle * segsPerRad), 1) const feedSegs = 20 const totalSegs = feedSegs + coilSegs const halfW = width / 2 const halfT = stripThickness / 2
const positions = [] const indices = []
const radiusAt = (a) => coreRadius + (a / (Math.PI2))stripThickness
for (let i = 0; i <= totalSegs; i++) { let cx, cy, nx, ny
if (i < feedSegs) { // 进料段:水平从右侧远端到卷芯右侧 const t = 1 - i / feedSegs cx = radiusAt(angle) + t * feedLen cy = 0 nx = 0 ny = 1 } else { // 螺旋段:从最外圈(angle)顺时针卷到最内圈(0) // 连续递减角度,同时半径递减,不会穿层 const t = (i - feedSegs) / coilSegs // 0->1 const a = angle * (1 - t) // angle->0 递减 const r = radiusAt(a) // 顺时针旋转(负角度) const theta = -(angle - a) // 从0度开始顺时针转了多少 cx = Math.cos(theta) * r cy = Math.sin(theta) * r nx = Math.cos(theta) ny = Math.sin(theta) }
positions.push(cx + nxhalfT, cy + nyhalfT, halfW) positions.push(cx + nxhalfT, cy + nyhalfT, -halfW) positions.push(cx - nxhalfT, cy - nyhalfT, halfW) positions.push(cx - nxhalfT, cy - nyhalfT, -halfW) }
for (let i = 0; i < totalSegs; i++) { const base = i * 4 const next = base + 4 indices.push(base, next, base + 1, base + 1, next, next + 1) indices.push(base + 2, base + 3, next + 2, base + 3, next + 3, next + 2) indices.push(base, base + 2, next, next, base + 2, next + 2) indices.push(base + 1, next + 1, base + 3, base + 3, next + 1, next + 3) }
indices.push(0, 1, 2, 1, 3, 2) const last = totalSegs * 4 indices.push(last, last + 2, last + 1, last + 1, last + 2, last + 3)
const geo = new THREE.BufferGeometry() geo.setAttribute('position', new THREE.Float32BufferAttribute(positions, 3)) geo.setIndex(indices) geo.computeVertexNormals() return geo }
// 卷芯 const coreMat = new THREE.MeshStandardMaterial({ color: 0x444444, metalness: 0.9, roughness: 0.2 }) const core = new THREE.Mesh(new THREE.CylinderGeometry(params.coreRadius, params.coreRadius, params.stripWidth + 0.2, 32), coreMat) core.rotation.x = Math.PI / 2 scene.add(core)
// 支撑辊 const rollerMat = new THREE.MeshStandardMaterial({ color: 0x666666, metalness: 0.7, roughness: 0.4 }) const roller = new THREE.Mesh(new THREE.CylinderGeometry(0.3, 0.3, params.stripWidth + 1, 16), rollerMat) roller.rotation.x = Math.PI / 2 roller.position.set(params.coreRadius + params.feedLength * 0.5, -0.5, 0) scene.add(roller)
function updateStrip() { if (stripMesh) { scene.remove(stripMesh) stripMesh.geometry.dispose() }
const remainFeed = params.feedLengthMath.max(1 - totalAngle / (params.maxTurnsMath.PI * 2), 0)
const geo = buildStripGeometry( totalAngle, params.coreRadius, params.thickness, params.stripWidth, remainFeed )
stripMesh = new THREE.Mesh(geo, stripMat) stripMesh.castShadow = true stripMesh.receiveShadow = true scene.add(stripMesh)
// 更新卷芯大小 const outerR = params.coreRadius + (totalAngle / (Math.PI2))params.thickness core.scale.set(1, 1, 1) }
// GUI const gui = new GUI() gui.add(params, 'speed', 0.1, 5).name('速度') gui.add(params, 'coreRadius', 0.5, 4).name('卷芯半径').onChange(v => { core.geometry.dispose() core.geometry = new THREE.CylinderGeometry(v, v, params.stripWidth + 0.2, 32) }) gui.add(params, 'thickness', 0.05, 0.5).name('钢带厚度') gui.add(params, 'maxTurns', 2, 20, 1).name('最大圈数') gui.add(params, 'stripWidth', 1, 8).name('钢带宽度').onChange(v => { core.geometry.dispose() core.geometry = new THREE.CylinderGeometry(params.coreRadius, params.coreRadius, v + 0.2, 32) }) gui.add(params, 'playing').name('播放') gui.add(params, 'reset').name('重置')
const clock = new THREE.Clock()
function animate() { requestAnimationFrame(animate) const delta = clock.getDelta()
const maxAngle = params.maxTurnsMath.PI2 if (params.playing && totalAngle < maxAngle) { totalAngle += deltaparams.speed2 totalAngle = Math.min(totalAngle, maxAngle) updateStrip() }
// 卷芯旋转,钢带不转 core.rotation.y = -totalAngle
renderer.render(scene, camera) } animate()
window.onresize = () => { renderer.setSize(box.clientWidth, box.clientHeight) camera.aspect = box.clientWidth / box.clientHeight camera.updateProjectionMatrix() }完整源码:GitHub
小结
- 本文提供卷曲动画完整 Three.js 源码与在线 Demo,建议先运行案例再改 uniform/参数做二次实验
- 更多 Three.js 实战案例见 three-cesium-examples 合集 与 GitHub 开源仓库