普通视图

发现新文章,点击刷新页面。
昨天以前首页

Three.js高效几何体创建指南:BufferGeometry深度解析

作者 Mintopia
2025年4月1日 18:08

1. 为什么选择BufferGeometry?

在Three.js开发中,几何体创建是3D建模的基础。相比传统Geometry,BufferGeometry具有显著优势:

  • 内存效率‌:采用TypedArray存储顶点数据,内存占用减少40%
  • 渲染性能‌:直接对接WebGL缓冲区,减少CPU-GPU数据传输
  • 灵活性‌:支持动态更新顶点数据
  • 扩展性‌:可处理百万级顶点的复杂模型

2. 基础创建流程

2.1 创建空几何体

const geometry = new THREE.BufferGeometry();

2.2 定义顶点数据

// 创建包含12个顶点的立方体(每个面2个三角形)
const vertices = new Float32Array([
  // 前表面
  -1, -1,  1,  // 0
   1, -1,  1,  // 1
   1,  1,  1,  // 2
  -1,  1,  1,  // 3
  
  // 后表面
  -1, -1, -1,  // 4
   1, -1, -1,  // 5
  // ...(完整顶点数据)
]);

// 创建并设置顶点属性
geometry.setAttribute(
  'position', 
  new THREE.BufferAttribute(vertices, 3)
);

2.3 定义索引数据(可选优化)

const indices = new Uint16Array([
  // 前表面
  0, 1, 2,  2, 3, 0,
  
  // 顶部表面
  2, 3, 7,  7, 6, 2,
  // ...(完整索引数据)
]);

geometry.setIndex(new THREE.BufferAttribute(indices, 1));

3. 高级属性配置

3.1 添加法线向量

const normals = new Float32Array(vertices.length);
for (let i = 0; i < vertices.length; i += 9) {
  // 计算三角形法线
  const vA = new THREE.Vector3(...vertices.slice(i, i+3));
  const vB = new THREE.Vector3(...vertices.slice(i+3, i+6));
  const vC = new THREE.Vector3(...vertices.slice(i+6, i+9));
  
  const cb = new THREE.Vector3().subVectors(vC, vB);
  const ab = new THREE.Vector3().subVectors(vA, vB);
  const normal = new THREE.Vector3()
    .crossVectors(cb, ab)
    .normalize();

  // 为每个顶点设置法线
  normals.set([...normal.toArray()], i);
  normals.set([...normal.toArray()], i+3);
  normals.set([...normal.toArray()], i+6);
}

geometry.setAttribute(
  'normal',
  new THREE.BufferAttribute(normals, 3)
);

3.2 添加UV坐标

const uvs = new Float32Array([
  // 前表面UV
  0, 0, 
  1, 0,
  1, 1,
  0, 1,
  
  // 其他面UV坐标...
]);

geometry.setAttribute(
  'uv',
  new THREE.BufferAttribute(uvs, 2)
);

4. 性能优化技巧

4.1 内存复用策略

// 创建可复用数组
const vertexPool = new Float32Array(300000); // 预分配内存

function updateGeometry(geometry) {
  const positions = geometry.attributes.position;
  
  // 直接修改已存在的BufferAttribute
  for (let i = 0; i < positions.count; i++) {
    positions.array[i * 3] += Math.random() * 0.1; // X坐标
    positions.array[i * 3 + 1] *= 0.95; // Y坐标
  }
  
  positions.needsUpdate = true;
}

4.2 几何体合并

const geometries = [];
const material = new THREE.MeshStandardMaterial();

// 生成多个几何体
for (let i = 0; i < 100; i++) {
  const geom = new THREE.BufferGeometry();
  // ...配置几何体
  geometries.push(geom);
}

// 合并几何体
const mergedGeometry = THREE.BufferGeometryUtils.mergeBufferGeometries(
  geometries
);

const mesh = new THREE.Mesh(mergedGeometry, material);
scene.add(mesh);

5. 动态几何体示例:波浪平面

// 初始化平面
const WIDTH_SEGMENTS = 128;
const SIZE = 20;

const geometry = new THREE.BufferGeometry();
const positions = new Float32Array(
  (WIDTH_SEGMENTS + 1) ** 2 * 3
);
const uvs = new Float32Array(
  (WIDTH_SEGMENTS + 1) ** 2 * 2
);

