介绍
这次课将学习如何将HTML集成到场景中去——即一个交互式的HTML元素跟随场景中的3D位置而变化,看起来就像嵌在WebGL中一般。
初始设置
采用上一节教的带有进度条的加载动画,并且使用头盔模型。
import './style.css'
import * as THREE from 'three'
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js'
import { GLTFLoader } from 'three/examples/jsm/loaders/GLTFLoader.js'
import { gsap } from 'gsap'
/**
* Loaders
*/
const loadingBarElement = document.querySelector('.loading-bar')
const loadingManager = new THREE.LoadingManager(
// Loaded
() =>
{
// Wait a little
window.setTimeout(() =>
{
// Animate overlay
gsap.to(overlayMaterial.uniforms.uAlpha, { duration: 3, value: 0, delay: 1 })
// Update loadingBarElement
loadingBarElement.classList.add('ended')
loadingBarElement.style.transform = ''
}, 500)
},
// Progress
(itemUrl, itemsLoaded, itemsTotal) =>
{
// Calculate the progress and update the loadingBarElement
const progressRatio = itemsLoaded / itemsTotal
loadingBarElement.style.transform = `scaleX(${progressRatio})`
}
)
const gltfLoader = new GLTFLoader(loadingManager)
const cubeTextureLoader = new THREE.CubeTextureLoader(loadingManager)
/**
* Base
*/
// Debug
const debugObject = {}
// Canvas
const canvas = document.querySelector('canvas.webgl')
// Scene
const scene = new THREE.Scene()
/**
* Overlay
*/
const overlayGeometry = new THREE.PlaneBufferGeometry(2, 2, 1, 1)
const overlayMaterial = new THREE.ShaderMaterial({
// wireframe: true,
transparent: true,
uniforms:
{
uAlpha: { value: 1 }
},
vertexShader: `
void main()
{
gl_Position = vec4(position, 1.0);
}
`,
fragmentShader: `
uniform float uAlpha;
void main()
{
gl_FragColor = vec4(0.0, 0.0, 0.0, uAlpha);
}
`
})
const overlay = new THREE.Mesh(overlayGeometry, overlayMaterial)
scene.add(overlay)
/**
* Update all materials
*/
const updateAllMaterials = () =>
{
scene.traverse((child) =>
{
if(child instanceof THREE.Mesh && child.material instanceof THREE.MeshStandardMaterial)
{
// child.material.envMap = environmentMap
child.material.envMapIntensity = debugObject.envMapIntensity
child.material.needsUpdate = true
child.castShadow = true
child.receiveShadow = true
}
})
}
/**
* Environment map
*/
const environmentMap = cubeTextureLoader.load([
'/textures/environmentMaps/0/px.jpg',
'/textures/environmentMaps/0/nx.jpg',
'/textures/environmentMaps/0/py.jpg',
'/textures/environmentMaps/0/ny.jpg',
'/textures/environmentMaps/0/pz.jpg',
'/textures/environmentMaps/0/nz.jpg'
])
environmentMap.encoding = THREE.sRGBEncoding
scene.background = environmentMap
scene.environment = environmentMap
debugObject.envMapIntensity = 5
/**
* Models
*/
gltfLoader.load(
'/models/DamagedHelmet/glTF/DamagedHelmet.gltf',
(gltf) =>
{
gltf.scene.scale.set(2.5, 2.5, 2.5)
gltf.scene.rotation.y = Math.PI * 0.5
scene.add(gltf.scene)
updateAllMaterials()
}
)
/**
* Lights
*/
const directionalLight = new THREE.DirectionalLight('#ffffff', 3)
directionalLight.castShadow = true
directionalLight.shadow.camera.far = 15
directionalLight.shadow.mapSize.set(1024, 1024)
directionalLight.shadow.normalBias = 0.05
directionalLight.position.set(0.25, 3, - 2.25)
scene.add(directionalLight)
/**
* Sizes
*/
const sizes = {
width: window.innerWidth,
height: window.innerHeight
}
window.addEventListener('resize', () =>
{
// Update sizes
sizes.width = window.innerWidth
sizes.height = window.innerHeight
// Update camera
camera.aspect = sizes.width / sizes.height
camera.updateProjectionMatrix()
// Update renderer
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
})
/**
* Camera
*/
// Base camera
const camera = new THREE.PerspectiveCamera(75, sizes.width / sizes.height, 0.1, 100)
camera.position.set(4, 1, - 4)
scene.add(camera)
// Controls
const controls = new OrbitControls(camera, canvas)
controls.enableDamping = true
/**
* Renderer
*/
const renderer = new THREE.WebGLRenderer({
canvas: canvas,
antialias: true
})
renderer.physicallyCorrectLights = true
renderer.outputEncoding = THREE.sRGBEncoding
renderer.toneMapping = THREE.ReinhardToneMapping
renderer.toneMappingExposure = 3
renderer.shadowMap.enabled = true
renderer.shadowMap.type = THREE.PCFSoftShadowMap
renderer.setSize(sizes.width, sizes.height)
renderer.setPixelRatio(Math.min(window.devicePixelRatio, 2))
/**
* Animate
*/
const tick = () =>
{
// Update controls
controls.update()
// Render
renderer.render(scene, camera)
// Call tick again on the next frame
window.requestAnimationFrame(tick)
}
tick()
HTML
首先在html文件添加一个类为point
的元素,后面还会继续添加其他点元素point-1
、 point-2
……这样子。
<canvas class="webgl"></canvas>
<div class="loading-bar"></div>
<div class="point point-0">
<div class="label">1</div>
<div class="text">Lorem ipsum, dolor sit amet consectetur adipisicing elit</div>
</div>
CSS
.point
{
position: absolute;
top: 50%;
left: 50%;
}
.point .label
{
position: absolute;
top: -20px;
left: -20px;
width: 40px;
height: 40px;
border-radius: 50%;
background: #00000077;
border: 1px solid #ffffff77;
color: #ffffff;
font-family: Helvetica, Arial, sans-serif;
text-align: center;
line-height: 40px;
font-weight: 100;
font-size: 14px;
cursor: help;
}
.point .text
{
position: absolute;
top: 30px;
left: -120px;
width: 200px;
padding: 20px;
border-radius: 4px;
background: #00000077;
border: 1px solid #ffffff77;
color: #ffffff;
line-height: 1.3em;
font-family: Helvetica, Arial, sans-serif;
font-weight: 100;
font-size: 14px;
opacity: 0;
transition: opacity 0.3s;
pointer-events: none;
}
.point:hover .text
{
opacity: 1;
}
添加样式,达成下图效果:
由于后边还要通过缩放隐藏这些点元素,因此要给它们一个样式来控制在页面中的缩放达成显隐,修改css如下:
.point .label
{
/* ... */
transform: scale(0, 0);
transition: transform 0.3s;
}
.point.visible .label
{
transform: scale(1, 1);
}
元素要想显示就加上样式:
<div class="point point-0 visible">
JavaScript
存储点元素
使用数组来存储页面上的点元素,每个数组元素对象有两个属性:
- position:用来存放3D位置坐标
- element: 从HTML获取对应点元素的节点
const points = [
{
position: new THREE.Vector3(1.55, 0.3, - 0.6),
element: document.querySelector('.point-0')
}
]
更新点位置
我们要在tick函数中直接更新每个帧上的点元素节点。
const tick = () =>
{
// Update controls
controls.update()
// 遍历每个点
for(const point of points)
{
}
// ...
}
我们需要根据场景中点的3D坐标来获取点在屏幕上的二维坐标。
第一步,使用clone()
方法克隆点的位置得到screenPosition
,再对screenPosition
调用project(...)
方法并传入摄像机camera
作为参数:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
console.log(screenPosition.x)
}
// ...
}
打印该点的x坐标,可以看到其值非常接近于0。按住右键把头盔拖往屏幕左侧,值将接近-1,拖往屏幕右侧,值将接近+1。
接下去要把这个坐标转换成像素值,因为要根据像素值去改变页面元素的位置。
要把场景中的点坐标转换为屏幕中的像素,需要乘以渲染器尺寸的一半:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
console.log(translateX)
}
// ...
}
更新元素的transform
属性,在x轴上平移了translateX
个像素:
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
point.element.style.transform = `translateX(${translateX}px)`
}
y轴同理,但是要注意的是在css中,y轴下方代表正值,而在Three.js中则是普通的坐标系y轴上方代表正值,因此translateY
要取反:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
const translateX = screenPosition.x * sizes.width * 0.5
const translateY = - screenPosition.y * sizes.height * 0.5
point.element.style.transform = `translateX(${translateX}px) translateY(${translateY}px)`
}
// ...
}
点的显隐
当有东西挡住该点在场景中的3D坐标时,元素应该隐藏起来。
先移除元素的visible
样式类。之后,为了测试点的前面是否有东西,我们将使用光线投射Raycaster。
我们将从摄像机发射一条射线到对应点,如果没有东西与射线相交,则显示该点;若有东西与射线相交,则计算与该交点的距离,如果交点比点元素距离更远,则说明点元素在交点前面,继续显示,如果交点离摄像机距离更近,则说明点元素被覆盖住了,要移除visible
样式将点给隐藏起来。
在tick函数外部实例化光线投射,然后在函数内部更新raycaster
。
要让射线从摄像机发射到对应点,需要调用setFromCamera(...)
方法,方法第一个参数是对应点在屏幕上的二维坐标,第二个参数则是射线来源的相机。
在这里,虽然screenPosition
是一个Vector3
,但是我们只用到他的x和y属性:
const raycaster = new THREE.Raycaster()
// ...
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
// ...
}
// ...
}
使用intersectObject(...)
方法来检查与射线相交的物体,这里要传入俩个参数,第一个是scene.children
,场景中所有物体,第二个参数为true
代表开启递归测试,会递归遍历物体的子代。
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
// ...
}
// ...
}
先判断射线是否有相交物体,没有的话则添加visible
类显示该点,若有相交物体,再做后续其他判断:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0)
{
point.element.classList.add('visible')
}
else
{
}
// ...
}
// ...
}
intersectObjects(...)
方法会返回一个相交对象数组,这些对象会按照距离排序,这也就意味着我们无需去测试全部相交对象,只需要测试第一个便可,通过distance
属性获取距离值。
然后通过distanceTo(...)
方法传入相机位置,获取点元素到相机的距离值。
接着比较两个值大小,决定点元素的显隐:
const tick = () =>
{
// ...
for(const point of points)
{
const screenPosition = point.position.clone()
screenPosition.project(camera)
raycaster.setFromCamera(screenPosition, camera)
const intersects = raycaster.intersectObjects(scene.children, true)
if(intersects.length === 0)
{
point.element.classList.add('visible')
}
else
{
const intersectionDistance = intersects[0].distance
const pointDistance = point.position.distanceTo(camera.position)
if(intersectionDistance < pointDistance)
{
point.element.classList.remove('visible')
}
else
{
point.element.classList.add('visible')
}
}
// ...
}
// ...
}
现在已经可以说是几乎完成了,但是在页面刷新加载进度条时,场景尚未加载完毕,但那些点元素是显示着的。
要解决这个问题很简单,设置一个值为false的变量,然后在tick函数中使用。只有当资源加载完毕后,再设为true,去更新点元素坐标:
let sceneReady = false
const loadingManager = new THREE.LoadingManager(
// Loaded
() =>
{
// ...
window.setTimeout(() =>
{
sceneReady = true
}, 2000)
},
// ...
)
// ...
const tick = () =>
{
// ...
if(sceneReady)
{
for(const point of points)
{
// ...
}
}
// ...
}
现在可以去增加其他点元素节点并在js中设置它们的坐标了:
<div class="point point-0">
<div class="label">1</div>
<div class="text">Front and top screen with HUD aggregating terrain and battle informations.</div>
</div>
<div class="point point-1">
<div class="label">2</div>
<div class="text">Ventilation with air purifier and detection of environment toxicity.</div>
</div>
<div class="point point-2">
<div class="label">3</div>
<div class="text">Cameras supporting night vision and heat vision with automatic adjustment.</div>
</div>
const points = [
{
position: new THREE.Vector3(1.55, 0.3, - 0.6),
element: document.querySelector('.point-0')
},
{
position: new THREE.Vector3(0.5, 0.8, - 1.6),
element: document.querySelector('.point-1')
},
{
position: new THREE.Vector3(1.6, - 1.3, - 0.7),
element: document.querySelector('.point-2')
}
]