three.js 实践,模拟《我的世界》场景

1,354 阅读14分钟

但行好事 莫问前程

前言

学习 three.js 有一段时间了,写了个demo模拟《我的世界》作为实践,然后以本文为阶段性总结。

demo中的功能(模型、灯光、阴影、后期渲染...)比较基础,基本都是由官方案例延伸而来,用于入门练习还是很合适的,总结的同时希望也能引起你学习的兴趣。

官方文档: documentation & fundamentals

往期文章:

  1. 【Canvas实战】仿明日方舟Logo粒子动画 vue3+ts
  2. 图形学 & canvas2d,实现跨窗口球体动画 部分自学理解

预览

QQ_1736122648663.png

掘金只能插西瓜视频链接,西瓜跟抖音合并后没了,又限制了gif大小 崩溃

视频:v.douyin.com/iyPf8WQp/

预览:demo (服务器带宽低传输慢)

源码:github.com/XIwE1/three…

分析

一个典型的 Three.js 应用至少包括 渲染器、场景、相机,以及在场景中的物体。

  1. 搭建场景scene
  • 地面(贴图纹理、花草树木、建筑物)
  • 天空(蓝底、云、阳光)
  1. 创建相机camera
  • 第一人称视角
  • 俯视视角
  1. 增加真实体验,还需要:
  • 立体感:增加灯光Light(方向光、半球光)& 阴影Shadow
  • 交互感:添加人物模型model,支持交互(移动、跳跃、旋转视角)
  • 动态感:增加云、动物等模型,添加相应动画animation

此外通过后期处理effectCompose中的渲染通道使用shader对画面进行二次处理(如辉光、像素化、撕裂...)。

最后加上一些符合风格的UI(加载进度、物体标签、过渡动画 ...) ,整个项目流程就完成了。

实现

准备工作

创建场景用于盛放物体和模型,创建两个透视相机(俯视角 & 第一人称)并摆放,创建渲染器配置画面渲染方式。

import * as THREE from "three";
import { OrbitControls } from "three/examples/jsm/controls/OrbitControls.js";

let _innerWidth = window.innerWidth;
let _innerHeight = window.innerHeight;
let _aspect = _innerWidth / _innerHeight;
let renderCamera;

// 创建场景
const scene = new THREE.Scene();
scene.background = new THREE.Color("black");

// 创建相机
const camera = new THREE.PerspectiveCamera(75, _aspect, 0.1, 200);
const personCamera = new THREE.PerspectiveCamera(70, _aspect, 0.5, 130);
// 摆放相机
camera.position.set(-7.6, 9.3, 40.3);
camera.lookAt(5, 2, 0);
renderCamera = camera;

// 创建渲染器
const renderer = new THREE.WebGLRenderer({ antialias: true });
renderer.setSize(_innerWidth, _innerHeight);
renderer.shadowMap.enabled = true;  // 开启阴影
renderer.setPixelRatio(window.devicePixelRatio);  // 设置像素比
renderer.outputEncoding = THREE.sRGBEncoding;  // 设置sRGB颜色编码
document.body.appendChild(renderer.domElement);

// 创建轨道控制器
const controls = new OrbitControls(renderCamera, renderer.domElement);
controls.update();

// 窗口自适应
window.onresize = () => {
  _innerWidth = window.innerWidth;
  _innerHeight = window.innerHeight;
  camera.aspect = _aspect;
  personCamera.aspect = _aspect;
  renderer.setSize(_innerWidth, _innerHeight);
  camera.updateProjectionMatrix();
  personCamera.updateProjectionMatrix();
};

animate();

function animate() {
  requestAnimationFrame(animate);
  controls.update();
  renderer.render(scene, renderCamera);
}

场景

地面 & 天空

创建地面组groundGroup放入场景,后续人物、动物、植物模型 都以此为参照(局部坐标系),进行添加。

const basicWidth = 100;
const basicHeight = 80;

// 创建地面组
const groundGroup = new THREE.Group();
scene.add(groundGroup);

创建一个 二维矩形PlaneGeometry + 高光材质MeshPhongMaterial,构造出地面网格mesh,旋转90度后平放作为地面。