// 生成初始顶点
let vertexIndex = 0;
for (let y = 0; y <= WIDTH_SEGMENTS; y++) {
  for (let x = 0; x <= WIDTH_SEGMENTS; x++) {
    positions[vertexIndex * 3] = 
      (x / WIDTH_SEGMENTS) * SIZE - SIZE/2;
    positions[vertexIndex * 3 + 1] = 0;
    positions[vertexIndex * 3 + 2] = 
      (y / WIDTH_SEGMENTS) * SIZE - SIZE/2;
    
    uvs[vertexIndex * 2] = x / WIDTH_SEGMENTS;
    uvs[vertexIndex * 2 + 1] = y / WIDTH_SEGMENTS;
    
    vertexIndex++;
  }
}

// 设置几何体属性
geometry.setAttribute(
  'position',
  new THREE.BufferAttribute(positions, 3)
);
geometry.setAttribute(
  'uv',
  new THREE.BufferAttribute(uvs, 2)
);

// 创建动画效果
function animate() {
  const positions = geometry.attributes.position.array;
  const time = performance.now() * 0.001;
  
  for (let i = 0; i < positions.length; i += 3) {
    positions[i + 1] = Math.sin(
      positions[i] * 0.5 + positions[i+2] * 0.3 + time
    ) * 1.5;
  }
  
  geometry.attributes.position.needsUpdate = true;
}

6. 常见问题解决方案

6.1 内存管理

// 正确释放内存
function disposeGeometry(geometry) {
  geometry.dispose();
  geometry.attributes.position.array = null;
  geometry = null;
}

6.2 顶点更新优化

// 使用共享ArrayBuffer
const sharedBuffer = new ArrayBuffer(1024 * 1024);
const positions = new Float32Array(sharedBuffer);
const normals = new Float32Array(sharedBuffer);

7. 完整应用案例

// 创建参数化圆柱体
function createCylinder(radiusTop, radiusBottom, height, radialSegments) {
  const geometry = new THREE.BufferGeometry();
  const vertices = [];
  const uvs = [];

  // 生成侧面顶点
  for (let y = 0; y <= 1; y++) {
    const radius = y ? radiusTop : radiusBottom;
    for (let i = 0; i <= radialSegments; i++) {
      const angle = (i / radialSegments) * Math.PI * 2;
      vertices.push(
        Math.cos(angle) * radius,
        height * (y - 0.5),
        Math.sin(angle) * radius
      );
      uvs.push(i / radialSegments, y);
    }
  }

  // 设置几何属性
  geometry.setAttribute(
    'position',
    new THREE.BufferAttribute(new Float32Array(vertices), 3)
  );
  geometry.setAttribute(
    'uv',
    new THREE.BufferAttribute(new Float32Array(uvs), 2)
  );

  return geometry;
}

掌握BufferGeometry的使用可以显著提升Three.js应用的性能表现,特别适用于以下场景:

  • 大数据量可视化(如地图、分子模型)
  • 动态几何体(实时变形、粒子系统)
  • 程序化生成模型(参数化建模)
  • WebXR等高性能要求场景

建议通过实际项目加深理解,可以先从修改现有几何体参数开始,逐步尝试完整几何体创建流程。

模型苏醒计划:Threejs 让静态模型「叛逆」起来

作者 伶俜monster
2025年3月29日 16:39

模型类型

  • GLTF(.gltf,.glb): 是由 Khronos Group 开发的一种新型 3D 文件格式,具有高效、紧凑、支持 PBR 材质等优点,是当前主流的 3D 模型格式。

  • FBX(.fbx): 是由 Autodesk 开发的一种 3D 模型格式,支持几何体、材质、骨骼动画等丰富信息,但文件较大,加载速度较慢,常用于 3D 内容制作和动画。

  • OBJ(.obj): 是由 Wavefront Technologies 开发的一种 3D 模型格式,文件较小,但缺少动画和材质信息。

  • Collada(.dae): 是由 Khronos Group 开发的一种 3D 模型格式,支持几何体、材质、动画等多种信息,但文件较大,加载速度较慢。

  • STL(.stl): 是由 3D Systems 开发的一种 3D 模型格式,主要用于 3D 打印,文件较小,但缺少动画和材质信息。

  • PLY(.ply): 是一种用于存储 3D 扫描数据的文件格式,支持几何体和材质信息,但文件较大,加载速度较慢。

  • 3DS(.3ds): 是由 Autodesk 开发的一种 3D 模型格式,支持几何体、材质和简单动画。

  • VRML(.wrl): 是一种早期的 3D 模型格式,支持几何体、材质和简单动画。

  • JSON(.json): 是一种轻量级的数据交换格式,可以用于存储 3D 模型信息,支持几何体、材质、动画等信息,但需要自己解析和处理。

