ThreeJS实现水母漂浮

大家好,我是王大傻。这次给大家带来的是一个ThreeJS加载FBX水母模型的效果(PS:模型中的动画更好一点,但是因为是GLTF的缘故有些线条无法导出)。

前期准备

在文章开始大傻首先列举下此次文章主要完成的效果以及使用的技术栈,整体效果展示(PS:大傻的GPU配置不太好,看起来不太流畅)

效果展示

  • blender建模水母
  • 导出为FBX模型材质
  • ThreeJS加载模型材质

本文示例使用的技术栈

  • Three
  • parcel
  • gsap

模型准备

这里就简单给大家讲一下建模的步骤,首先呢,我们这期希望拿到的就是一个水母的形态,对于水母,我们将其分为上半部分的水母头,这块我们是通过建立一个球几何体进行替代,在通过挤压变形使其塌陷为一个类半球体,如图

接下来的就比较麻烦了,需要通过几何节点编辑器进行设置,感兴趣的我会在文章的末尾提供这个含几何节点的素材

这里完成之后我们就可以通过动画编辑器,或者是修改器中的波浪效果进行动画编辑,当然如果想要做一些更为复杂的动画,则可以通过骨骼动画,通过给我们的水母添加热区以及骨骼进而完成更为复杂的动作,这里就先不讲了

中期准备

接下来当然就是把上述模型的材质导出为我们的FBX材质了,为什么不用GLTF呢?因为GLTF材质对于我们做的节点动画不友好,大傻也是试了很多次没成功,最后不得不用FBX材质的内容了,在我们导出时候如果选择了压缩,那么我们在实际使用时候需要通过draco去解析我们的模型。

接下来说下我们项目的具体结构以及依赖:

因为我们是用的parcel进行demo演示的,所以相对于脚手架来说自然是少了很多

这是我们的目录,下面就是我们具体代码部分,首先就是three最基础的场景摄像机控制器的加入:

javascript 复制代码
import * as THREE from "three";
// 导入控制器
import {
  OrbitControls
} from "three/examples/jsm/controls/OrbitControls";
const scene = new THREE.Scene();

// 初始化相机
const camera = new THREE.PerspectiveCamera(
  75,
  window.innerWidth / window.innerHeight,
  0.1,
  2000
);

// 设置相机位置
camera.position.set(-50, 50, 130);
// 更新摄像头宽高比例
camera.aspect = window.innerWidth / window.innerHeight;
// 更新摄像头投影矩阵
camera.updateProjectionMatrix();

scene.add(camera);

// 初始化渲染器
const renderer = new THREE.WebGLRenderer({
  // 设置抗锯齿
  antialias: true,
  //   对数深度缓冲区
  logarithmicDepthBuffer: true,
});
renderer.outputEncoding = THREE.sRGBEncoding;

// 设置渲染器宽高
renderer.setSize(window.innerWidth, window.innerHeight);

// 监听屏幕的大小改变,修改渲染器的宽高,相机的比例
window.addEventListener("resize", () => {
  camera.aspect = window.innerWidth / window.innerHeight;
  camera.updateProjectionMatrix();
  renderer.setSize(window.innerWidth, window.innerHeight);
});

// 将渲染器添加到页面
document.body.appendChild(renderer.domElement);

// 实例化控制器
const controls = new OrbitControls(camera, renderer.domElement);

