本文已参与「新人创作礼」活动,一起开启掘金创作之路。
最近接到一个比较奇怪的需求,需要在浏览器中以3D曲面的形式展示商品图片,并且图片的大小存在两种规格,大图的宽高皆为小图的两倍,而且大图的位置随机,类似于下图:
尝试过各种方案,踩了各种坑之后,发现只有three.js比较符合需求,最终效果如下:
在线预览地址(服务器小水管,偶尔会连不上,多刷新几次)。
github: PersonalWebsite/curvedSurface.vue at main · onefantasy/PersonalWebsite (github.com)
目录
- 技术栈
- 需求分析
- 页面初始化
- three.js初始化
- 计算坐标
- 图片与坐标对应
技术栈
vue3 + three.js + Tailwindcss + Mock.js
需求分析
难点在于要使列表元素弯曲,单纯的使用css比较难以实现,至少本人没有找到什么好方案。
如果换一个思路去思考,我们只需要让其看上去弯曲就行,不需要真的让列表元素弯曲,利用一点障眼法,也能达到差不多的效果。
这里借鉴了定积分的基本思想,只需要让每一列的商品图片都按照一定的角度弯曲,那么整体看上去就像是一个曲面;列数越多,弯曲的角度越小,那么就越接近一个曲面;列数趋近于无限,角度趋近于无限小,那么最终结果就无限趋近于一个曲面。
然而在实际开发中,不可能真的趋近于无限,所以取一个差不多的列数即可。
页面初始化
先初始化页面,包括获取数据。
- 创建html容器,大小需要先规定好,因为后续计算坐标需要用到容器的宽高,需要充满页面的话,可以直接设置成
width:100vw;height:100vh;。
<div
ref="container"
class="relative h-full w-full"
/>
该容器用于three.js的渲染使用。
- 初始化变量和函数
先说一下思路
- 需要获取到html中容器,定义好展示的列数。
- 根据定义的列数和容器的宽高数据,计算出每个商品图片的宽度(高度与宽度相等)。
- 接着利用容器高度数据和商品图片的宽度,计算出行数,进而计算出每个页面商品的数量,这一点比较特殊,pageSize由容器的宽高和列数决定的。
- 计算好页面的商品数量之后,自然就是向后端获取数据,本次项目使用mockjs模拟数据,就不详细展示。 ps: 看上去定义了很多变量,最终好像只用到了pageSize请求数据,实则不然,其他数据大多都是用于three部分的计算。
从vue中引入需要的函数:
import { onMounted, onBeforeUnmount, ref, nextTick, watch, computed } from 'vue'
然后获取容器元素:
const container = ref<HTMLElement | null>()
定义屏幕大小信息,因为浏览器窗口的大小会随着用户拖动而改变,所以宽高都用let定义:
let containerWidth = 0
let containerHeight = 0
定义列表信息,包括每一页的列数、行数、总的数量、每个元素的宽度等,其中列数需要先预定义好,用于计算其他变量:
// 列数
const colNum = 12
// 行数
let rowNum = 0
// 元素宽度,每个元素宽高相等
let itemWidth = 0
// 元素总数量
let allItemNum = 0
// 角度转弧度的系数
const degToRadianConstant = 0.017453293
// 页面展示的角度,此项目使用5个页面组成一个圆,因此计算每个页面所占据的角度
const pageDeg = 360 / 5
// 单列元素的弧度
let radian = 0
// 半径(坐标(0,0,0)到中心点在z轴上的产品图片的距离)
let radius = 0
// 1px 等于的弧度值
let pxToRadian = 0
然后就是计算需要的数据了,定义一个函数来执行:
// 获取屏幕信息信息并计算相关数据
function setContainerInfo(): Promise<void> {
if (!container.value) {
return Promise.reject('set container info error !')
}
// 获取容器的宽高
const { clientWidth, clientHeight } = container.value
containerWidth = clientWidth
containerHeight = clientHeight
if (clientWidth === 0 || clientHeight === 0) {
return Promise.reject('width or height is 0 !')
}
// 计算每个元素的宽度
itemWidth = clientWidth / colNum
// 计算行数
rowNum = Math.floor(clientHeight / itemWidth)
// 每一页的商品数量
allItemNum = colNum * rowNum
// 计算每个页面所占据的弧度
radian = (pageDeg / colNum) * degToRadianConstant
// 计算半径
radius = itemWidth / 2 / Math.tan(radian / 2)
// 计算1px 的弧度值
pxToRadian = (pageDeg / clientWidth) * degToRadianConstant
return Promise.resolve()
}
至此,页面初始化以及数据的获取就完成了。
three.js初始化
three三板斧:场景、相机、渲染器,主要就是初始化这个三个部分。
场景
const scene = new THREE.Scene()
渲染器
const renderer = new CSS3DRenderer()
const renderer.setSize(clientWidth, clientHeight)
container.appendChild(renderer.domElement)
相机
相机最主要就是一个fov的计算,要使相机能够刚好显示每个页面所有的产品图片,必须设定一个刚刚好的fov。
因为本项目比较特殊,再决定fov之前,已经决定了半径(坐标(0,0,0)到z轴上的产品图片的距离),因此这个fov的算法比较特殊。基本思路就是利用立体几何的知识求解,不再赘述,代码如下:
const cameraToPlaneDistance = radius * Math.cos((pageDeg * degToRadianConstant - radian) / 2)
const fov = Math.atan(clientHeight / 2 / cameraToPlaneDistance) * 2 * (180 / Math.PI)
camera = new THREE.PerspectiveCamera(fov, clientWidth / clientHeight, 1, 2000)
计算坐标
最大的难点在于此,怎么去计算每一张产品的图片的中心坐标。首先,建立一个坐标系,坐标系大概如下,其中z轴垂直于屏幕,z正轴指向屏幕外:
其实可以把相机视为与y轴重合的直线,那么每一张产品图片到相机的最短距离都为半径(坐标(0,0,0)到z轴上的产品图片的距离),则产品图片列表的起始旋转角度为:
// 起始角度
const startDeg = pageDeg * 0.5 * degToRadianConstant - radian / 2
每一列的旋转弧度为起始角度 - 列数 * 每一列的所占据的弧度:
const deg = startDeg - i * radian
x轴的计算为:
const x = radius * Math.sin(deg)
z轴计算为:
const z = radius * Math.cos(deg)
起始的y轴坐标 = 容器高度 / 2 - 空白区域高度 / 2 - 单个元素高度 / 2,化简得到如下计算:
let positionY = (clientHeight - (clientHeight % itemWidth) - itemWidth) / 2
每一行的y轴坐标为:
y = positionY - 行数 * itemWidth
整体代码如下:
const coordinates: Array<curvedSurfaceCoordinateType> = []
// 半径(取负数, 因为相机初始时面对(0,0,-z)的方向)
const radius = -this.radius
// 起始角度
const startDeg = pageDeg * (0.5 - index) * degToRadianConstant - radian / 2
// 起始坐标y = 容器高度 / 2 - 剩余高度 / 2 - 单个元素高度 / 2, 化简得到以下公式
const positionY = (clientHeight - (clientHeight % itemWidth) - itemWidth) / 2
for (let i = 0; i < colNum; i++) {
const deg = startDeg - i * radian
const x = radius * Math.sin(deg)
let y = positionY
const z = radius * Math.cos(deg)
for (let j = 0; j < rowNum; j++) {
y = positionY - j * itemWidth
coordinates.push({
position: { x, y, z },
lookAt: { x: -2 * x, y, z: -2 * z },
deg
})
}
}
图片与坐标对应
首先需要生成图片DOM的函数,这个比较简单,代码如下:
import { CSS3DRenderer, CSS3DObject } from 'three-css3d'
/**
* 创建元素
* @param { curvedSurfaceListItemType } item 单个元素数据
* @param { curvedSurfaceCoordinateType } coordiante 坐标
* @param { number } expand 扩大倍数
*/
_generateElement(
item: curvedSurfaceListItemType,
coordiante: curvedSurfaceCoordinateType | false,
expand: number
): void {
const { scene, itemWidth, elementClass, imgClass } = this
if (!scene || itemWidth === 0 || !coordiante) {
return
}
// 创建元素
const element = document.createElement('div')
element.className = elementClass
element.style.cssText = `width:${expand * itemWidth}px;height:${expand * itemWidth}px;`
// 创建图片
const img = document.createElement('img')
img.className = imgClass
img.src = item.url
img.draggable = false
img.dataset.isimg = 'true'
element.appendChild(img)
const { position, lookAt } = coordiante
// 创建 CSS3D 对象
const object = new CSS3DObject(element)
object.position.set(position.x, position.y, position.z)
// 元素朝向
const vector = new THREE.Vector3(lookAt.x, lookAt.y, lookAt.z)
object.lookAt(vector)
scene.add(object)
}
如果没有大图的存在,全部图片都是统一规格,那么直接循环dataList(产品数据列表)和坐标集,把数据和坐标按照索引一一对应,然后调用上面创建DOM的函数即可,代码如下:
/**
* 根据数据列表创建视图
* @returns { void }
*/
createListView(): void {
const { dataList } = this
const dataLen = dataList.length
if (dataLen === 0) {
return
}
const coordiantes: Array<curvedSurfaceCoordinateType | false> = this._computeCoordinate()
// 普通图处理
const coordinateLen = coordiantes.length - 1
for (let j = 0; j < dataLen; j++) {
this._generateElement(list[j], coordiantes[j], 1)
}
}
当存在大图时,没办法按照坐标顺序进行排放,因为需求中说明大图必须时随机放置,也就是坐标随机,大图的起始坐标也不能存在于最后一列或者最后一行,因此,大图的起始坐标只能存在于下图的红色框区域:
因此,只能先处理大图,再处理小图,大概思路如下:
- 遍历dataList(产品数据列表), 分开大图的数据和小图的数据。
- 大图处理
- 随机选取一个坐标(leftTop)作为大图的左上角,判断坐标是否可用
- 如果可用,判断拾取坐标右方坐标(righTop)、下方坐标(leftBottom)、右下方(rightBottom)坐标是否可用
- 如果可用,将拾取到坐标(leftTop)与右下方的图片(rightBottom)的每一项求平均数,生成新的坐标(center), 利用该坐标生成大图DOM
- 如果不可用,拾取下一个坐标,重新判断。
- 小图处理
将剩余可用的坐标筛选出来,与小图数据一一对应,遍历生成DOM。 因为这部分代码比较多且复杂,就不放出来了。