{
  // 生成地面
  const groundWidth = basicWidth;
  const groundHeight = basicHeight;

  const roundGeometry = new THREE.PlaneGeometry(groundWidth, groundHeight);
  
  const roundMaterial = new THREE.MeshPhongMaterial({
    side: THREE.DoubleSide,
  });
  
  const roundMesh = new THREE.Mesh(roundGeometry, roundMaterial);
  roundMesh.receiveShadow = true;  // 地面接收阴影
  roundMesh.rotateX(Math.PI * -0.5);  // 旋转铺平
  groundGroup.add(roundMesh);
}

创建天空盒(skyBox),通过一个立方体包围整个场景,立方体的每个面都贴上不同的纹理,从而模拟出一个完整的天空或环境效果。

// 天空盒
{
  const skyBoxGeoMetry = new THREE.BoxGeometry(
    basicWidth,
    basicHeight,
    basicHeight
  );
  const skyBoxMaterial = new THREE.MeshBasicMaterial({
    color: "rgb(141,174,252)",
    side: THREE.BackSide,
  });

  const skyBoxMesh = new THREE.Mesh(skyBoxGeoMetry, skyBoxMaterial);
  skyBoxMesh.rotateX(Math.PI * -0.5);
  scene.add(skyBoxMesh);
}

整个场景摆放完后,在自由视角看来 就是一个盒子里摆放了各种模型,调整为人物视角 代入场景就达成了漫游的效果。

image.png

灯光 & 阴影

创建方向光DirectionalLight,设置它的投射范围,并将光源放入场景。

DirectionalLight常用来表现太阳光照的效果,光的方向是从它的位置照向目标点( 默认target(0,0,0) )的位置。

{
  const color = 0xffffff;
  const intensity = 10;
  
  const light = new THREE.DirectionalLight(color, intensity);
  light.position.set(0, 55, 30);
  // 设置方向光-阴影投射范围
  light.castShadow = true;
  light.shadow.camera.left = basicWidth / -2;
  light.shadow.camera.right = basicWidth / 2;
  light.shadow.camera.top = basicHeight / -1;
  light.shadow.camera.bottom = basicHeight / 1;
  light.shadow.camera.far = 90;

  scene.add(light);
}

这样得到了一个基础的场景,接下来将模型的加载到场景中,并对地面进行纹理贴图。

image.png

纹理 & 加载纹理

geometry 决定了物体内在的形状,而 material 决定了物体表面的效果,其中的关键就是使用texture纹理。

texture纹理 允许我们将图像应用到几何体对象上,并通过调整纹理的属性来实现更丰富的视觉效果。

{
  // 生成地面
  const groundWidth = basicWidth;
  const groundHeight = basicHeight;

  const roundGeometry = new THREE.PlaneGeometry(groundWidth, groundHeight);
  
+  const roundTexture = new THREE.TextureLoader().load("./texture/floor.png");
+  roundTexture.wrapS = THREE.RepeatWrapping;  // 纹理贴图在水平方向上重复放置
+  roundTexture.wrapT = THREE.RepeatWrapping;  // 纹理贴图在垂直方向上重复放置
+  roundTexture.magFilter = THREE.NearestFilter;
+  roundTexture.colorSpace = THREE.SRGBColorSpace;
+  roundTexture.repeat.set(groundWidth / 2, groundHeight / 2);
  const roundMaterial = new THREE.MeshPhongMaterial({
    side: THREE.DoubleSide,
-   color: "black",    
+   map: roundTexture,
  });
  
  const roundMesh = new THREE.Mesh(roundGeometry, roundMaterial);
  roundMesh.receiveShadow = true;  // 地面接收阴影
  roundMesh.rotateX(Math.PI * -0.5);  // 旋转铺平
  groundGroup.add(roundMesh);
}

image.png

模型

模型包含了一个三维物体的多种信息(网格、材质、纹理、皮肤、骨骼、动画、灯光),多是用3d建模软件 创建而成,常见的有3dMax,blender。官方推荐使用glTF(gl传输格式)的模型格式。

加载模型

以glft为例,我们封装一个加载函数,将模型加载、设置阴影、文件路径... 相关操作公共化。

import { GLTFLoader } from "three/addons/loaders/GLTFLoader.js";

