Skip to content

开发助手

素材

帮助理解

物体交互

模型检查

工具

Vite 处理 public 资源

  • threejs 里的各种加载器:设置 setPath textureLoader = new THREE.TextureLoader().setPath('images/')
  • vite.config.js 配置增加 assetsInclude: ["**/*.m4a", "**/*.mp4", "**/*.gltf"] 认识模型类型资源
  • public 里的资源在 js 里引用时不要加/public/,如在 public 下有 sounds 资源,只需要写:sounds/game/Ambient.m4a即可;如在 public 下有 videos 资源,只需要写:videos/video1.mp4

THREE.JS 使用记录

解决旋转不同轴之后带来的问题(万向轴)

  • 当旋转了 x 轴,此时 Y 轴已经不再是原来的方向(默认朝上),而是始终垂直与旋转后的 X 轴。

解决方案 1:reorder('YXZ'),重新设定 XYZ 轴的设定顺序

js
mesh.rotation.reorder("YXZ");
mesh.rotation.x = Math.PI / 4;
mesh.rotation.y = Math.PI / 4;

// 以上代码表示:
// Y轴先旋转45°,然后X轴再旋转45°。目的是合理的安排我们达到某个旋转效果凡人顺序。

解决方案 2:用 Group 包裹目标元素。(自定义箭头指示器时遇到的问题)

先调整目标元素的旋转、位置,然后加入到 Group 中,以 Group 作为最终生成的物体,并添加到 scene 中。

让移动物体始终保持在屏幕中央位置:角色移动控制,视角跟踪

js
camera.lookAt(objMesh.position);

淡入淡出效果:改变物体材质的 opacity 属性

js
// 初始状态
let material = new THREE.MeshBasicMaterial({
  transparent: true,
  opacity: 0.1,
  map: texture,
});// x6个
let materials = [material x6个]
let geometry = new THREE.BoxGeometry(100, 100, 100);
let cube = new THREE.Mesh(geometry, materials);

// 淡入
cube.material.map((item) => {
  gsap.to(item, {
    opacity: 1,
    duration: 1,
  });
});

// 淡出
cube.material.map((item) => {
  gsap.to(item, {
    opacity: 0,
    duration: 1,
  });
});

场景加入了灯光没效果?

场景需要开启阴影,灯光需要开启投影,物体需要允许投影,地板需要接受投影。

  • 渲染器开启:renderer.shadowMap.enabled = true; 激活
  • 渲染器开启:renderer.physicallyCorrectLights = true; 进行优化
  • 检查灯光的castShadow是否开启:light.castShadow = true; // default false
  • 物体材质不能是MeshBasicMaterial,需要是 MeshStandardMaterial(需要深入验证)
  • 开启物体的castShadow选项:plane.castShadow = true;
  • 需要一个接收阴影的物体(一般是地面):floor.receiveShadow = true
/** Open Shadows */
// step1.渲染器开启阴影
renderer.shadowMap.enabled = true;

// step2.物体开启投下阴影
moonLight.castShadow = true
doorLight.castShadow = true
ghost1.castShadow = true
ghost2.castShadow = true
ghost3.castShadow = true
walls.castShadow = true
roof.castShadow = true

// step3.地板开启接收阴影
floor.receiveShadow = true

// stre4.优化阴影效果
renderer.shadowMap.type = THREE.PCFSoftShadowMap; // default THREE.PCFShadowMap
doorLight.shadow.mapSize.width = 256
doorLight.shadow.mapSize.height = 256
doorLight.shadow.camera.far = 5
ghost1.shadow.mapSize.width = 256
ghost1.shadow.mapSize.height = 256
ghost1.shadow.camera.far = 5

参考链接

迷雾效果:解决场景边缘割裂问题

js
const FLOOR_SIZE = 40;
const FOG_COLOR = "#262837";

const scene = new THREE.Scene();
const renderer = new THREE.WebGL1Renderer({ antialias: true });

// 像场景中添加「迷雾」
scene.fog = new THREE.Fog(FOG_COLOR, 1, FLOOR_SIZE - 15);

// 解决场景边缘割裂问题(无缝融合)
renderer.setClearColor(FOG_COLOR);

渲染器优化(场景效果)

  • 解决多个模型组合后,模型闪烁问题
js
const renderer = new THREE.WebLRenderer(
// 设置抗锯齿
antialias: true,
// 对数深度缓冲区
logarithmicDepthBuffer: true,
)
  • 优化场景中显示效果
js
renderer.shadowMap.enabled = true; //开启阴影

renderer.shadowMap.type = THREE.PCFSoftShadowMap; //调整 render 的阴影算法为 PCFSoftShadowMap