加载器

GLTFLoader

是 Three.js 提供的一个用于加载 GLTF 格式模型的加载器。

//导入GLTFLoader
import { GLTFLoader } from "three/examples/jsm/loaders/GLTFLoader.js";

//创建GLTFLoader实例
const gltfLoader = new GLTFLoader();

//加载模型
gltfLoader.load(
  "./models/scene.gltf",
  (gltf) => {
    scene.add(gltf.scene);
  },
  (xhr) => {
    console.log(xhr);
    let progress = (xhr.loaded / xhr.total) * 100;
    processDom.innerText = `加载进度:${progress.toFixed(2)}%`;
  },
  (err) => {
    console.log(err);
  }
);

gltfLoader.load(): 加载模型的方法。

  • 第一个参数: 模型的路径。

  • 第二个参数: 加载成功后的回调函数,参数为加载的模型对象。

  • 第三个参数: 加载过程中的回调函数,参数为加载进度。

  • 第四个参数: 加载失败的回调函数,参数为错误信息。

gltf.png

OBJLoader

是 Three.js 提供的一个用于加载 OBJ 格式模型的加载器。

//导入OBJLoader
import { OBJLoader } from "three/examples/jsm/loaders/OBJLoader.js";

//创建OBJLoader实例
const objLoader = new OBJLoader();

//加载模型
objLoader.load("./models/scene.obj", (obj) => {
  scene.add(obj);
  //添加贴图
  obj.traverse((child) => {
    if (child.isMesh) {
      child.material.map = new THREE.TextureLoader().load("./models/nezha.png");
    }
  });
});

objLoader.load(): 加载模型的方法。

  • 第一个参数: 模型的路径。

  • 第二个参数: 加载成功后的回调函数,参数为加载的模型对象。

  • 第三个参数: 加载过程中的回调函数,参数为加载进度。

  • 第四个参数: 加载失败的回调函数,参数为错误信息。

obj.traverse(): 遍历模型对象的所有子对象。

obj.gif

FBXLoader

是 Three.js 提供的一个用于加载 FBX 格式模型的加载器。

//导入FBXLoader
import { FBXLoader } from "three/examples/jsm/loaders/FBXLoader.js";

ColladaLoader

是 Three.js 提供的一个用于加载 Collada 格式模型的加载器。

//导入ColladaLoader
import { ColladaLoader } from "three/examples/jsm/loaders/ColladaLoader.js";

STLLoader

是 Three.js 提供的一个用于加载 STL 格式模型的加载器。

//导入STLLoader
import { STLLoader } from "three/examples/jsm/loaders/STLLoader.js";

PLYLoader

是 Three.js 提供的一个用于加载 PLY 格式模型的加载器。

//导入PLYLoader
import { PLYLoader } from "three/examples/jsm/loaders/PLYLoader.js";

VRMLLoader

是 Three.js 提供的一个用于加载 VRML 格式模型的加载器。

//导入VRMLLoader
import { VRMLLoader } from "three/examples/jsm/loaders/VRMLLoader.js";

TDSLoader

是 Three.js 提供的一个用于加载 3DS 格式模型的加载器。

//导入TDSLoader
import { TDSLoader } from "three/examples/jsm/loaders/TDSLoader.js";

JSONLoader

是 Three.js 提供的一个用于加载 JSON 格式模型的加载器。

//导入JSONLoader
import { JSONLoader } from "three/examples/jsm/loaders/JSONLoader.js";

注意: 以上加载器的使用方法基本相同,只是加载的模型格式不同。另外加载模型的时候可能需要添加光源,不然加载的模型是黑的。

模型对象

gltf 模型对象