// 设置加载路径
const gltfLoader = new GLTFLoader().setPath("./models/");

const loadModel = (path) =>
  new Promise((resolve) => {
    gltfLoader.load(
      path,
      (glb) => {
        const model = glb.scene;
        // 模型与它的子网格 接收和投射阴影
        model.receiveShadow = true;
        model.castShadow = true;
        model.traverse((child) => {
          if (child.isMesh) {
            child.castShadow = true;
            child.receiveShadow = true;
          }
        });
        resolve(glb);
      },
      () => {},
      (e) => console.warn(path + " failed \n [reason] " + e.message)
    );
  })  

加载完成后,我们可能还需要调整模型的 缩放scale、位置position、旋转rotate ...,最后再将模型放入场景或者局部坐标(group)中。

loadModel("tree.glb").then((glb) => {
    const model = glb.scene;
    
    model.rotateY(Math.PI * 0.5);
    model.position.set(25, 11.605, -10);
    model.scale.set(0.8, 0.8, 0.8);
    
    scene.add(model);
});

image.png

模型动画

一般来说动画模型都是3D美术创建,然后程序员通过threejs引擎加载解析,具体表现为 glb.animations 动画数组属性

image.png

animations包含着多个 AnimationClip,每个Clip都是一个完整的动画数据。

如何使用这些clip?

  1. 使用 AnimationMixer 传入动画目标 生成混合器,管理目标多个动画的播放、混合和更新。
  2. 使用 mixer.clipAction 读取指定的clip数据 返回action 用于控制动画的播放、暂停、停止等。
// 加载steve模型
const model = glb.scene;
// 创建控制对应模型的混合器
steve_mixer = new THREE.AnimationMixer(model);
// 根据clip数据 得到对应的动画剪辑
const action = steve_mixer.clipAction(glb.animations[0]);
action.play();

然后我们需要在渲染循环中更新动画混合器AnimationMixer,以确保动画能够播放。

const clock = new THREE.Clock();
const mixers = [];

function updateMixer(delta) {
  mixers.forEach(item => item.update(delta));
}

function animate() {
  requestAnimationFrame(animate);
  controls.update();
  renderer.render(scene, renderCamera);

+ updateMixer(clock.getDelta());
}
steve_walk.gif

开发者也可以使用模型中 SkinnedMeshBone类型的数据,给几何体上的顶点添加动画。

人物交互

  • 添加第一人称相机,在角色模型正前方 跟随角色移动
  • 通过鼠标滑动旋转角色的朝向
  • 通过键盘的WASD控制角色在场景中运动,空格进行跳跃

第一人称视角

使用group,将人物和相机添加到group中 摆放在对应位置,通过group的改变带动它们。

const viewGroup = new THREE.Group();

loadModel("steve.glb").then((glb) => { 
    // ...
-   scene.add(model);    
+   viewGroup.add(model);
    viewGroup.add(personCamera);

    // 摆方摄像机
    personCamera.position.setY(1.8);
    personCamera.position.setZ(0.5);
    personCamera.rotateY(Math.PI);
    personCamera.rotation.x = Math.PI;
    personCamera.updateMatrixWorld();
});

groundGroup.add(viewGroup);
// 在合适时,切换第一人称相机
renderCamera = personCamera;

增加cameraHelper可以看到相机在人物正前方,且因为层级原因,移动group时内部物体也会移动。

positionrotate的变化作用于group而非模型,模型model和相机camera相对group是不变的。

image.png

旋转

监听鼠标移动事件,控制角色朝向和视角,对于左右转向我们旋转group即可,上下移动只改变camera否则人物模型会产生倾斜。

// 设置上下视角的最大/小旋转值
const max_x_rotation = Math.PI / 2;
const min_x_rotation = Math.PI * 1.3;

function onMouseMove(event) {
  const move_x = Math.abs(event.movementX) > 1 ? event.movementX : 0;
  const move_y = Math.abs(event.movementY) > 1 ? event.movementY : 0;

  const x_rotate = move_y * 0.0012 * Math.PI;
  const y_rotate = -move_x * 0.0006 * Math.PI;

  viewGroup.rotation.y += y_rotate;
  // 设置上下视角的有效区间
  personCamera.rotation.x = Math.min(
    Math.max(personCamera.rotation.x + x_rotate, max_x_rotation),
    min_x_rotation
  );
}