renderer.physicallyCorrectLights = true; //以符合物理直觉的方式显示光

renderer.outputEncoding = THREE.sRGBEncoding; //色彩饱和度高一点

renderer.toneMapping = THREE.ACESFilmicToneMapping; //电影渲染效果
renderer.toneMappingExposure = 1.5; //电影效果曝光度

给物体添加渐变色材质

js
// 三个像素,每个像素一种颜色的图片[黑,暗,亮]
const gradientTexture = textureLoader.load("/images/texture/3_colors.png");
// 就近选色,不要自动进行颜色过渡
gradientTexture.magFilter = THREE.NearestFilter;

const material = new THREE.MeshToonMaterial({
  color: "#ffffff",
  gradientMap: gradientTexture, //设置渐变色(明,暗,更暗)
});
const mesh = new THREE.Mesh(
  new THREE.TorusKnotGeometry(0.8, 0.35, 100, 16),
  material
);
mesh.castShadow = true;

CubeTextureLoader 立体场景

js
scene.background = new THREE.CubeTextureLoader()
  .setPath("/images/skybox/race.parts/")
  .load([
    "px.png", //left(+x)
    "nx.png", //right(-x)
    "py.png", //top(+y)
    "ny.png", //bottom(-y)
    "pz.png", //front(+z)
    "nz.png", //back(-z)
  ]);

视差效果

js

粒子效果

js
/**
 * 创建粒子
 */
const particlesCount = 200; //粒子数量
const particlesPostions = new Float32Array(particlesCount * 3); //每三个长度单位代表一个(粒子)坐标
for (let i = 0; i < particlesCount; i++) {
  particlesPostions[i * 3 + 0] = (Math.random() - 0.5) * 12; //i.x
  particlesPostions[i * 3 + 1] =
    objectDistance / 2 - Math.random() * (objectDistance * objects.length); //i.y [-height, 2*height+height/2]
  particlesPostions[i * 3 + 2] = (Math.random() - 0.5) * 12; //i.z
}
const particlesGeometry = new THREE.BufferGeometry(); //创建Buffer物体
// 设置Buffer物体的位置信息
particlesGeometry.setAttribute(
  "position",
  new THREE.BufferAttribute(particlesPostions, 3)
);
// 粒子材质
const particlesMaterial = new THREE.PointsMaterial({
  color: palette.material_color,
  sizeAttenuation: true, //开启粒子衰减,越远越小
  size: 0.03,
});
// 粒子物体
const particlesMesh = new THREE.Points(particlesGeometry, particlesMaterial);
scene.add(particlesMesh);

物理世界 CANNON.js,设置材料的几种方式

  1. 组合两种材质
js
// Materials-给重力世界增加材质,模拟真实世界物体接触的效果(摩擦力、反弹力)
const concreteMaterial = new CANNON.Material("concrete"); //混凝土材质
const plasticMaterial = new CANNON.Material("plastic"); //塑料材质
const concretePlasticContactMaterial = new CANNON.ContactMaterial(
  concreteMaterial,
  plasticMaterial,
  {
    friction: 0.1, //摩擦力
    restitution: 0.7, //反弹力
  }
);
world.addContactMaterial(concretePlasticContactMaterial);

// 并且,把concreteMaterial和plasticMaterial分别加到地板和物体上去

// Sphere
const sphereShape = new CANNON.Sphere(1); //和ball半径一致
const sphereBody = new CANNON.Body({
  mass: 5, //球体质量
  position: new CANNON.Vec3(0, 8, 0), //用真空三位向量定位
  shape: sphereShape,
  material: plasticMaterial, //++
}); //类似Three.Group
world.addBody(sphereBody);

// Floor
const floorShape = new CANNON.Plane();
const floorBody = new CANNON.Body();
floorBody.mass = 0;
floorBody.material = concreteMaterial; //++
floorBody.addShape(floorShape);
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2); //沿X轴旋转90度,放平
world.addBody(floorBody);
  1. 只用一种默认的材料
js
/**方式2.只用一种默认的材料 */
const defaultMaterial = new CANNON.Material("default");
const defaultContactMaterial = new CANNON.ContactMaterial(
  defaultMaterial,
  defaultMaterial,
  {
    friction: 0.1, //摩擦力
    restitution: 0.7, //反弹力
  }
);
world.addContactMaterial(defaultContactMaterial);

// Sphere
const sphereShape = new CANNON.Sphere(1); //和ball半径一致
const sphereBody = new CANNON.Body({
  mass: 5, //球体质量
  position: new CANNON.Vec3(0, 8, 0), //用真空三位向量定位
  shape: sphereShape,
  material: defaultMaterial,
}); //类似Three.Group
world.addBody(sphereBody);