gltfObj.png

  • scene: 模型场景,包含模型的所有子对象。

  • scenes: 模型场景数组,包含模型的所有场景。

  • animations: 模型动画,包含模型的所有动画。

  • asset: 模型资源,包含模型的元数据。

  • parser: 模型解析器,用于解析模型文件。

  • camera: 模型相机,用于渲染模型。

  • userData: 模型用户数据,用于存储用户自定义数据。

obj 模型对象

ObjObj.png

  • children: 模型子对象数组,包含模型的所有子对象。

位置操作

不管从 gltf 对象 scene 中还是 obj 对象中可以看到很多熟悉的属性,比如 position、scale 等,这些属性都是 Three.js 中常用的属性。加载模型成功后就可以操作模型的这些属性了。

以 Obj 模型为例,加载成功后可以看到模型对象中有一个 children 属性,这个属性是一个数组,里面包含了模型的所有子对象。每个子对象都是一个 Mesh 对象,Mesh 对象是 Three.js 中用于渲染几何体的对象,它包含了几何体和材质两个属性。我们可以通过操作这些属性来改变模型的显示效果。

//获取模型对象
objLoader.load("./models/scene.obj", (obj) => {
  //缩放
  obj.scale.set(0.5, 0.5, 0.5);
  //位移
  obj.position.set(0.5, 0, 0);
  // 旋转180度
  obj.rotation.y = Math.PI;
  scene.add(obj);
});

models-opr.png

材质操作

//获取模型对象
objLoader.load("./models/scene.obj", (obj) => {
  // 设置材质颜色
  obj.children[0].material.color.set(0xff0000); 
  // 设置材质纹理
  obj.children[0].material.map = new THREE.TextureLoader().load("./models/nezha.png");
  scene.add(obj);
});

models-material.png

动画操作

let mixer; // 动画混合器
//获取模型对象
gltfLoader.load("./models/Soldier.glb", (gltf) => {
  //获取动画
  const animations = gltf.animations;
  //创建动画混合器
  mixer = new THREE.AnimationMixer(gltf.scene);
  //创建动画动作
  const action = mixer.clipAction(animations[1]);
  //播放动画
  action.play();
  scene.add(gltf.scene);
});

let clock = new THREE.Clock();
//更新动画
function animate() {
  requestAnimationFrame(animate);
  if (mixer) {
    mixer.update(clock.getDelta());
  }
  renderer.render(scene, camera);
}

1. 创建 AnimationMixer 实例,传入模型对象。

AnimationMixer(模型对象): 动画混合器是用于场景中特定对象的动画的播放器。

2. 调用 clipAction() 方法创建动画动作,传入动画剪辑。

clipAction(动画剪辑): 创建动画动作,用于播放动画。

  • fadeIn(时间): 动画淡入时间。

  • fadeOut(时间): 动画淡出时间。

  • play(): 播放动画。

  • stop(): 停止动画。

  • reset(): 重置动画。

  • setLoop(循环方式): 设置动画循环方式。

3. 调用 play() 方法播放动画。

play(): 播放动画。

4. 在渲染循环中调用 update() 方法更新动画。

update(时间差): 更新动画。

models-animation.gif

书洞笔记

轻松搞定 TIFF:基于 Three.js 和 Cesium 的渲染技巧分享

2025年3月27日 10:55

前言:

你是否需要在 Three.js 和 Cesium 中渲染 TIFF 文件 却不知从何下手?本文提供 零到一的全流程实现,涵盖 加载、解析、渲染 等核心步骤,并通过完整 Vue + Vite 项目 演示如何高效可视化 TIFF 数据。

示例代码位置: gitcode.com/fujie/three…


1. 准备工作

1.1. 什么是 TIFF 文件?

TIFF(Tagged Image File Format)是一种灵活的位图图像格式,广泛用于存储高质量的图像数据,尤其是地理空间数据、卫星图像、航拍照片等。它的特点是很能“装”,支持多图层、多通道、无损压缩等特性。

1.2. 搭建本地服务

接下来,我们使用 Vite 初始化一个 Vue 项目,并清理掉不必要的样式和结构。同时,按照功能需求,创建两个组件:

  • ThreeTif.vue:专门用于通过 Three.js 渲染 TIFF 文件。
  • CesiumTif.vue:专门用于通过 Cesium 渲染 TIFF 文件。

这个过程非常简单,只需要几步就能完成。让我们开始吧!