document.addEventListener("mousemove", onMouseMove);

此时有一点点代入感了。

steve_roate_view.gif

steve_rotate.gif

优化模型观感可以找到模型的头部Node,在旋转时同步更新头部

移动 & 跳跃

通过监听键盘事件,去触发对应事件或者更新状态。

因为移动是可中断的过程,所以在每次render中去更新group位置。

而跳跃是一个完整的过程,所以按键时触发完整的动画流程。

// 键盘状态
const keyStates = {
  w: false,
  s: false,
  a: false,
  d: false,
};

document.addEventListener("keydown", (event) => {
  const key = event.key.toLowerCase();
  keyStates[key] = true;
  if (key === " ") jump();
});

document.addEventListener("keyup", (event) => {
  const key = event.key.toLowerCase();
  keyStates[key] = false;
});

因为角色移动时,可能有旋转角度 并不是笔直的方向,所以将与y轴的夹角代入三角函数计算公式,得到正确的移动距离。

juejin.cn/post/716049… 详细可以参考以前的文章。

const speed = 0.15;

function computedMoveDistance(speed, states, angle) {
  const sin = Math.sin(angle);
  const cos = Math.cos(angle);
  const front = states["w"] - states["s"];
  const right = states["d"] - states["a"];
  const x_distance = speed * (sin * front - cos * right);
  const z_distance = speed * (sin * right + cos * front);
  return [x_distance, z_distance];
}

function moveViewGroup() {
  const y_angle = viewGroup.rotation.y;
  const [x_distance, z_distance] = computedMoveDistance(speed, keyStates, y_angle);
  // 如果有position更新,则播放walk动画,否则播放stop动画
  if (!x_distance && !z_distance) return stop_action.play();
  walk_action.play();
  viewGroup.position.x += x_distance;
  viewGroup.position.z += z_distance;
};

function animate(lastTime = 0) {
  // ...
  moveViewGroup();
}

跳跃使用缓动函数控制动画流程,配合防抖保证流程的完整。

function bezier(t) {
  return t > 1 ? 0 : 4 * t * (1 - t);
}

let isJumping = false;

function jumpAction(startTime) {
  requestAnimationFrame(() => {
    const t = (Date.now() - startTime) / 800;
    const progress = bezier(t);
    viewGroup.position.y = 2 * progress;
    if (t > 1) {
      viewGroup.position.y = 0;
      isJumping = false;
      return;
    }
    requestAnimationFrame(() => jumpAction(startTime));
  });
}

function jump() {
  if (isJumping) return;
  isJumping = true;
  
  const startTime = Date.now();
  jumpAction(startTime);
}

此时已经能控制人物在场景中漫游了。

steve_move_jump.gif

其他特性

将所有模型都添加到场景中后,我们着手一些 “高级特性” 进行实践。

后期处理

简单的说就是先渲染一张图存起来,在这张图上面"添油加醋"(例如景深、发光、胶片微粒或是各种类型的抗锯齿)。

处理完后再渲染到屏幕上。这一过程threejs进行了封装,使用现成的包可以更快实现需求。

  1. EffectComposer(渲染后处理的通用框架,用于将多个渲染通道(pass)组合在一起创建特定的视觉效果)
  2. RenderPass(是用于渲染场景的通道。它将场景和相机作为输入,使用Three.js默认的渲染器(renderer)来进行场景渲染,并将结果输出给下一个渲染通道)
  3. UnrealBloomPass(是 three.js 中用于实现泛光效果的后期处理效果,通过高斯模糊和屏幕混合技术,将亮度较高的区域扩散开来,从而实现逼真的泛光效果。)
  4. ShaderPass(是一个自定义着色器的通道。它允许你指定自定义的着色器代码,并将其应用于场景的渲染结果。这样你可以创建各种各样的图形效果,如高斯模糊、后处理效果等)

几乎任何后期处理,都需要 EffectComposer.js 和 RenderPass.js。

以辉光+像素化效果为例:

