第一人称视角的可视化小程序

前言

之前做了一个好玩的第一人称3D视角的小程序,在这里记录一下开发过程

效果展示


微信小程序搜索 Visual3D 可在线预览

3D场景的实现

看似复杂的3d场景其实使用 CSS 来实现的,这个灵感来源于天猫还是淘宝的一次活动,看到了类似的实现网页,然后在网上找到了一篇文章,我把它贴在最下面参考资料了。

首先需要实现一个3d的立方体,这里用到了 CSS 的一个属性 transform-style

1
2
transform-style: flat; // 默认,子元素将不保留其 3D 位置
transform-style: preserve-3d; // 子元素将保留其 3D 位置。

当父元素设置了 transform-style: preserve-3d; 后,就可以对子元素进行 3D 变形操作了,3D 变形和 2D 变形一样可以,使用 transform 属性来设置,或者可以通过制定的函数或者通过三维矩阵来对元素变型操作:当我们指定一个容器的 transform-style 的属性值为 preserve-3d 时,容器的后代元素便会具有 3D 效果,这样说有点抽象,也就是当前父容器设置了 preserve-3d 值后,它的子元素就可以相对于父元素所在的平面,进行 3D 变形操作。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
.visual { // 最外层
width: 100vw;
height: 100vh;
overflow: hidden;

.stage { // 舞台层
width: 100vw;
height: 100vh;
position: relative;
display: flex;
justify-content: center;
align-items: center;

.planeWrap {
width: 100vh;
height: 100vh;
list-style: none;
position: relative;
// 子元素转3D
transform-style: preserve-3d;
transition: all 0.1s linear;

.plane {
width: 100vh;
height: 100vh;
border-radius: 5rpx;
text-align: center;
position: absolute;
/*让所有的子元素都重叠在一起*/
left: 0;
right: 0;
}

.x-plus {
transform: translateX(50vh) rotateY(90deg); // 右
box-shadow: inset 0px 0px 4px 4px #000;
}

.x-minus {
transform: translateX(-50vh) rotateY(-90deg); // 左
box-shadow: inset 0px 0px 4px 4px #000;
}

.y-plus {
transform: translateY(-50vh) rotateX(90deg); // 上
}

.y-minus {
transform: translateY(50vh) rotateX(-90deg); // 下
}

.z-plus {
transform: translateZ(50vh); // 后
box-shadow: inset 0px 0px 4px 4px #000;
}

.z-minus {
transform: translateZ(-50vh) rotateY(-180deg); // 前
box-shadow: inset 0px 0px 4px 4px #000;
}
.z-tier1 {
bottom: 0;
width: 300rpx;
height: 300rpx;
transform: translateZ(0vh); // 后
}
.z-tier2 {
transform: translateZ(30vh); // 后
}
.z-tier3 {
transform: translateZ(-30vh); // 后
}
}
}
}

最后应该是这样的效果(图片取自参考资料):

第二部我们需要把视角探到正方体里面,这样就有了3d的空间感,这里用到了另一个 CSS 属性 perspective

1
2
perspective: 400; // 数字或none
perspective: none;

当元素没有设置 perspective 时,也就是当 perspective:none|0; 时所有后代元素被压缩在同一个二维平面上,不存在景深的效果。perspective 为一个元素设置三维透视的距离,仅作用于元素的后代,而不是其元素本身。

而如果设置 perspective 后,将会看到三维的效果。我们上面之所以能够在正方体外围看到正方体,以及深入正方体内,都是因为 perspective 这个属性。它让我们能够选择推进视角,还是远离视角,因此便有了 3D 的感觉。

在 stage 层加上该属性,这个属性的值需要自己去试,我试了600是比较合适的

1
2
3
4
.stage { // 舞台层
// 景深视角
perspective: 600rpx;
}

到这就完成了一个3d的场景,下面讲一下视角的移动。

视角移动的两种方式

我实现了两种移动视角的方式,触屏拖动改变视角和水平仪驱动。在屏幕中添加了一个按钮用于切换这两种视角移动方式。

触控

