介绍
在龙年春节即将来临之际,本期将制作一套3D龙年图案的饼干物理效果,你可以学习或者了解到,怎么用blender制作几个龙年图案的贺岁饼干,以及用three.js将他们渲染出来,并结合cannon-es给予物理特性,点击可视区域可以生成出随机图案的饼干落下。
演示
jsmask.gitee.io/dragon-year...
正文
饼干模型
这里我们可以从一些模型网站下载合适的饼干模型去调整或者自己建立圆柱调整几个顶点展UV烘焙出来也行,我就是在sketchfab找了一个低模改了些顶点坐标使用的尽可能的处理干净些,如下图:
接下来,要给这个饼干做上龙年图案,所以我们在网上找几个合眼的png图片,我这里找了6张可爱的小龙贺年的图案。
然后,最关键的一步是如何把这些图案融合到已经做好的uv纹理贴图上。这里有三个方案可供选择,第一个是PS软件把图案叠加到纹理图上。第二方案是再创建一个uv图,展开把相应位置贴上去,然后两个uv图叠加。第三个方案,也是我用到的方案,就是建立一个笔刷把笔刷的纹理设置成该图案用镂空模式,设置成正片叠底最后确定好位置刷上去。
渲染模型
我们把做好的模型都导出来,这里我导出一张白模,和6张纹理图,后面我会渲染这个模型后,把纹理图贴上生成不同图案的饼干。至于基础的场景搭建,可以看我以往的文章或者参考源码,这里就不过多赘述了,只讲关键的部分。
首先,我们先获取到模型和它需要的纹理贴图,并下载下来,注意此次开发用的是vite
,所以一次性获取多张纹理图获取用到了import.meta.glob
,把文件夹下所有符合的图片都拿到,然后用THREE.TextureLoader
加载生成出纹理来,并且我们要把它存到 textures
数组中,以备后续使用,至于,glb
模型就很简单用 GLTFLoader
加载处理就好,当然需要稍微处理下它的大小位置角度是否产生阴影或接收阴影等,本次只有两个glb
模型分别是饼干和桌子(充当平面)。
js
const images = import.meta.glob(`./assets/textures/*.png`, { eager: true });
const imageUrls = Object.values(images).map((mod) => mod.default);
import ck from "./assets/cookie.glb?url" // 饼干模型
import tb from "./assets/kitchen_table.glb?url" // 桌子模型
class Sketch {
constructor() {
this.loadingManager = new THREE.LoadingManager();
this.textureLoader = new THREE.TextureLoader(this.loadingManager);
this.gltfLoader = new GLTFLoader(this.loadingManager);
this.cookiesItems = []
this.textures = []
this.init();
return this;
}
init() {
this.loadTexture();
this.loadCookieModel();
this.addPlane()
this.loadingManager.onProgress = ((url, num, total) => {})
this.loadingManager.onLoad = () => {
this.play();
}
}
loadTexture() {
imageUrls.forEach(url=>{
this.textures.push(this.textureLoader.load(url))
})
}
loadCookieModel() {
this.gltfLoader.load(ck, (gltf) => {
this.model = gltf.scene;
this.model.children[0].position.y -= .01
this.model.children[0].scale.setScalar(.08)
this.model.rotation.y = 0
this.model.traverse(mesh => {
if (mesh.isMesh) {
mesh.receiveShadow = true;
mesh.castShadow = true;
}
})
})
}
addPlane() {
this.gltfLoader.load(tb, (gltf) => {
let table = gltf.scene;
table.scale.setScalar(.1)
table.rotation.y = Math.PI / 2
table.position.y = -0.912
table.traverse(mesh => {
if (mesh.isMesh) {
mesh.receiveShadow = true;
}
})
this.scene.add(table)
})
}
}
然后,我们可以用给模型附上我们之前做好的纹理材质贴图,图案是随机给出的,之后我们执行这段代码把饼干添加到场景中,可以看到已经可以渲染出来了。
js
createCookies(position) {
let cookies = this.model.clone();
let material = cookies.getObjectByName("Object_2").material.clone()
material.map = this.textures[Math.floor(Math.random()*this.textures.length)]
material.map.flipY = false;
material.map.encoding = THREE.sRGBEncoding
material.map.wrapS = 1000
material.map.wrapT = 1000
material.needsUpdate = true;
cookies.getObjectByName("Object_2").material = material
cookies.position.set(0, .15, 0)
this.scene.add(cookies);
}
添加物理
这里物理库我使用了比较简单易上手的 cannon-es
,而且物理调试器引入跟它配套的cannon-es-debugger
,可以非常方便的看到物理世界的情况,另外,值得说的是,如果把mass
属性代表质量,如果设置成0则表示无限大,我们做桌面的时候就要设置它为0,这样就变成一个静态的刚体了。
js
import * as CANNON from "cannon-es"
import CannonDebugger from 'cannon-es-debugger'
initCannon() {
this.world = new CANNON.World({
gravity: new CANNON.Vec3(0, -9.82 * .18, 0), // m/s²
})
this.world.allowSleep = true
let groundShape = new CANNON.Plane();
let groundBody = new CANNON.Body({ mass: 0 });
groundBody.addShape(groundShape);
groundBody.position.y = -0.8
groundBody.quaternion.setFromAxisAngle(new CANNON.Vec3(1, 0, 0), -Math.PI / 2);
this.world.addBody(groundBody);
let tableBody = new CANNON.Body({
mass: 0,
});
const tableShape = new CANNON.Box(new CANNON.Vec3(.88, .01, .58))
tableBody.addShape(tableShape);
tableBody.position.set(-.07, 0, 0);
tableBody.type = CANNON.Body.STATIC;
this.world.addBody(tableBody)
this.cannonDebugger = CannonDebugger(this.scene, this.world, {
onInit(body, mesh) {
mesh.visible = true
},
})
}
接下来,我们要改造下createCookies
方法,其实创建时也能生成对应的物理形状,再用cookiesItems
数组存储起来,每次update
的时候也要执行下,使其模型与刚体状态同步。
js
createCookies(position) {
let cookies = this.model.clone();
let material = cookies.getObjectByName("Object_2").material.clone()
material.map = this.textures[Math.floor(Math.random()*this.textures.length)]
material.map.flipY = false;
material.map.encoding = THREE.sRGBEncoding
material.map.wrapS = 1000
material.map.wrapT = 1000
material.needsUpdate = true;
cookies.getObjectByName("Object_2").material = material
cookies.position.set(0, .15, 0)
let body = new CANNON.Body({
mass: 0.2,
material: new CANNON.Material({
friction: .15,
restitution: .01,
})
});
const boxShape = new CANNON.Cylinder(.08, .08, .026, 32)
body.addShape(boxShape);
body.position.set(0, 0.2, 0);
if (position) {
body.position.copy(position)
}
this.cookiesItems.push({
cookies,
body
})
this.world.addBody(body);
this.scene.add(cookies);
}
updatePhysics(dt) {
this.world.fixedStep()
this.cannonDebugger.update()
if (this.cookiesItems.length) {
this.cookiesItems.forEach(({ cookies, body }) => {
cookies.position.copy(body.position)
cookies.quaternion.copy(body.quaternion)
})
}
}
物理交互
接下来,我们要鼠标点击并抬起后会发出一道射线,如果射线捕获的桌面那么就会在这个点位生成一个随机图案的饼干,从空中落下,执行自由落体运动。
js
mouseEffects() {
this.handlePointerDown = this.pointerDown.bind(this)
this.handlePointerMove = this.pointerMove.bind(this);
this.handlePointerUp = this.pointerUp.bind(this);
this.renderer?.domElement.addEventListener("pointerdown", this.handlePointerDown, false);
this.renderer?.domElement.addEventListener("pointermove", this.handlePointerMove, false);
this.renderer?.domElement.addEventListener("pointerup", this.handlePointerUp, false);
}
pointerDown(event) {
this.changeControls = false;
}
pointerMove(event) {}
pointerUp(event) {
if (this.changeControls) return;
this.mouse.x = (event.clientX / window.innerWidth) * 2 - 1;
this.mouse.y = -(event.clientY / window.innerHeight) * 2 + 1;
this.raycaster.setFromCamera(this.mouse, this.camera);
const intersects = this.raycaster.intersectObjects(this.scene.children);
if (intersects.length > 0) {
const point = intersects[0].point;
this.createCookies(new THREE.Vector3(point.x, 1, point.z))
}
}
没有声音可没意思,最后一步,要让饼干在碰撞的瞬间发出声音来,所以其刚体要加碰撞监听事件,当碰撞的力度越大声音也越大。
js
createCookies(position) {
// ...
body.addEventListener('collide', playHitSound)
}
js
import hit from "./assets/hit.mp3?url"
const hitSound = new Audio(hit)
const playHitSound = (collision) => {
const impactStrength = collision.contact.getImpactVelocityAlongNormal()
if (impactStrength > 1) {
hitSound.volume = Math.max(0, Math.min(impactStrength * .25, 1));
hitSound.currentTime = 0
hitSound.play()
}
}
源码
结语
本期文章希望会给各位小伙伴带来收获或者帮助,里面其实还可以做很多好玩的事情,喜欢的小伙伴可以多多点赞收藏哦,最后祝龙年大吉,健康快乐~