import { EffectComposer } from "three/addons/postprocessing/EffectComposer.js";
import { RenderPass } from "three/addons/postprocessing/RenderPass.js";
import { ShaderPass } from "three/addons/postprocessing/ShaderPass.js";
import { UnrealBloomPass } from "three/addons/postprocessing/UnrealBloomPass.js";
import { OutputPass } from "three/addons/postprocessing/OutputPass.js";

const renderScene = new RenderPass(scene, renderCamera); // 创建一个渲染通道

// 编写像素化shader
const pixelationShader = {
  uniforms: {
    tDiffuse: { value: null },
    resolution: {
      value: new THREE.Vector2(_innerWidth, _innerHeight),
    },
    pixelSize: { value: 10.0 }, // Adjust this value to control pixelation size
  },
  vertexShader: `
    varying vec2 vUv;
    void main() {
      vUv = uv;
      gl_Position = projectionMatrix * modelViewMatrix * vec4(position, 1.0);
    }
  `,
  fragmentShader: `
    uniform sampler2D tDiffuse;
    uniform vec2 resolution;
    uniform float pixelSize;

    varying vec2 vUv;

    void main() {
      vec2 dxy = pixelSize / resolution;
      vec2 coord = dxy * floor(vUv / dxy);
      gl_FragColor = texture2D(tDiffuse, coord);
    }
  `,
};
// 创建像素化渲染通道
const pixelationPass = new ShaderPass(pixelationShader);
pixelationPass.needsSwap = true;

const params = {
  threshold: 0, // 辉光强度
  strength: 0.8, // 辉光阈值
  radius: 1, // 辉光半径
  exposure: 3,
};
// 创建辉光渲染通道
const bloomPass = new UnrealBloomPass(new THREE.Vector2(_innerWidth, _innerHeight));
bloomPass.threshold = params.threshold;
bloomPass.strength = params.strength;
bloomPass.radius = params.radius;

// 辉光合成器
const glowComposer = new EffectComposer(renderer);
glowComposer.renderToScreen = false;  // 辉光后还需要像素化处理,所以不渲染到屏幕
glowComposer.addPass(bloomPass);
glowComposer.addPass(pixelationPass);

// 创建辉光效果
const mixPass = new ShaderPass(
  new THREE.ShaderMaterial({
    uniforms: {
      baseTexture: { value: null },
      bloomTexture: { value: glowComposer.renderTarget2.texture },
    },
    vertexShader: document.getElementById("vertexshader").textContent,
    fragmentShader: document.getElementById("fragmentshader").textContent,
    defines: {},
  }),
  "baseTexture"
);

const outputPass = new OutputPass();

// 场景渲染器
const finalComposer = new EffectComposer(renderer); // 创建效果组合器
finalComposer.addPass(renderScene);
finalComposer.addPass(mixPass);
finalComposer.addPass(outputPass);

finalComposer.setSize(_innerWidth, _innerHeight);
glowComposer.setSize(_innerWidth, _innerHeight);

此外我们还要筛选要后期处理的目标,普遍做法是在后期处理时 让不需要处理的目标丢失材质material,在渲染完成后再恢复。

function composerRender() {
  scene.traverse(darkenNonBloomed);
  glowComposer.render();

  scene.traverse(restoreMaterial);
  finalComposer.render();
}

function darkenNonBloomed(obj) {
  if (obj.isMesh && bloomLayer.test(obj.layers) === false) {
    materials[obj.uuid] = obj.material;
    obj.material = darkenMaterial;
  }
}

function restoreMaterial(obj) {
  if (materials[obj.uuid]) {
    obj.material = materials[obj.uuid];
    delete materials[obj.uuid];
  }
}

function animate() {
  requestAnimationFrame(animate);
  controls.update();
  composerRender();
  // renderer.render(scene, renderCamera);
  // ...
}

QQ_1736113036345.png

QQ_1736113086724.png

标签

将三维物体和基于HTML的标签相结合,各个DOM元素被包含到一个CSS2DObject实例中,并被添加到场景图中。

import {
  CSS2DRenderer,
  CSS2DObject,
} from "three/addons/renderers/CSS2DRenderer.js";

