轨迹漫游
创建关键空间点数组
首先我们可以先找出运动轨迹上几个特定的点。
假设给定的点是(1,1,-1),(1,0,1),(-1,0,1),(-1,0,-1)
这里在每个点放了一个实体方块用于示意点的位置,同时为后面的调整功能做准备
const initialPoints = [
{ x: 1, y: 0, z: -1 },
{ x: 1, y: 0, z: 1 },
{ x: -1, y: 0, z: 1 },
{ x: -1, y: 0, z: -1 },
];
const boxGeometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
const boxMaterial = new THREE.MeshBasicMaterial();
for (const handlePos of initialPoints) {
const handle = new THREE.Mesh(boxGeometry, boxMaterial);
handle.position.copy(handlePos);
curveHandles.push(handle);
scene.add(handle);
}
根据点数组绘制曲线
这里采用的是 CatmullRom 插值的方法绘制曲线。CatmullRom 插值的曲线一定会经过所有给定的点,所以这种方法会更适合用作轨迹曲线的绘制。
curve = new THREE.CatmullRomCurve3(
curveHandles.map((handle) => handle.position)// 直接绑定方块的position以便后续用方块调整曲线
);
curve.curveType = "centripetal";
curve.closed = true;
const points = curve.getPoints(50);
const line = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: 0x00ff00 })
);
获取曲线上特定位置的点,修改物体位置
有了曲线之后,可以通过 getPointAt 函数获取曲线上特定位置的点向量,然后复制给物体的 position.
function changePosition(t, mesh, curve) {
const position = curve.getPointAt(t); // t: 当前点在线条上的位置百分比,后面计算
mesh.position.copy(position);
}
获取曲线上特定位置的切线,修改物体朝向
现在物体的位置对上了,但是朝向却是固定的,不符合生活经验。一般来说物体在运动的时候,正面总是朝向轨迹的切线方向的。
现在我们通过 getTangentAt 函数获取曲线上特定位置的切线向量,根据该切线向量和点的位置向量计算物体朝向的点向量,传入物体的 lookAt 函数
function changeLookAt(t, mesh, curve) {
const tangent = curve.getTangentAt(t);
const lookAtVec = tangent.add(mesh.position); // 位置向量和切线向量相加即为所需朝向的点向量
mesh.lookAt(lookAtVec);
}
注意下图示的切线(黄线)实际起点为原点(0,0,0),这里为了示意切线在曲线上的位置,平移到了点所在位置上.
因为 lookAt 实际上是指向某个点向量,如果直接传切线向量会导致物体朝向下图 A 点,需要和位置向量相加后才能得到所需的点向量(蓝线)即 C 点
随时间实时改变物体位置和朝向
现在轨迹上单一点的位置和朝向都可以获取到了,剩下的就是在渲染函数中实时修改了。
根据时间计算当前点在曲线上的位置百分比。
const loopTime = 10 * 1000; // loopTime: 循环一圈的时间
// 在渲染函数中获取当前时间
const render = () => {
let time = Date.now();
let t = (time % loopTime) / loopTime; // 计算当前时间进度百分比
changePosition(t);
changeLookAt(t);
requestAnimationFrame(render);
renderer.render(scene, camera);
}
requestAnimationFrame(render);
引入模型模拟应用场景
经过前面的步骤现在有了一个比较抽象的场景,现在可以考虑通过模型让应用场景更具象化。这里采用和场景契合的轨道模型。
机器人模型的处理方式和方块基本没区别这里就不放相关代码了,轨道是通过一小段的轨道模型不断重复的方式去模拟。
new GLTFLoader()
.setPath("models/gltf/")
.load("SheenChair.glb", function (gltf) {
// 轨道容器
const railway = new THREE.Object3D();
let position = new THREE.Vector3();
let tangent = new THREE.Vector3();
for (let i = 0; i < railNum; i++) {
// 复制多段轨道模型
let model = gltf.scene.clone();
railway.add(model);
// 这里和前面一样通过获取位置和切线向量去计算每段轨道的朝向
position = curve.getPointAt(i / railNum);
tangent = curve.getTangentAt(i / railNum);
model.position.copy(position);
model.lookAt(tangent.add(position));
}
railway.rotateX(Math.PI);
scene.add(railway);
});
设置漫游相机
该相机和机器人绑定,辅助拍摄机器人移动时所看到的场景。为了观察相机的观察视角和转动的方位,我们使用threejs自带的相机辅助类CameraHelper,示意相机的视角变动情况。
创建parent,将相机和机器人绑定,方便同步相机、机器人的位置和朝向。
splineCamera = new THREE.PerspectiveCamera(
84,
window.innerWidth / window.innerHeight,
0.01,
1000
);
parent.add(splineCamera);
cameraHelper = new THREE.CameraHelper(splineCamera);
scene.add(cameraHelper);
const loader1 = new GLTFLoader();
loader1.load("models/gltf/Soldier.glb", function (gltf) {
console.log(gltf);
gltf.scene.traverse(function (object) {
if (object.isMesh) object.castShadow = true;
});
const model1 = SkeletonUtils.clone(gltf.scene);
mixer1 = new THREE.AnimationMixer(model1);
mixer1.clipAction(gltf.animations[1]).play(); // idle
model1.scale.set(0.4, 0.4, 0.4);
scene.add(model1);
parent.add(model1);
});
设置漫游视角窗口
漫游视角窗口能够辅助观察机器人奔跑时的视角,使用户既能在全局上观察机器人奔跑的过程,又同时广场机器人奔跑时的视角。本质是将绑定在机器人身上的漫游相机所拍摄到的场景展示在一个单独的小窗口中。
调整两个相机的视口:
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
insetWidth = window.innerHeight / 3; // square
insetHeight = window.innerHeight / 3;
splineCamera.aspect = insetWidth / insetHeight;
splineCamera.updateProjectionMatrix();
}
渲染两个相机场景:
function render() {
renderer.setViewport( 0, 0, window.innerWidth, window.innerHeight );
renderer.render(scene, camera);
renderer.setClearColor(0x222222, 1);
renderer.clearDepth(); // important!
//开启WebGL剪裁测试功能,如果不开启,.setScissor方法设置的范围不起作用
renderer.setScissorTest(true);
// setViewport方法设置的视口变换区域和剪裁方法setScissor设置的渲染区域保持一致
// 注意setScissor方法设置的是render影响范围,setViewport方法影响的是图形变换(屏幕等比)
renderer.setViewport(100, 200, width / 3, height / 3)
renderer.setScissor(20, 20, insetWidth, insetHeight);
renderer.setViewport(20, 20, insetWidth, insetHeight);
renderer.render( scene, splineCamera );
renderer.setScissorTest( false );
}
setScissor
通过 Three.js 渲染器 WebGLRenderer 的剪裁方法 .setScissor() 可以在canvas画布上定义一个局部矩形区域,这个矩形区可以称为剪裁框或剪裁区域。
剪裁方法 .setScissor() 功能简单说就是控制Threejs渲染器渲染方法 .render() 影响范围,threejs每执行一次渲染器渲染方法.render()就会得到一帧图像,canvas画布上的像素就会更新。在执行渲染器渲染方法 .render() 之前,如果通过剪裁方法 .setScissor() 定义了一个剪裁区域,那么执行渲染方法 .render() 的时候,剪裁框内的像素数据会被清除掉,canvas画布上处于剪裁框范围外的像素不受影响,渲染结果的像素数据只会覆盖剪裁区域内像素,这也就是 .setScissor() 方法为什么被称为剪裁方法的原因。
剪裁方法 .setScissor() 和视口方法 .setViewport() 功能区分
表面上看剪裁方法.setScissor()和视口方法.setViewport()好像都是定义一个渲染范围。不过要注意剪裁方法.setScissor()定义的是Threejs渲染结果像素覆盖影响的范围,剪裁方法.setScissor()并不影响Threejs场景中模型的平移缩放变换,而视口方法.setViewport()影响Threejs场景中模型的平移缩放变换,你可以通过注释上面代码中视口方法.setViewport()设置的代码,查看canvas画布上的渲染效果来体验视口方法.setViewport()的作用。
剪裁方法 .setScissor() 和视口方法 .setViewport() 用途
可以在网页上窗口上创建多个canvas元素,每一个canvas元素是一个渲染显示窗口,可以实现多窗口显示。但是每一个canvas画布对象对应一个独立的WebGL上下文, 不同canvas对象的三维场景渲染资源是不能共享的。开发产品有的时候需要同一份canvas资源显示在窗口的多个的区域中,比如在浏览器窗口的客户区分割出两个显示区域, 同一个三维场景以不同的比例分别显示在两个视口里面。同一个三维场景three.js程序是一样的,没有必要创建两个canvas对象,对于WebGL而言,使用原生WebGL APIgl.viewport()和gl.scissor()可以实现, 对于three.js而言使用WebGL渲染器的剪裁方法.setScissor()和视口方法.setViewport()用途即可。
.setScissorTest() 方法
Threejs渲染器的剪裁测试方法.setScissorTest()用于开启WebGL剪裁区域功能,如果不开启,.setScissor方法设置的范围不起作用该方法封装了原生WebGL的代码enable( gl.SCISSOR_TEST );和disable( gl.SCISSOR_TEST );
//开启剪裁测试功能,等价于原生WebGL:enable( gl.SCISSOR_TEST ); renderer.setScissorTest(true)
//关闭剪裁测试功能,等价于原生WebGL:disable( gl.SCISSOR_TEST ); renderer.setScissorTest(false)
Three.js渲染视口.setViewport()
通过Threejs渲染器的.setViewport()方法的四个参数可以在canvas画布上定义一个矩形的局部区域,如果没有通过.setViewport()方法设置一个局部区域的情况下,Threejs执行渲染方法.render()时候,渲染场景中模型进行平移缩放变换的时候以整个canvas画布为准,如果通过视口方法.setViewport()设置了一个矩形范围,那么执行渲染方法.render()时候,渲染场景中模型进行平移缩放变换的时候不以整个canvas画布为准,而是以视口方法.setViewport()设置的矩形范围为准。
简单地说,就是通过.setViewport()方法可以实现在一个canvas画布上面不同区域分别执行.render()输出渲染结果。
// 渲染函数
function render() {
// 默认情况就是整个canvas画布
// renderer.setViewport(0, 0, window.innerWidth, window.innerHeight)
//矩形范围宽高都是canvas画布的二分之一 左侧偏移20 顶部偏移200
renderer.setViewport(20, 200, window.innerWidth / 2, window.innerHeight / 2)
//矩形范围宽高都是canvas画布的四分之一 左侧偏移20 顶部偏移200
// renderer.setViewport(20, 200, window.innerWidth / 4, window.innerHeight / 4)
renderer.render(scene, camera);
mesh.rotateY(0.01);
requestAnimationFrame(render);
}
render();
three.js提供的渲染器对象WebGLRenderer实际上是对WebGL绘制函数gl.drawArray()、视口函数gl.viewport()等原生WebGL API方法的封装, three.js渲染器对象的方法.setViewport()对应的就是WebGL APIgl.viewport()。
源代码
<!DOCTYPE html>
<html lang="en">
<head>
<title>three.js webgl - curve modifier</title>
<meta charset="utf-8" />
<meta
name="viewport"
content="width=device-width, user-scalable=no, minimum-scale=1.0, maximum-scale=1.0"
/>
<link type="text/css" rel="stylesheet" href="main.css" />
</head>
<body>
<div id="info">
<a href="https://threejs.org" target="_blank" rel="noopener">three.js</a>
webgl - curve modifier
<button id="#foo">点击</button>
</div>
<!-- Import maps polyfill -->
<!-- Remove this when import maps will be widely supported -->
<script
async
src="https://unpkg.com/es-module-shims@1.3.6/dist/es-module-shims.js"
></script>
<script type="importmap">
{
"imports": {
"three": "../build/three.module.js",
"three/addons/": "./jsm/"
}
}
</script>
<script type="module">
import * as THREE from "three";
import { TransformControls } from "three/addons/controls/TransformControls.js";
import Stats from "three/addons/libs/stats.module.js";
import { Flow } from "three/addons/modifiers/CurveModifier.js";
import { FontLoader } from "three/addons/loaders/FontLoader.js";
import { TextGeometry } from "three/addons/geometries/TextGeometry.js";
import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";
import * as SkeletonUtils from "three/addons/utils/SkeletonUtils.js";
const ACTION_SELECT = 1,
ACTION_NONE = 0;
const curveHandles = [];
const mouse = new THREE.Vector2();
let stats;
let scene,
camera,
renderer,
rayCaster,
control,
flow,
action = ACTION_NONE;
let cube,
mixer,
AnimationAction,
clock,
sphere,
curve,
parent,
splineCamera,
cameraHelper,
mixer1,
flag = false;
let loopTime = 10 * 1000; // loopTime: 循环一圈的时间
// viewport
let insetWidth;
let insetHeight;
const binormal = new THREE.Vector3();
const normal = new THREE.Vector3();
init();
animate();
//开始漫游
document.getElementById("#foo").addEventListener("click", function () {
flag = !flag;
});
function init() {
scene = new THREE.Scene();
camera = new THREE.PerspectiveCamera(
40,
window.innerWidth / window.innerHeight,
0.01,
1000
);
camera.position.set(2, 2, 4);
camera.lookAt(scene.position);
clock = new THREE.Clock();
//创建轨迹线
const initialPoints = [
{ x: 1, y: 0, z: -1 },
{ x: 1, y: 0, z: 1 },
{ x: -1, y: 0, z: 1 },
{ x: -1, y: 0, z: -1 },
];
const boxGeometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
const boxMaterial = new THREE.MeshBasicMaterial();
for (const handlePos of initialPoints) {
const handle = new THREE.Mesh(boxGeometry, boxMaterial);
handle.position.copy(handlePos);
curveHandles.push(handle);
scene.add(handle);
}
curve = new THREE.CatmullRomCurve3(
curveHandles.map((handle) => handle.position)
);
curve.curveType = "centripetal";
curve.closed = true;
const points = curve.getPoints(50);
console.log(points);
const line = new THREE.LineLoop(
new THREE.BufferGeometry().setFromPoints(points),
new THREE.LineBasicMaterial({ color: 0x00ff00 })
);
//创建parent将机器人和漫游相机绑定在一起
parent = new THREE.Object3D();
scene.add(parent);
splineCamera = new THREE.PerspectiveCamera(
84,
window.innerWidth / window.innerHeight,
0.01,
1000
);
parent.add(splineCamera);
cameraHelper = new THREE.CameraHelper(splineCamera);//设置相机辅助器
scene.add(cameraHelper);
const loader = new GLTFLoader(); //加载机器人
loader.load("models/gltf/Soldier.glb", function (gltf) {
console.log(gltf);
gltf.scene.traverse(function (object) {
if (object.isMesh) object.castShadow = true;
});
const model1 = SkeletonUtils.clone(gltf.scene);
mixer1 = new THREE.AnimationMixer(model1);
mixer1.clipAction(gltf.animations[1]).play(); // idle
model1.scale.set(0.4, 0.4, 0.4);
scene.add(model1);
parent.add(model1);
});
//添加轨迹线到场景中
//scene.add(line);
//
const light = new THREE.DirectionalLight(0xffaa33);
light.position.set(-10, 10, 10);
light.intensity = 1.0;
scene.add(light);
const light2 = new THREE.AmbientLight(0x003973);
light2.intensity = 1.0;
scene.add(light2);
// 设置轨道
let railNum = 50;// 轨道分段数
new GLTFLoader()
.setPath("models/gltf/")
.load("SheenChair.glb", function (gltf) {
// 轨道容器
const railway = new THREE.Object3D();
let position = new THREE.Vector3();
let tangent = new THREE.Vector3();
for (let i = 0; i < railNum; i++) {
// 复制多段轨道模型
let model = gltf.scene.clone();
railway.add(model);
// 这里和前面一样通过获取位置和切线向量去计算每段轨道的朝向
position = curve.getPointAt(i / railNum);
tangent = curve.getTangentAt(i / railNum);
model.position.copy(position);
model.lookAt(tangent.add(position));
}
railway.rotateX(Math.PI);
scene.add(railway);
});
//设置自动沿着轨道移动的立方体
const geometry = new THREE.BoxGeometry(0.1, 0.1, 0.1);
const material = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
cube = new THREE.Mesh(geometry, material);
flow = new Flow(cube);
flow.updateCurve(0, curve);
scene.add(flow.object3D);
//console.log(flow);
//
renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setPixelRatio(window.devicePixelRatio);
renderer.setSize(window.innerWidth, window.innerHeight);
document.body.appendChild(renderer.domElement);
renderer.domElement.addEventListener("pointerdown", onPointerDown);
//拖拽立方体改变轨迹线
rayCaster = new THREE.Raycaster();
control = new TransformControls(camera, renderer.domElement);
control.addEventListener("dragging-changed", function (event) {
if (!event.value) {
const points = curve.getPoints(50);
line.geometry.setFromPoints(points);
flow.updateCurve(0, curve);
}
});
stats = new Stats();
document.body.appendChild(stats.dom);
onWindowResize();
window.addEventListener("resize", onWindowResize);
}
//设置窗体视口
function onWindowResize() {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
insetWidth = window.innerHeight / 3; // square
insetHeight = window.innerHeight / 3;
splineCamera.aspect = insetWidth / insetHeight;
splineCamera.updateProjectionMatrix();
}
function onPointerDown(event) {
action = ACTION_SELECT;
mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
}
function changeLookAt(t, mesh, curve) {
const tangent = curve.getTangentAt(t);
const lookAtVec = tangent.add(mesh.position); // 位置向量和切线向量相加即为所需朝向的点向量
mesh.lookAt(lookAtVec);
}
function changePosition(t, mesh, curve) {
const position = curve.getPointAt(t); // t: 当前点在线条上的位置百分比,后面计算
mesh.position.copy(position);
}
function animate() {
requestAnimationFrame(animate);
if (action === ACTION_SELECT) {
rayCaster.setFromCamera(mouse, camera);
action = ACTION_NONE;
const intersects = rayCaster.intersectObjects(curveHandles, false);
if (intersects.length) {
const target = intersects[0].object;
control.attach(target);
scene.add(control);
}
}
let time = Date.now();
let t = 1 - (time % loopTime) / loopTime; // 计算当前时间进度百分比
let t1 = (time % loopTime) / loopTime; // 计算当前时间进度百分比
console.log(curve);
if (flag == true) {
const delta = clock.getDelta();
mixer1.update(delta);//机器人奔跑的动画
// changePosition(t1, camera, curve);
// changeLookAt(t1, camera, curve);
//开始沿着轨迹线移动机器人
changePosition(t, parent, curve);
parent.position.y -=0.14;
changeLookAt(t, parent, curve);
}
//changeLookAt(t, camera, curve);
//mixer.update(time);
if (flow) {
//console.log(flow);
flow.moveAlongCurve(0.001);
}
render();
}
function render() {
//设置两个视口
renderer.setViewport( 0, 0, window.innerWidth, window.innerHeight );
renderer.render(scene, camera);
renderer.setClearColor(0x222222, 1);
renderer.clearDepth(); // important!
//开启WebGL剪裁测试功能,如果不开启,.setScissor方法设置的范围不起作用
renderer.setScissorTest(true);
renderer.setScissor(20, 20, insetWidth, insetHeight);
renderer.setViewport(20, 20, insetWidth, insetHeight);
renderer.render( scene, splineCamera );
renderer.setScissorTest( false );
// splineCamera.position.copy(splineCamera.position);
// splineCamera.quaternion.copy(splineCamera.quaternion);
//stats.update();
}
</script>
</body>
</html>