触控的话比较简单,首先给舞台层添加事件,@touchmove="onTouchMoveChange",实现代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
export default {
data() {
return {
coordinates: { x: 0, y: 0, z: 0 }, // 坐标
touchData: [0, 0], // 触摸参数
visualAngleStyle: 'transform:rotateX(0deg) rotateY(0deg)', // css
};
},
watch: {
coordinates: {
deep: true,
handler(newVal) { // 监听坐标变换,改变 CSS 样式
const { x, y } = newVal;
this.visualAngleStyle = `transform:rotateX(${x}deg) rotateY(${y}deg)`;
},
},
},
methods: {
onTouchMoveChange(event) { // 根据触摸参数计算坐标移动位置并更新坐标
const { pageX, pageY } = event.touches[0];
const [startPageX, startPageY] = this.touchData;
const rateX = 4.5, rateY = 2;
if (startPageX > pageX) {
this.coordinates.y += rateX;
}
if (startPageX < pageX) {
this.coordinates.y += -rateX;
}
if (startPageY > pageY) {
if (this.coordinates.x > -10) this.coordinates.x += -rateY;
}
if (startPageY < pageY) {
if (this.coordinates.x < 20) this.coordinates.x += rateY;
}
this.touchData = [pageX, pageY];
}
},
}

水平仪

我之前试了很多关于水平仪或方向的api(包括uni-app封装的微信原生的),发现只有 wx.startDeviceMotionListening 这个系列的 api 符合想要的效果。这是一个微信原生的 api,用于监听设备方向的变化,直接上代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
export default {
data() {
return {
coordinates: { x: 0, y: 0, z: 0 }, // 坐标
touchData: [0, 0], // 触摸参数
visualAngleStyle: 'transform:rotateX(0deg) rotateY(0deg)', // css
};
},
watch: {
coordinates: {
deep: true,
handler(newVal) { // 监听坐标变换,改变 CSS 样式
const { x, y } = newVal;
this.visualAngleStyle = `transform:rotateX(${x}deg) rotateY(${y}deg)`;
},
},
},
methods: {
onStartGyroscopeChange() { // 开始监听方向变化
const isIOSFlag = this.isIOS();

wx.startDeviceMotionListening({
interval: 'ui',
success() {
console.log('开始监听');
},
fail(err) {
console.error(err);
}
});

wx.onDeviceMotionChange(res => { // 监听到方向发生变化更新坐标数据
let x = res.beta;
let y = -res.gamma;
if (!isIOSFlag) {
x = -res.beta;
y = res.gamma;
}
this.coordinates.x = x;
this.coordinates.y = y;
});
},
onStopGyroscopeChange() { // 结束方向变化的监听
wx.offDeviceMotionChange();
wx.stopDeviceMotionListening();
},
isIOS() { // 是否为 IOS
const res = wx.getSystemInfoSync();
if(res.platform == 'android') return false; // 安卓
return true;
},
},
}

这里需要注意一点,在 android 系统中的x轴与y轴是与 ios 相反的,这个官方说不会修复了,需要开发者自行处理,所以这里需要加判断。

3D爱心的实现

3d爱心肯定是用 three.js 来实现的了,但是这里我没有用 three.js 官方的微信小程序版本,据说小程序官方对于 three.js 的支持并不是很友好,我用的是 YannLiaothree.weapp.min.js这个库。

另外还需要很多个额外的小包,比如 OBJLoader 的包、TrackballControls 的包等等,这里我就不列出来了,一会把项目的 github 地址贴下面,自行去看吧

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
<template>
<view class="heart-box">
<canvas type="webgl" id="heart" />
</view>
</template>