1.3. 准备 TIFF 文件

我将准备好的 TIFF 文件放置在 public目录下, 这里我准备了三个tif文件


2. Three 渲染 TIFF 文件

在加载和渲染 TIFF 文件之前,我们需要确保项目已正确安装和配置 Three.js 及其必要的依赖。以下是具体的准备工作:

2.1. 安装 Three.js

通过 npm 或 yarn 安装 three 库,这是渲染 TIFF 文件的核心依赖。

2.2. 导入 Three.js 和 TIFF 加载器

在项目中导入 Three.js 以及适用于 TIFF 文件的加载器(如 tiffloader 或自定义的 TIFF 解析器),以便在代码中使用它们。

2.3. 渲染 TIFF 文件

Three 中,渲染之前,先创建场景(Scene)、相机(Camera) 和渲染器(Renderer)

使用 TTFLoader 或自定义加载器加载 TIFF 文件。


3. Cesium 中渲染 TIFF

在 Cesium 中渲染 TIFF 文件时,通常需要将 TIFF 文件转换为 Cesium 支持的格式,并将其作为影像图层加载到场景中。为了简化这一过程,可以使用 tiff-imagery-provider包。 通过 tiff-imagery-provider,可以轻松地将 TIFF 影像数据无缝集成到 Cesium 的三维地球场景中

3.1. 安装依赖

首先,安装 cesiumtiff-imagery-provider

3.2. 使用 tiff-imagery-provider

以下是使用 tiff-imagery-provider 在 Cesium 中渲染 TIFF 文件的完整代码:

3.3. 代码解析

TIFFImageryProvider:这是 tiff-imagery-provider 包提供的类,专门用于加载和处理 TIFF 文件。

url:指定 TIFF 文件的路径。

viewer.imageryLayers.addImageryProvider:将 TIFFImageryProvider 生成的影像图层添加到 Cesium 场景中。


4. 最终运行效果

页面左侧为Three 渲染效果, 右侧为Cesium 渲染效果


总结

本文详细介绍了如何使用 Three.jsCesium 渲染 TIFF 文件。通过 Three.js,我们利用 TTFLoader 加载 TIFF 文件并将其转换为纹理,最终在三维场景中渲染。而在 Cesium 中,通过 tiff-imagery-provider 简化了 TIFF 文件的加载和地理信息处理,使其能够无缝集成到三维地球场景中。

如果觉得本文对你有帮助,希望能够给我点赞支持一下哦 💪 也可以关注wx公众号:程序员付杰 ,一起学习编程技能

Threejs 物理引擎高阶:作用力、休眠与组合体奥秘

作者 伶俜monster
2025年3月26日 17:04

休眠

当一个物体在小范围内移动时,物理引擎会认为它是静止的,此时物体就会进入休眠状态,不再受到物理引擎的影响。休眠状态可以减少物理引擎的计算量,提高性能。

  • 设置物理世界休眠
const world = new CANNON.World();
world.allowSleep = true;

allowSleep: 是否允许休眠,默认为 false。

  • 设置物体休眠
const boxBody = new CANNON.Body({
  shape: new CANNON.Box(new CANNON.Vec3(0.5, 0.5, 0.5)),
  position: new CANNON.Vec3(-2, 5, 0),
  mass: 1,
  material: boxMaterialCon,
  collisionFilterGroup: GROUP1,
  collisionFilterMask: GROUP1 | GROUP2 | GROUP3 | GROUP4,
});
boxBody.allowSleep = true; // 允许休眠
boxBody.sleepSpeedLimit = 0.5; //速度小于0.5时休眠
boxBody.sleepTimeLimit = 1; // 休眠时间超过1秒
world.addBody(boxBody);

sleepSpeedLimit: 速度小于该值时,物体进入休眠状态。

sleepTimeLimit: 休眠时间超过该值时,物体从休眠状态恢复。

休眠事件

当物体从运动状态到休眠状态,会触发很多事件,我们可以监听这些事件,做一些操作。

  • 监听物体休眠事件
boxBody.addEventListener("sleep", () => {
  console.log("休眠");
});
  • 监听即将休眠事件
boxBody.addEventListener("sleepy", () => {
  console.log("即将休眠");
});

cannon-sleep.gif

物体组合

将多个物体组合成一个物体,可以减少物理引擎的计算量,提高性能。