function render() {
  // 渲染场景

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

这样我们就拥有了一个不断更新的渲染器,之后我们引入我们的水母模型,因为我们模型里面做了动画,所以上面的render我们需要改为这样

javascript 复制代码
let mixers = [];
let clock = new THREE.Clock()

function render() {
  // 渲染场景

  renderer.render(scene, camera);
  // console.log(mixer)
  // 引擎自动更新渲染器
  if (mixers && mixers.length) {
    const t = clock.getDelta();
    mixers.forEach((mixer) => mixer.update(t));
    // console.log(mixers)
    controls.update(t)
  }
  requestAnimationFrame(render);
}

这时候我们再去通过FBXLoader 来加载我们的模型,这里我们通过一个for循环将模型通过克隆了若干份

javascript 复制代码
const loader = new FBXLoader()
loader.load("./model/test.fbx", (obj) => {
  for (let i = 0; i < 10; i++) {
    let flyLight = obj.clone(true);
    let x = (Math.random() - 0.5) * 300;
    let z = (Math.random() - 0.5) * 300;
    let y = Math.random() * 20 + 15;
    flyLight.position.set(x, y, z);
    gsap.to(flyLight.rotation, {
      y: 2 * Math.PI,
      duration: 20 + Math.random() * 30,
      repeat: -1,
    });
    gsap.to(flyLight.position, {
      // x: "+=" + Math.random() * 1,
      y: "+=" + Math.random() * 20 + 30,
      yoyo: true,
      duration: 5 + Math.random() * 10,
      repeat: -1,
    });
    flyLight.animations = obj.animations
    let mixer = new THREE.AnimationMixer(obj);
    let actions = []
    for (let j = 0; j < flyLight.animations.length; j++) {
      actions[j] = mixer.clipAction(flyLight.animations[j], flyLight)
      actions[j].setDuration(Math.random() * 10 + 10)
      // actions[j].loop = THREE.LoopRepeat
      mixers.push(mixer)
    }
    actions.forEach((action) => {
      action.play();
    });
    flyLight.scale.multiplyScalar(Math.random()*.1)
    console.log(flyLight.scale)
    scene.add(flyLight);
  }

})

那么我们的效果如图所示 在这里着重讲下这个动画,因为我们用的是for循环生成,所以我们在mixer.clipAction时候需要传入第二个参数,这个参数就是以哪个模型为基准,这样我们的全部克隆模型就可以正常显示动画。除此以外,我们还可以通过Instance去进行多模型渲染,这样也能优化我们的渲染速度。大傻此处只是用了简单的for循环生成的随机位置。至此为止我们的水母动画已经完成了,是不是感觉有点枯燥。别急,往下看

后期处理

这里就要对我们的水母进行后期处理了,首先是给水母加点水,这里大傻用的是three官方提供的水:

javascript 复制代码
// 创建水面
const waterGeometry = new THREE.CircleBufferGeometry(300, 64);
const water = new Water(waterGeometry, {
  textureWidth: 1920,
  textureHeight: 1920,
  color: 0xeeeeff,
  flowDirection: new THREE.Vector2(1, 1),
  scale: 1,
});
water.position.y = 3;
// 水面旋转至水平
water.rotation.x = -Math.PI / 2;
 scene.add(water);

加完水平面后我们的效果如下(PS:感觉水中的倒影太真实的扣1)

最后就是对我们的全部场景进行的包装,这里大傻在shadertoy上面找了一个海洋并进行更正:

大家如果找这种shader的话记得一定找channel内容没有的,这样转化的也比较简单,其他的还需要texture2D等一些操作,话不多说,将转化后的部分贴一下

glsl 复制代码
// vertex.glsl
void main(){
    vec4 modelPosition=modelMatrix*vec4(position,1.);
    gl_Position=projectionMatrix*viewMatrix*modelPosition;
}
glsl 复制代码
// fragment.glsl
// 时间
uniform float iTime;
// 分辨率
uniform vec2 iResolution;
const int ITER_GEOMETRY=3;
const int ITER_FRAGMENT=5;
const float SEA_HEIGHT=.6;
const float SEA_CHOPPY=4.;
const float SEA_SPEED=.8;
const float SEA_FREQ=.16;
const vec3 SEA_BASE=vec3(.1,.19,.22);
const vec3 SEA_WATER_COLOR=vec3(.8,.9,.6);
const int NUM_STEPS=8;
const float PI=3.141592;
const float EPSILON=1e-3;
const mat2 octave_m=mat2(1.6,1.2,-1.2,1.6);// math
float hash(vec2 p){
    float h=dot(p,vec2(127.1,311.7));
    return fract(sin(h)*43758.5453123);
}
float noise(in vec2 p){
    vec2 i=floor(p);
    vec2 f=fract(p);
    vec2 u=f*f*(3.-2.*f);
    return-1.+2.*mix(mix(hash(i+vec2(0.,0.)),
    hash(i+vec2(1.,0.)),u.x),mix(hash(i+vec2(0.,1.)),
    hash(i+vec2(1.,1.)),u.x),u.y);
}// lighting
float diffuse(vec3 n,vec3 l,float p){
    return pow(dot(n,l)*.4+.6,p);
}
float sea_octave(vec2 uv,float choppy){
    uv+=noise(uv);
    vec2 wv=1.-abs(sin(uv));
    vec2 swv=abs(cos(uv));
    wv=mix(wv,swv,wv);
    return pow(1.-pow(wv.x*wv.y,.65),choppy);
}
float map(vec3 p){
    float freq=SEA_FREQ;
    float amp=SEA_HEIGHT;
    float choppy=SEA_CHOPPY;
    vec2 uv=p.xz;uv.x*=.75;
    float d,h=0.;
    for(int i=0;i<ITER_GEOMETRY;i++){
        d=sea_octave((uv+(1.+iTime*SEA_SPEED))*freq,choppy);
        d+=sea_octave((uv-(1.+iTime*SEA_SPEED))*freq,choppy);
        h+=d*amp;
        uv*=octave_m;freq*=1.9;
        amp*=.22;choppy=mix(choppy,1.,.2);
    }
    return p.y-h;
}
float map_detailed(vec3 p){
    float freq=SEA_FREQ;
    float amp=SEA_HEIGHT;
    float choppy=SEA_CHOPPY;
    vec2 uv=p.xz;
    uv.x*=.75;
    float d,h=0.;
    for(int i=0;i<ITER_FRAGMENT;i++){
        d=sea_octave((uv+(1.+iTime*SEA_SPEED))*freq,choppy);
        d+=sea_octave((uv-(1.+iTime*SEA_SPEED))*freq,choppy);
        h+=d*amp;
        uv*=octave_m;freq*=1.9;
        amp*=.22;choppy=mix(choppy,1.,.2);
    }
    return p.y-h;
}
float specular(vec3 n,vec3 l,vec3 e,float s){
    float nrm=(s+8.)/(PI*8.);
    return pow(max(dot(reflect(e,n),l),0.),s)*nrm;
}// sky
vec3 getSkyColor(vec3 e){
    e.y=max(e.y,0.);
    return vec3(pow(1.-e.y,2.),1.-e.y,.6+(1.-e.y)*.4);
}// sea
mat3 fromEuler(vec3 ang){
    vec2 a1=vec2(sin(ang.x),cos(ang.x));
    vec2 a2=vec2(sin(ang.y),cos(ang.y));
    vec2 a3=vec2(sin(ang.z),cos(ang.z));
    mat3 m;
    m[0]=vec3(a1.y*a3.y+a1.x*a2.x*a3.x,a1.y*a2.x*a3.x+a3.y*a1.x,-a2.y*a3.x);
    m[1]=vec3(-a2.y*a1.x,a1.y*a2.y,a2.x);
    m[2]=vec3(a3.y*a1.x*a2.x+a1.y*a3.x,a1.x*a3.x-a1.y*a3.y*a2.x,a2.y*a3.y);
    return m;
}
vec3 getSeaColor(vec3 p,vec3 n,vec3 l,vec3 eye,vec3 dist){
    float fresnel=clamp(1.-dot(n,-eye),0.,1.);
    fresnel=pow(fresnel,3.)*.65;
    vec3 reflected=getSkyColor(reflect(eye,n));
    vec3 refracted=SEA_BASE+diffuse(n,l,80.)*SEA_WATER_COLOR*.12;
    vec3 color=mix(refracted,reflected,fresnel);
    float atten=max(1.-dot(dist,dist)*.001,0.);
    color+=SEA_WATER_COLOR*(p.y-SEA_HEIGHT)*.18*atten;
    color+=vec3(specular(n,l,eye,60.));
    return color;
}
float heightMapTracing(vec3 ori,vec3 dir,out vec3 p){
    float tm=0.;
    float tx=1000.;
    float hx=map(ori+dir*tx);
    if(hx>0.)return tx;
    float hm=map(ori+dir*tm);
    float tmid=0.;
    for(int i=0;i<NUM_STEPS;i++){
        tmid=mix(tm,tx,hm/(hm-hx));
        p=ori+dir*tmid;
        float hmid=map(p);
        if(hmid<0.){
            tx=tmid;hx=hmid;
        }else{
            tm=tmid;
            hm=hmid;
        }}
        return tmid;
    }
    vec3 getNormal(vec3 p,float eps){
        vec3 n;
        n.y=map_detailed(p);
        n.x=map_detailed(vec3(p.x+eps,p.y,p.z))-n.y;
        n.z=map_detailed(vec3(p.x,p.y,p.z+eps))-n.y;n.y=eps;
        return normalize(n);
    }
    void main(){
        vec2 uv=gl_FragCoord.xy/iResolution.xy;
        uv=uv*2.-1.;
        uv.x*=iResolution.x/iResolution.y;
        float time=iTime*.3;
        vec3 ang=vec3(sin(time*3.)*.1,sin(time)*.2+.3,time);
        vec3 ori=vec3(0.,3.5,time*5.);
        vec3 dir=normalize(vec3(uv.xy,-2.));
        dir.z+=length(uv)*.15;dir=normalize(dir)*fromEuler(ang);// tracing
        vec3 p;
        heightMapTracing(ori,dir,p);
        vec3 dist=p-ori;vec3 n=getNormal(p,dot(dist,dist)*.1/iResolution.x);
        vec3 light=normalize(vec3(0.,1.,.8));// color
        vec3 color=mix(vec3(0.,1.,.8),getSeaColor(p,n,light,dir,dist),pow(smoothstep(0.,-.05,dir.y),.3));// post
        gl_FragColor=vec4(pow(color,vec3(.75)),1.);
    }
    

这两个文件直接复制下来就行,接下来就是我们的three内容

javascript 复制代码
const skyGeometry = new THREE.SphereGeometry(1000, 60, 60);
const skyMaterial = new THREE.ShaderMaterial({
  vertexShader: vertexShader,
  fragmentShader: fragmentShader,
  transparent: false,
  side: THREE.DoubleSide,
  blending: THREE.AdditiveBlending,
  depthWrite: false,
  depthTest: false,
  uniforms: {
    iTime: {
      value: 0,
    },
    iResolution: {
      value: new THREE.Vector2(1920, 1920),
    },
  },
  // map: texture,
});
gsap.to(skyMaterial.uniforms.iTime, {
  value: 3,
  duration: 10,
  repeat: -1,
  yoyo: true,
});
skyGeometry.scale(1, 1, -1);
const sky = new THREE.Mesh(skyGeometry, skyMaterial);
scene.add(sky);

最终效果如下,有需要的可以联系大傻提供下模型的blend版,大家也可以自行制作动画效果。

相关推荐
拉不动的猪2 分钟前
# 关于初学者对于JS异步编程十大误区
前端·javascript·面试
玖釉-7 分钟前
解决PowerShell执行策略导致的npm脚本无法运行问题
前端·npm·node.js
Larcher41 分钟前
新手也能学会,100行代码玩AI LOGO
前端·llm·html
徐子颐1 小时前
从 Vibe Coding 到 Agent Coding:Cursor 2.0 开启下一代 AI 开发范式
前端
小月鸭1 小时前
如何理解HTML语义化
前端·html
jump6801 小时前
url输入到网页展示会发生什么?
前端
诸葛韩信2 小时前
我们需要了解的Web Workers
前端
brzhang2 小时前
我觉得可以试试 TOON —— 一个为 LLM 而生的极致压缩数据格式
前端·后端·架构
yivifu2 小时前
JavaScript Selection API详解
java·前端·javascript
这儿有一堆花2 小时前
告别 Class 组件:拥抱 React Hooks 带来的函数式新范式
前端·javascript·react.js