<script>
import { STATIC_URL } from '@/utils/constant.js';
import * as THREE from '@/libs/three/three.weapp.js';
import { OBJLoader } from '@/libs/three/OBJLoader.js';
import { OrbitControls } from '@/libs/three/OrbitControls.js';
import { SimplexNoiseFactory } from '@/libs/three/simplex-noise.js';
import { TrackballControls } from '@/libs/three/TrackballControls.js';
import { MeshSurfaceSamplerFactory } from '@/libs/three/MeshSurfaceSampler.js';
import gsap from '@/libs/three/gsap.min.js';
export default {
props: {
isDynamicEffect: {
type: Boolean,
default: () => false,
},
},
mounted() {
uni.createSelectorQuery()
.in(this)
.select('#heart')
.node()
.exec((res) => {
this.drawCanvas(res[0].node);
});
},
methods: {
drawCanvas(node) {
const canvas = THREE.global.registerCanvas(node);
const camera = new THREE.PerspectiveCamera(75,1,0.1,1000);
const scene = new THREE.Scene();
const renderer = new THREE.WebGLRenderer({
alpha: true,
antialias: true
});
renderer.setClearAlpha(0);
renderer.setSize(1000, 1000);
camera.position.z = 1;
const controls = new TrackballControls(camera, renderer.domElement);
controls.noPan = true;
controls.maxDistance = 3;
controls.minDistance = 0.7;

const group = new THREE.Group();
scene.add(group);

let heart = null;
let sampler = null;
let originHeart = null;
new OBJLoader().load(`${STATIC_URL}/files/heart.obj`,obj => {
heart = obj.children[0];
heart.geometry.rotateX(-Math.PI * 0.5);
heart.geometry.scale(0.04, 0.04, 0.04);
heart.geometry.translate(0, -0.4, 0);
group.add(heart);

heart.material = new THREE.MeshBasicMaterial({
color: 0xdc143c
});
originHeart = Array.from(heart.geometry.attributes.position.array);

sampler = new (MeshSurfaceSamplerFactory(THREE))(heart).build();
init();
renderer.setAnimationLoop(render, canvas);
});
let positions = [];
const geometry = new THREE.BufferGeometry();
const material = new THREE.LineBasicMaterial({
color: 0x00e924
});
const lines = new THREE.LineSegments(geometry, material);
const simplex = new (SimplexNoiseFactory());
const pos = new THREE.Vector3();
class Grass {
constructor () {
sampler.sample(pos);
this.pos = pos.clone();
this.scale = Math.random() * 0.01 + 0.001;
this.one = null;
this.two = null;
}
update (a) {
const noise = simplex.noise4D(this.pos.x*1.5, this.pos.y*1.5, this.pos.z*1.5, a * 0.0005) + 1;
this.one = this.pos.clone().multiplyScalar(1.01 + (noise * 0.15 * beat.a));
this.two = this.one.clone().add(this.one.clone().setLength(this.scale));
}
}

let spikes = [];
function init (a) {
positions = [];
for (let i = 0; i < 20000; i++) {
const g = new Grass();
spikes.push(g);
}
}
const beat = {a:0}
gsap.timeline({
repeat: -1,
repeatDelay: 0.3
}).to(beat, {
a: 1.2,
duration: 0.6,
ease: 'power2.in'
}).to(beat, {
a: 0.0,
duration: 0.6,
ease: 'power3.out'
});
gsap.to(group.rotation, {
y: Math.PI * 2,
duration: 12,
ease: 'none',
repeat: -1
});
const _this = this;
function render(a) {
if (_this.isDynamicEffect) {
positions = [];
spikes.forEach(g => {
g.update(a);
positions.push(g.one.x, g.one.y, g.one.z);
positions.push(g.two.x, g.two.y, g.two.z);
});
geometry.setAttribute('position', new THREE.BufferAttribute(new Float32Array(positions), 3));
const vs = heart.geometry.attributes.position.array;
for (let i = 0; i < vs.length; i+=3) {
const v = new THREE.Vector3(originHeart[i], originHeart[i+1], originHeart[i+2]);
const noise = simplex.noise4D(originHeart[i]*1.5, originHeart[i+1]*1.5, originHeart[i+2]*1.5, a * 0.0005) + 1;
v.multiplyScalar(1 + (noise * 0.15 * beat.a));
vs[i] = v.x;
vs[i+1] = v.y;
vs[i+2] = v.z;
}
heart.geometry.attributes.position.needsUpdate = true;
}
controls.update();
renderer.render(scene, camera);
}
},
},
}
</script>

<style lang="scss" scoped>
.heart-box {
width: 80vh;
height: 80vh;
canvas {
width: 100%;
height: 100%;
}
}
</style>

爱心的 obj 模型文件没在代码目录里,因为微信小程序编译后有大小限制,所以是以网路资源的方式加载的。

最后

看似很简单的东西,但开发起来却不那么简单,调试那个3d爱心的时候是最费劲的,遇到很多问题,好像还改了 three.weapp.js 里的东西,有点记不清了,最后虽然代码是整的乱七八糟的但总算是弄出来了。

项目 github 地址为:https://github.com/lvboda/visual-3d

参考资料

第一人称视角的可视化小程序

https://lvboda-blog.pages.dev/f76d.html

作者

Boda Lü

发布于

2022-09-23

更新于

2026-03-24

许可协议


评论