// 创建一个物理组合体
const capsuleBody = new CANNON.Body({
  mass: 1,
  position: new CANNON.Vec3(0, 4, 0),
  material: boxMaterialCon,
  collisionFilterGroup: GROUP2,
  collisionFilterMask: GROUP1 | GROUP2 | GROUP3,
});
//创建球体
const sphereShape = new CANNON.Sphere(0.5);
//创建圆柱体
const cylinderShape = new CANNON.Cylinder(0.5, 0.5, 1.5, 20);
//创建胶囊体
capsuleBody.addShape(sphereShape, new CANNON.Vec3(0, 0.75, 0));
capsuleBody.addShape(cylinderShape, new CANNON.Vec3(0, 0, 0));
capsuleBody.addShape(sphereShape, new CANNON.Vec3(0, -0.75, 0));
capsuleBody.velocity.set(2, 0, 0); //设置速度
world.addBody(capsuleBody);

//创建胶囊体网格
const capsuleGeometry = new THREE.CylinderGeometry(0.5, 0.5, 1.5, 20);
const capsuleMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000 });
const capsule = new THREE.Mesh(capsuleGeometry, capsuleMaterial);
capsule.position.copy(capsuleBody.position);
capsule.quaternion.copy(capsuleBody.quaternion);
scene.add(capsule);
//创建球体网格
const sphereGeometry = new THREE.SphereGeometry(0.5, 20, 20);
const sphereMaterial = new THREE.MeshBasicMaterial({ color: 0x00ff00 });
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
sphere.position.set(0, 0.75, 0);
capsule.add(sphere);
const sphere2 = new THREE.Mesh(sphereGeometry, sphereMaterial);
sphere2.position.set(0, -0.75, 0);
capsule.add(sphere2);

addShape: 添加形状,第一个参数为形状,第二个参数为形状相对于物体的位置。

cannon组合体.gif

施加力

给物体施加力,可以让物体运动。

applyForce

//创建物理球
const sphereBody = new CANNON.Body({
  mass: 1,
  shape: new CANNON.Sphere(0.5),
  position: new CANNON.Vec3(0, 5, 0),
  material: boxMaterialCon,
  collisionFilterGroup: GROUP2,
  collisionFilterMask: GROUP1 | GROUP2 | GROUP3,
});
world.addBody(sphereBody);

//创建3D球
const sphereGeometry = new THREE.SphereGeometry(0.5, 8, 8);
const sphereMaterial = new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true });
const sphere = new THREE.Mesh(sphereGeometry, sphereMaterial);
scene.add(sphere);

//监听鼠标点击事件
window.addEventListener("click", () => {
  console.log("点击了:", sphereBody.position);
  sphereBody.applyForce(new CANNON.Vec3(10, 0, 0), sphereBody.position);
});

applyForce: 施加力,第一个参数为力的大小,第二个参数为力的方向。

cannon施加力.gif

applyLocalForce

sphereBody.quaternion.setFromAxisAngle(new CANNON.Vec3(0, 0, 1), Math.PI / 2); //设置旋转

window.addEventListener("click", () => {
  console.log("点击了:", sphereBody.position);
  sphereBody.applyLocalForce(new CANNON.Vec3(10, 0, 0), new CANNON.Vec3(0, -0.5, 0));
});

applyLocalForce: 施加局部力,第一个参数为力的大小,第二个参数为力的方向。

这个是相对物体自身的力,物体自身旋转后,力的方向也会跟着旋转。就会出现向后滚动的效果。

cannon施加力1.gif

applyImpulse

sphereBody.applyImpulse(new CANNON.Vec3(10, 0, 0), sphereBody.position);

applyImpulse: 施加冲量,第一个参数为冲量的大小,第二个参数为冲量的方向。

冲量是力的积累,力的作用时间越长,冲量越大。所以如果想达到上面的效果,就需要修改冲量的大小。如果是 60 帧,那么就需要将冲量的大小改为 new CANNON.Vec3(10*(1/60), 0, 0)

applyImpulse.gif

applyTorque

sphereBody.applyTorque(new CANNON.Vec3(0, 0, 10));

applyTorque: 施加扭矩,第一个参数为扭矩的大小,第二个参数为扭矩的方向。

applyTorque.gif

书洞笔记

❌
❌