// 创建 CSS2DRenderer
const labelRenderer = new CSS2DRenderer();
labelRenderer.setSize(_innerWidth, _innerHeight);
labelRenderer.domElement.style.position = "absolute";
labelRenderer.domElement.style.top = "0";
labelRenderer.domElement.style.pointerEvents = "none";
document.body.appendChild(labelRenderer.domElement);

function createLabelByModel(model) {
  const labelDiv = document.createElement("div");
  labelDiv.className = "label-item";
  labelDiv.textContent = model.name;
  const label = new CSS2DObject(labelDiv);
  label.element.style.visibility = "hidden";
  label.position.set(0, 2, 0);
  model.label = label;
  model.add(label);
  return label;
}

QQ_1736114728107.png

移动轨迹

创建一个平滑的二维样条曲线SplineCurve 作模型的运动轨迹,根据时间获取轨迹进度,通过进度获取两个点 模型位置 和 目标位置,用于更新和设置模型朝向。

const curve = new THREE.SplineCurve([
  new THREE.Vector2(-10, 0),
  new THREE.Vector2(-5, 5),
  new THREE.Vector2(0, 0),
  new THREE.Vector2(5, -5),
  new THREE.Vector2(10, 0),
  new THREE.Vector2(5, 5),
  new THREE.Vector2(-5, 5),
  new THREE.Vector2(-10, -10),
  new THREE.Vector2(-15, -8),
  new THREE.Vector2(-10, 0),
]);

const points = curve.getPoints(50);
const geometry = new THREE.BufferGeometry().setFromPoints(points);
const material = new THREE.LineBasicMaterial({ color: 0xff0000 });
const splineObject = new THREE.Line(geometry, material);
splineObject.rotation.x = Math.PI * 0.5;
splineObject.position.y = 0.05;
scene.add(splineObject);
    
function moveFoxModelByCurve(curve) {
  const foxCurve = curve;
  return function (time) {
    if (!fox_model) return;

    const progress = (time * 0.00003) % 1;
    const _progress = (progress + 0.01) % 1;
    const [positionX, positionZ] = foxCurve.getPointAt(progress);
    const [targetX, targetZ] = foxCurve.getPointAt(_progress);

    fox_model.position.set(positionX, fox_model.position.y, positionZ);
    fox_model.lookAt(targetX, fox_model.position.y, targetZ);
    fox_model.rotateY(Math.PI / 2);
  };
}
const moveFox = moveFoxModelByCurve(curve);

QQ_1736114893458.png

指针锁定

developer.mozilla.org/zh-CN/docs/…

为了提升用户体验,使用 指针锁定 API。

通过它可以访问原始的鼠标运动,把鼠标事件的目标锁定到一个单独的元素,这就消除了鼠标在一个单独的方向上到底可以移动多远这方面的限制,并从视图中删去光标。 ——MDN

function lockPointer() {
  if (
    !("pointerLockElement" in document) &&
    !("mozPointerLockElement" in document) &&
    !("webkitPointerLockElement" in document)
  ) {
    console.log("Pointer lock unavailable in this browser.");
    return;
  }
  if ("mozPointerLockElement" in document) {
    console.log(
      "Firefox needs full screen to lock mouse. Use Chrome for the time being."
    );
    return;
  }
  const element = document.body;
  element.requestPointerLock =
    element.requestPointerLock ||
    element.mozRequestPointerLock ||
    element.webkitRequestPointerLock;

  element.requestPointerLock();
}

function pointerLockChange() {
  if (document.pointerLockElement) {
    console.log("pointerLockElement", document.pointerLockElement);
    showLockPointer();
  } else {
    showUnLockPointer();
  }
}

document.addEventListener("pointerlockchange", pointerLockChange, false);
document.addEventListener("webkitpointerlockchange", pointerLockChange, false);
document.addEventListener("mozpointerlockchange", pointerLockChange, false);

边界校验

前文提过:“创建天空盒(skyBox),通过一个立方体包围整个场景,立方体的每个面都贴上不同的纹理,从而模拟出一个完整的天空或环境效果。”

我们调整贴图给skyBox前后左右的面,贴上纹理,展示边界的效果。