// Floor
const floorShape = new CANNON.Plane();
const floorBody = new CANNON.Body();
floorBody.mass = 0;
floorBody.material = defaultMaterial;
floorBody.addShape(floorShape);
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2); //沿X轴旋转90度,放平
world.addBody(floorBody);
  1. 最精简方式:只给 world 设置默认材质,不再给物体和地板设置材料
js
/**方式3.只给world设置默认材质 */
const defaultMaterial = new CANNON.Material("default");
const defaultContactMaterial = new CANNON.ContactMaterial(
  defaultMaterial,
  defaultMaterial,
  {
    friction: 0.1, //摩擦力
    restitution: 0.7, //反弹力
  }
);
world.addContactMaterial(defaultContactMaterial);
world.defaultContactMaterial = defaultContactMaterial; //++

// Sphere
const sphereShape = new CANNON.Sphere(1); //和ball半径一致
const sphereBody = new CANNON.Body({
  mass: 5, //球体质量
  position: new CANNON.Vec3(0, 8, 0), //用真空三位向量定位
  shape: sphereShape,
}); //类似Three.Group
world.addBody(sphereBody);

// Floor
const floorShape = new CANNON.Plane();
const floorBody = new CANNON.Body();
floorBody.mass = 0;
floorBody.addShape(floorShape);
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2); //沿X轴旋转90度,放平
world.addBody(floorBody);

物理世界,增加可接触地板

js
// Canno-Floor
const floorShape = new CANNON.Plane();
const floorBody = new CANNON.Body();
floorBody.mass = 0;
floorBody.addShape(floorShape);
floorBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2); //沿X轴旋转90度,放平
world.addBody(floorBody);

物理世界 CANNON.js,作用力

1.applyLocalForce 给物体局部的作用力

js
sphereBody.applyLocalForce(
  new CANNON.Vec3(300, 0, 0),
  new CANNON.Vec3(0, 0, 0)
); //朝X轴正方向,给球中心一个300的力

2.applyForce 给全局的作用力

js
sphereBody.applyForce(new CANNON.Vec3(-1, 0, 0), sphereBody.position); //朝X轴反方向,持续给一个模拟风吹的力,可以放在animate里持续调用

物理世界,监听碰撞接触,并优化碰撞发声技巧

js
let controlObjects = [];

const boxGeometry = new THREE.BoxGeometry(1, 1, 1);
const boxMaterial = new THREE.MeshStandardMaterial({
  color: "silver",
  roughness: 0.3,
  metalness: 0.3,
});
function createBoxObject(width, height, depth, position = { x, y, z }) {
  // 立方体-Mesh
  const mesh = new THREE.Mesh(boxGeometry, boxMaterial);
  mesh.scale.set(width, height, depth);
  mesh.position.copy(position);
  mesh.castShadow = true;
  scene.add(mesh);

  // 立方体-Body
  const boxShape = new CANNON.Box(
    new CANNON.Vec3(width / 2, height / 2, depth / 2)
  ); //canno.js的立方体不同之处:以盒子中心点为起点向对立方向画w,h,d
  const body = new CANNON.Body({
    mass: 1, //立方体质量
    position: new CANNON.Vec3(0, 3, 0), //用真空三位向量定位
    shape: boxShape,
    material: defaultMaterial,
  }); //类似Three.Group
  body.position.copy(position);

  // 监听碰撞接触
  body.addEventListener("collide", playHitSound);
  world.addBody(body);

  controlObjects.push({
    mesh,
    body,
  });
}

// Sounds
const hitSound = new Audio("/sounds/collide.mp3");
/**
 * 优化发生技巧!
 * collision:碰撞强度
 * 1.可以根据collision.contact.getImpactVelocityAlongNormal()获取到碰撞强度
 * 2.根据碰撞强度,选择性的播放声音
 * 3.根据碰撞强度,播放不同音量大小的声音
 * 4.根据碰撞强度,播放不同声音
 * 5.可以通过随机音量,让效果更真实
 * 6.开始播放前,先暂停上一次播放,效果更真实
 */
const playHitSound = (collision) => {
  hitSound.currentTime = 0;
  if (!collision) {
    hitSound.play();
    return;
  }
  let strong = collision.contact.getImpactVelocityAlongNormal(); //获取碰撞强度
  if (strong > 1.5) {
    hitSound.volume = Math.random(); //随机音量,更真实
    hitSound.currentTime = 0;
    hitSound.play();
  }
};

const clock = new THREE.Clock();
let oldElapsedTime = 0;
function animate() {
  // update physic world
  const elapsedTime = clock.getElapsedTime();
  const deltaTime = elapsedTime - oldElapsedTime;
  oldElapsedTime = elapsedTime;

  world.step(1 / 60, deltaTime, 3);

  // 批量更新重力物体
  controlObjects.map(({ mesh, body }) => {
    mesh.position.copy(body.position); //用物理世界中的物体位置更新threejs中的物体位置
    mesh.quaternion.copy(body.quaternion); //处理旋转,如立方体下落或者碰撞应该会倾倒再静止
  });

  renderer.render(scene, camera);
  window.requestAnimationFrame(animate);
}

animate();

物理世界,碰撞 1.性能优化

js
// 创建重力系统
let world = new CANNON.World();
world.boradphase = new CANNON.SAPBroadphase(world); //一种更优质高效的碰撞检测(检测自身cell和相邻的cell里的物体)
world.allowSleep = true; //允许物体沉睡,这样就不会去测试它,提高性能!
world.gravity.set(0, -9.82, 0);

gui 的使用

新建一个操作对象,并把键注册到 gui 操作面板。gui.add(debugObj, "生成立方体");

js
let controlObjects = [];
const gui = new dat.GUI({ name: "My GUI" });

const folder1 = gui.addFolder("Test Field");
const debugObj = {
  生成立方体: () => {
    console.log("生成立方体");
    createBoxObject(Math.random() * 3, Math.random() * 3, Math.random() * 3, {
      x: (Math.random() - 0.5) * 10,
      y: 4,
      z: (Math.random() - 0.5) * 10,
    });
  },
  移除物体: () => {
    console.log("移除物体");
    controlObjects.map(({ mesh, body }) => {
      body.removeEventListener("collide", playHitSound);
      world.removeBody(body);
      scene.remove(mesh);
    });
    controlObjects = [];
  },
};
gui.add(debugObj, "生成立方体");
gui.add(debugObj, "移除物体");

⏱️ 播放模型动画

1.多个独立动画:直接按索引取对应动画播放

js
playerMixer = new THREE.AnimationMixer(gltf.scene);
actionIdel = gltf.animations[0]; //动画1
actionWalk = gltf.animations[1]; //动画2

// 分别取出播放
playerMixer.clipAction(actionIdel).play();
playerMixer.clipAction(actionWalk).play();

// 一个提取动画的方法
function createModelActions(gltf) {
  let mixer = new THREE.AnimationMixer(gltf.scene);
  let actions = { lastAction: "" };
  gltf.animations.map((item) => {
    actions[item.name] = mixer.clipAction(item); //存储动作播放函数
  });
  return { mixer, actions };
}

2.多个动画合并为一个动画:需借助 threejs 动画工具进行裁剪。

比如 idel、walk、jump 这些动作都在一个时间线上,需要借助 threejs 的动画工具函数对动画帧按照指定时间进行裁切,分别得到需要的动画。

js
playerMixer = new THREE.AnimationMixer(gltf.scene);
const clipIdel = THREE.AnimationUtils.subclip(
  gltf.animations[0],
  "idel",
  0,
  30
);
actionIdel = playerMixer.clipAction(clipIdel);
actionIdel.play();

3.多个动画流畅切换

js
function switchAction(curAction, newAction) {
  curAction.fadeOut(0.3); //淡出
  newAction.reset(); //重置播放位置
  newAction.setEffectiveWeight(1); //设置动画权重
  newAction.play();
  newAction.fadeIn(0.3); //淡入
}

设置视频纹理报错

【THREE.WebGLState: DOMException: Failed to execute 'texImage2D' on 'WebGLRenderingContext': The video element contains cross-origin data, and may not be loaded.】

给 video 增加 crossorigin 属性,video.setAttribute("crossorigin", "anonymous");

iOS 设备播放失败

ios 设备禁止自动播放,所以默认情况下,创建好 video 后是不能调用 video.play()的。需要在合适的时机再去触发!

在一些移动浏览器(chrome)里自动个全屏播放,

给 video 增加两个属性,video.setAttribute("webkit-playsinline", true);video.setAttribute("playsinline", true);

以下是一个完善的创建 vidoe 的方法:

js
function createVideo({ src = "", loop = true, muted = true }) {
  const video = document.createElement("video");
  video.src = src;
  video.loop = loop;
  video.setAttribute("webkit-playsinline", true);
  video.setAttribute("playsinline", true);
  video.setAttribute("crossorigin", "anonymous"); //解决视频资源跨域问题,否则在设置VideoTexture时会报错
  video.muted = muted;
  // video.play(); //如果直接播放 ios上会出错!

  return video;
}

Released under the MIT License.