// 天空盒
{
  const skyBoxGeoMetry = new THREE.BoxGeometry(
    basicWidth,
    basicHeight,
    basicHeight
  );
  // 创建渐变 将Canvas作为纹理
  let texture;
  let _texture;
  {
    const canvas = document.createElement("canvas");
    const ctx = canvas.getContext("2d");
    canvas.width = 512;
    canvas.height = 512;
    const gradient = ctx.createLinearGradient(0, 0, 0, canvas.height);
    gradient.addColorStop(0.494, "white");
    gradient.addColorStop(0.5, "lightgreen");
    gradient.addColorStop(0.506, "white");

    ctx.fillStyle = gradient;
    ctx.fillRect(0, 0, canvas.width, canvas.height);

    texture = new THREE.Texture(canvas);
    texture.needsUpdate = true;
    texture.wrapS = THREE.RepeatWrapping;
    texture.wrapT = THREE.RepeatWrapping;
    texture.magFilter = THREE.NearestFilter;
    texture.colorSpace = THREE.SRGBColorSpace;
    _texture = texture.clone();
    _texture.center = new THREE.Vector2(0.5, 0.5);
    _texture.rotation = -Math.PI / 2;
    _texture.needsUpdate = true;
  }

  const skyBoxBorderMaterial = new THREE.MeshBasicMaterial({
    color: "rgb(141,174,252)",
    side: THREE.BackSide,
    map: texture,
  });
  const _skyBoxBorderMaterial = new THREE.MeshBasicMaterial({
    color: "rgb(141,174,252)",
    side: THREE.BackSide,
    map: _texture,
  });
  const skyBoxMaterial = new THREE.MeshBasicMaterial({
    color: "rgb(141,174,252)",
    side: THREE.BackSide,
  });

  const skyBoxMesh = new THREE.Mesh(skyBoxGeoMetry, [
    _skyBoxBorderMaterial,
    _skyBoxBorderMaterial,
    skyBoxBorderMaterial,
    skyBoxBorderMaterial,
    skyBoxMaterial,
    skyBoxMaterial,
  ]);
  skyBoxMesh.rotateX(Math.PI * -0.5);
  scene.add(skyBoxMesh);
}

QQ_1736115777762.png

总结

简单总结一下:

  1. 一个典型的 Three.js 应用至少包括 渲染器renderer、场景scene、相机camera,以及在场景中的物体模型model
  2. geometry 决定了物体内在的形状,而 material 决定了物体表面的效果
  3. mesh 网格 —— 组合几何体和材质,可以理解为用一种特定的材质(Material)来绘制的一个特定的几何体(Geometry)
  4. texture纹理 允许我们将图像应用到几何体对象上,并通过调整纹理的属性来实现更丰富的视觉效果。
  5. group 我们要建立有层次的局部坐标系 给物体提供稳定的锚点,让物体只关心自己在局部坐标系中的位置,否则我们的计算会非常困难
  6. skyBox 创建天空盒,通过一个立方体(或者其他几何体)包围整个场景,立方体的每个面都贴上不同的纹理,从而模拟出一个完整的天空或环境效果
  7. model 模型包含了一个三维物体的多种信息(网格、材质、纹理、皮肤、骨骼、动画、灯光),推荐使用glTF(gl传输格式)的模型格式
  8. glb.animations 动画数组属性animations包含着多个 AnimationClip,每个Clip都是一个完整的动画数据。AnimationMixer 生成混合器, mixer.clipAction 读取指定的clip数据。
  9. effectComposer 能够对渲染画面进行后期处理,EffectComposer 生成合成器 将多个渲染通道(pass)组合在一起,RenderPass 将场景和相机作为输入 为后续渲染通道提供场景。

文章到这就结束了,样式相关的内容就不赘述了。代码还有不少可优化的地方,后续我会更新到git上,文章会同步对应的内容,之后会尝试粒子相关的内容。

后续学习方向:物理库,shader,blender建模 . . .

todolist:

  • 更好的边界效果
  • 物理交互
  • 昼夜更替
  • 下雨
    ...

结语🎉

不要光看不实践哦,希望本文能对你有所帮助。

持续更新前端知识,脚踏实地不水文,真的不关注一下吗~

写作不易,如果有收获还望 点赞+收藏 🌹

才疏学浅,如有问题或建议还望指教~