前言
这里是西伯利亚小火龙
最近闲来无事,发现了一个很有艺术感的网站,作为切图仔的我瞬间艺术细菌爆发,我想着这么好看我一定要探究下,好了不多说上图:
光看上面的图可能感觉一般般,可以直接访问网站感受:abeto.co/
随着网站的加载,就像一副画卷缓缓展开在你的面前,它是生动的,美丽的,不间断绽放的花朵,飘零的落叶,移动的线条,这一切组成了这个作品。它让我沉醉,这一刻我想起了新海诚导演的电影《秒速5センチメートル》(秒速五厘米)中的台词:
“呐,你知道吗?听说 樱花飘落的速度是秒速五厘米哦。" 秒速5厘米,那是樱花飘落的速度,那么怎样的速度才能走完我与你之间的距离
好,言归正传,既然我们要实现这个网站,那么首先我们要知道它是用什么技术完成的,于是我直接F12查看源代码,
没想到直接就看到 Three.js,运气不错,那么我们主要的方向就定下来了,使用THREE.js来Copy这个网站,让我们开始吧。
用到的库
THREE.jsGSAP(缓动动画)CustomEase(GSAP的自定义动画)events(事件管理)simplex-noise(噪声库)DRACOLoader(资源加载器)KTX2Loader(资源加载器)EffectComposer(后期处理管理器)RenderPass(RenderPass 是 EffectComposer 管线中的 第一个也是最基础的 Pass,它的作用是:把 Three.js 的场景(Scene + Camera)渲染到一个离屏缓冲区中,供后续 Pass 使用。)SMAAPass(抗锯齿后处理)ShaderPass(是 Three.js 后处理系统中最灵活也是最核心的 Pass 类型之一,专门用于通过自定义着色器(Shader)对图像做各种处理,如模糊、颜色调整、边缘检测、FXAA 等)GammaCorrectionShader(是 Three.js 提供的一个简单但非常实用的后处理着色器,用于将线性颜色空间(linear space)的渲染结果转换为伽马空间(gamma space)进行最终显示。它常用于在启用了线性渲染流程后确保颜色在屏幕上显示正确)
资源获取
既然我们要复刻网站,那肯定要使用它的资源,我们可以在控制台的网络请求中查看它的资源有哪些,经过我们的查看发现它有两大类资源,一种是drc结尾的,一种是KTX2结尾的。于是我们可以直接下载到本地。
但是问题来了,你会发现drc文件下载完成后会变成HTML格式的文件完全不能使用,那我问你,这是为什么呢,其实这是因为请求类型的原因。
那我问你,那么现在该怎么办呢。经过我不断的探索,我们可以使用 postman 这个工具,请求资源然后保存为drc结尾的格式
请看下图:
好了,关于资源的获取就到这了,下面进入我们的重头戏,coding!
搭建场景
众所周知 render、scene、camera 是不可缺少的,然后我们还需要加载模型,最后观察原网站,我们发现它还有鼠标模型互动的效果、后期处理、和线条特效,由此可以得出我们需要的使用的工具。不过,后期处理和线条特效今天暂时不弄,今天的目标是创建场景、加载模型、让花朵动起来。
首先我们创建一个类 MainCreate 并初始化我们需要的参数和工具。
class MainCreate {
fingers = 1
active = !1
audio = null
initialDPR = 1
currentDPR = 1
adaptiveDPR = null
uniforms = {
resolution: {value: new THREE.Vector2(2, 2), global: !0},
time: {value: 0, global: !0},
dtRatio: {value: 1, global: !0}
}
composer = null
renderPass = null
DPR = window.devicePixelRatio <= 2 ? Math.min(window.devicePixelRatio, 1.25) : Math.min(window.devicePixelRatio, 1.5) || 1
screen = {dpr: window.devicePixelRatio || 1, aspectRatio: 1, width: 0, height: 0, w: 0, h: 0}
start = false
constructor(config) {
this.target = document.getElementById('flower');
this.loadTarget = document.getElementById('loading');
this.screen.width = this.target.offsetWidth;
this.screen.height = this.target.offsetHeight;
this.screen.w = this.screen.width;
this.screen.h = this.screen.height;
this.screen.aspectRatio = this.screen.w / this.screen.h;
this.initUBO();
this.initDPR();
this.initUtils();
}
initUBO() {
this.UBO = new THREE.UniformsGroup()
this.UBO.setName("Global");
this.UBO.add(this.uniforms.resolution);
this.UBO.add(this.uniforms.time);
this.UBO.add(this.uniforms.dtRatio);
}
initDPR() {
this.initialDPR = this.DPR;
this.adaptiveDPR = new AdaptiveDPRController(this)
}
initUtils() {
this.gsap = gsap;
this.gsap.registerPlugin(CustomEase);
this.gsap.config({force3D: !0});
this.gsap.defaults({ease: "power2.inOut", duration: .6, overwrite: "auto"});
CustomEase.create("inOut1", "M0,0 C0.5,0 0.1,1 1,1");
CustomEase.create("inOut2", "M0,0 C0.56,0 0,1 1,1");
CustomEase.create("inOut3", "M0,0 C0.6,0 0,1 1,1");
CustomEase.create("inOut4", "M0,0 C0.4,0 -0.06,1 1,1");
this.eventManage = new EventEmitter();
this.timeStats = new TimeStats()
this.utils = new Utils(this)
this.inputManager = new InputManager(this)
}
initEvents() {
this.eventManage.on("resize", ({w, h}) => {
this.uniforms.resolution.value.set(w, h).multiplyScalar(this.currentDPR).floor();
this.renderer.webgl.setSize(this.uniforms.resolution.value.x, this.uniforms.resolution.value.y);
this.renderer.webgl.domElement.style.width = `${w}px`;
this.renderer.webgl.domElement.style.height = `${h}px`;
});
this.eventManage.on("webgl_prerender", (time) => {
this.uniforms.time.value = time;
this.uniforms.dtRatio.value = this.utils.deltaRatio();
});
this.eventManage.on("webgl_render", (delta) => {
if (!this.active) return;
this.renderer.info?.reset();
this.renderer.webgl.render(this.scene, this.camera)
});
this.eventManage.on("webgl_render_active", (active) => {
if (active && !this.adaptiveDPR?.hasRun) {
this.adaptiveDPR.start();
}
this.active = active;
});
}
initDPRMultiplier(dpr = 1) {
this.currentDPR = this.initialDPR * dpr
this.eventManage.emit("resize", {w: this.screen.w, h: this.screen.h});
}
resize() {
this.screen.width = this.screen.w = this.target.offsetWidth;
this.screen.height = this.screen.h = this.target.offsetHeight;
this.screen.aspectRatio = this.screen.w / this.screen.h;
this.eventManage.emit("resize", {w: this.screen.w, h: this.screen.h});
}
}
创建渲染器
initRenderer() {
this.renderer = new RenderCreate()
this.renderer.info.autoReset = false;
this.renderer.webgl.setSize(this.screen.width, this.screen.height);
this.renderer.webgl.toneMapping = THREE.LinearToneMapping;
this.target.appendChild(this.renderer.webgl.domElement);
}
RenderCreate是一个封装了 WebGLRenderer 并自检是否支持浮点纹理渲染的工具类。
- 初始化 WebGLRenderer
- 配置 clearColor 和 clearAlpha
- 检测扩展
- 暴露 renderer、info、domElement 等属性
- 清理资源的
dispose()方法
class RenderCreate {
/**
* 渲染器
* **/
webgl
domElement
info
clearColor = new THREE.Color("#000000")
clearAlpha = 1
constructor({shadowMap = true, shadowMapType = THREE.PCFSoftShadowMap} = {}) {
this.webgl = new THREE.WebGLRenderer({alpha: !1, antialias: !1, stencil: !1, depth: !1})
this.webgl.setClearColor(this.clearColor, this.clearAlpha)
if (shadowMap) {
this.webgl.shadowMap.enabled = !0
if (shadowMapType) this.webgl.shadowMap.type = shadowMapType
}
this.info = this.webgl.info
this.webgl.debug.checkShaderErrors = !1
this.webgl.capabilities.floatLinearFiltering = this.webgl.extensions.has("OES_texture_float_linear")
this.webgl.capabilities.floatRenderTarget = this.checkFloatRenderTarget()
}
checkFloatRenderTarget() {
const renderTarget = new THREE.WebGLRenderTarget(1, 1, {
minFilter: THREE.NearestFilter,
magFilter: THREE.NearestFilter,
type: THREE.FloatType
})
const scene = new THREE.Scene
const material = new THREE.ShaderMaterial({
vertexShader: " void main() { gl_Position = vec4(position, 1.0); } ",
fragmentShader: " void main() { gl_FragColor.rgb = vec3(0.0, 1.0 / 10.0, 1.0 / 20.0); gl_FragColor.a = 1.0; } "
});
const geometry = new THREE.BufferGeometry();
geometry.setAttribute("position", new THREE.BufferAttribute(new Float32Array([-1, -1, 0, 3, -1, 0, -1, 3, 0]), 3));
geometry.setAttribute("uv", new THREE.BufferAttribute(new Float32Array([0, 0, 2, 0, 0, 2]), 2));
scene.add(new THREE.Mesh(geometry, material));
const getRenderTarget = this.webgl.getRenderTarget()
this.webgl.setRenderTarget(renderTarget)
this.webgl.render(scene, new THREE.OrthographicCamera(-1, 1, 1, -1, 0, 1));
const array = new Float32Array(4);
this.webgl.readRenderTargetPixels(renderTarget, 0, 0, 1, 1, array)
this.webgl.setRenderTarget(getRenderTarget)
renderTarget.dispose()
material.dispose()
return !(array[0] !== 0 || array[1] < .1 || array[2] < .05 || array[3] < 1)
}
dispose() {
this.webgl.dispose()
this.webgl = null
this.domElement = null
this.info = null
}
}
checkFloatRenderTarget 它手动渲染一个浮点纹理,再读取像素值,判断是否真的支持浮点输出。通过 array 判断是否渲染成功(不是 NaN / 0),这在某些移动设备上能避免崩溃或黑屏的问题。
创建相机
initCamera() {
this.camera = new customCamera(this);
this.camera.basePosition.set(0, 0, 4)
this.camera.baseTarget.set(0, 0, 0)
this.camera.displacement.position.set(.03, .015)
this.camera.lerpPosition = .035
}
customCamera继承自 THREE.PerspectiveCamera,封装了交互控制、位移偏移、目标追踪、相机抖动等高级行为。
class customCamera extends THREE.PerspectiveCamera {
constructor(base) {
super(45, base.screen.w / base.screen.h, 0.1, 1e3);
this.base = base;
this.isBaseCamera = true;
this._sizing = 1; // 尺寸来源
this._size = new THREE.Vector2(base.screen.w, base.screen.h); // 当前尺寸
this._firstUpdate = true;
this._prevSize = this._size.clone();
// 状态缓存(防止冗余更新)
this._prevPosition = new THREE.Vector3();
this._prevTarget = new THREE.Vector3();
this._prevUp = new THREE.Vector3();
// 补充旋转与位置偏移控制(球坐标)
this._additionalSphericalPosition = new THREE.Spherical(); // theta/phi for position
this._additionalSphericalTarget = new THREE.Spherical(); // theta/phi for target
this._additionalRotationUp = 0;
// 摄像目标和方向
this.target = new THREE.Vector3();
this.basePosition = new THREE.Vector3(0, 0, 6); // 初始相机位置
this.baseTarget = new THREE.Vector3(); // 初始看向目标
this.baseUp = new THREE.Vector3(0, 1, 0); // 初始 up 向量
// 位移控制参数(用于跟随或拖拽)
this.displacement = {
position: new THREE.Vector2(), // 鼠标拖动造成的位置偏移
target: new THREE.Vector2(), // 鼠标拖动造成的目标偏移
rotation: 0 // 旋转偏移
};
// 平滑系数
this.lerpPosition = 0.035;
this.lerpTarget = 0.035;
this.lerpRotation = 0.035;
// 抖动参数
this.shake = new THREE.Vector3(); // xyz 分量控制 shake 影响程度
this.shakeSpeed = new THREE.Vector3(1, 1, 1); // 每个 shake 分量的噪声速度
// 交互控制参数
this.touchAmount = 1;
this.resetOnTouch = true;
this.Tn = new THREE.Vector3()
this.er = new THREE.Vector3()
this.Gu = new THREE.Vector3()
this.As = new THREE.Vector3()
this.Em = new THREE.Vector2()
this.XI = new THREE.Matrix4()
const noise = createNoise3D(Math.random);
this.Du = {sineNoise1: noise};
}
// 更新相机的实际位置、目标、up,并计算 quaternion
_update() {
if (this._firstUpdate) this._firstUpdate = false;
const isTouchReleased = this.resetOnTouch && this.base.inputManager.get(0).currentInput === "touch" && !this.base.inputManager.get(0).touching;
const interactionFactor = isTouchReleased ? 0.5 : 1;
const xInput = this.base.utils.fit(isTouchReleased ? 0 : this.base.inputManager.get(0).position11.x, -1, 1, -Math.PI * .5, Math.PI * .5) * this.touchAmount;
const yInput = this.base.utils.fit(isTouchReleased ? 0 : this.base.inputManager.get(0).position11.y, 1, -1, -Math.PI * .5, Math.PI * .5) * this.touchAmount;
// 方向向量构造
this.Tn.subVectors(this.basePosition, this.baseTarget);
if (this.Tn.lengthSq() === 0) this.Tn.z = 1;
this.Tn.normalize();
this.er.crossVectors(this.baseUp, this.Tn);
if (this.er.lengthSq() === 0) {
Math.abs(this.baseUp.z) === 1 ? this.Tn.x += 1e-4 : this.Tn.z += 1e-4;
this.Tn.normalize();
this.er.crossVectors(this.baseUp, this.Tn);
}
this.er.normalize();
this.Gu.crossVectors(this.Tn, this.er);
// === 位置更新 ===
if (this.displacement.position.equals(this.Em)) {
this.position.copy(this.basePosition);
this._additionalSphericalPosition.set(1, 0, 0);
} else {
this._additionalSphericalPosition.theta = this.base.utils.lerpFPS(this._additionalSphericalPosition.theta, xInput * this.displacement.position.x, this.lerpPosition * interactionFactor);
this._additionalSphericalPosition.phi = this.base.utils.lerpFPS(this._additionalSphericalPosition.phi, yInput * this.displacement.position.y, this.lerpPosition * interactionFactor);
this.As.subVectors(this.basePosition, this.baseTarget);
this.As.applyAxisAngle(this.er, this._additionalSphericalPosition.phi).applyAxisAngle(this.Gu, this._additionalSphericalPosition.theta);
this.position.copy(this.baseTarget).add(this.As);
}
// === 目标更新 ===
if (this.displacement.target.equals(this.Em) && this.shake.x === 0 && this.shake.y === 0) {
this.target.copy(this.baseTarget);
this._additionalSphericalTarget.set(1, 0, 0);
} else {
this._additionalSphericalTarget.theta = this.base.utils.lerpFPS(this._additionalSphericalTarget.theta, xInput * this.displacement.target.x, this.lerpTarget * interactionFactor);
this._additionalSphericalTarget.phi = this.base.utils.lerpFPS(this._additionalSphericalTarget.phi, yInput * this.displacement.target.y, this.lerpTarget * interactionFactor);
const shakeX = this.shake.x === 0 ? 0 : this.Du.sineNoise1(12.23, 3.44, -3.234 + this.base.timeStats.time * this.shakeSpeed.x) * this.shake.x * this.touchAmount;
const shakeY = this.shake.y === 0 ? 0 : this.Du.sineNoise1(-2.45, 4.789, 7.343 + this.base.timeStats.time * this.shakeSpeed.y) * this.shake.y * this.touchAmount;
this.As.subVectors(this.baseTarget, this.basePosition);
this.As.applyAxisAngle(this.er, this._additionalSphericalTarget.phi + shakeY)
.applyAxisAngle(this.Gu, this._additionalSphericalTarget.theta + shakeX);
this.target.copy(this.basePosition).add(this.As);
}
// === up向量更新(用于相机 roll 旋转)===
if (this.displacement.rotation === 0 && this.shake.z === 0) {
this.up.copy(this.baseUp);
this._additionalRotationUp = 0;
} else {
this._additionalRotationUp = this.base.utils.lerpFPS(this._additionalRotationUp, this.base.inputManager.get(0).velocity.x * this.displacement.rotation * this.touchAmount, this.lerpRotation * interactionFactor);
const shakeZ = this.shake.z === 0 ? 0 : this.Du.sineNoise1(23.434, -1.565, 8.454 + this.base.timeStats.time * this.shakeSpeed.z) * this.shake.z * this.touchAmount;
this.As.subVectors(this.position, this.target).normalize();
this.up.copy(this.baseUp).applyAxisAngle(this.As, this._additionalRotationUp + shakeZ);
}
// === 如果任意状态变化,更新方向四元数 ===
if (
!this.position.equals(this._prevPosition) ||
!this.target.equals(this._prevTarget) ||
!this.up.equals(this._prevUp)
) {
this._prevPosition.copy(this.position);
this._prevTarget.copy(this.target);
this._prevUp.copy(this.up);
this.quaternion.setFromRotationMatrix(this.XI.lookAt(this.position, this.target, this.up));
}
}
// 根据屏幕 / 自定义尺寸更新投影矩阵
_resize() {
if (this._sizing === 1) {
this._size.set(this.base.screen.w, this.base.screen.h);
}
if (!this._prevSize.equals(this._size)) {
this._prevSize.copy(this._size);
if (this.isPerspectiveCamera) {
this.aspect = this._size.x / this._size.y;
} else {
const halfW = this._size.x * 0.5;
const halfH = this._size.y * 0.5;
this.left = -halfW;
this.right = halfW;
this.top = halfH;
this.bottom = -halfH;
}
this.updateProjectionMatrix();
}
}
// 设置自定义尺寸而非屏幕尺寸
setCustomSize(width, height) {
this._sizing = 2;
this._size.set(width, height);
}
}
创建场景
initScene() {
this.scene = new customScene(this);
}
customScene 是对 Three.js Scene 的 封装增强版,添加了渲染钩子、自动相机同步、批量资源销毁等能力。
class customScene extends THREE.Scene {
/**
* 自定义场景类
* **/
constructor(base) {
super();
this.base = base;
this.camera = this.base.camera || new customCamera(this.base);
// 相机不自动更新矩阵,场景更新它
this.camera.matrixWorldAutoUpdate = false;
// 是否启用自动更新
this.matrixAutoUpdate = false;
this.matrixWorldAutoUpdate = true;
// 前置回调(渲染前)
this.beforeRenderCbs = [];
}
/**
* 覆写矩阵更新逻辑,同时更新相机逻辑
*/
updateMatrixWorld(force) {
super.updateMatrixWorld(force);
this.camera._resize?.();
this.camera._update?.();
this.camera.updateMatrixWorld();
this.beforeRenderCbs.forEach(cb => cb());
}
/**
* 销毁:释放纹理、RT、几何体、材质、相机等
*/
dispose() {
this.camera.dispose?.();
this.beforeRenderCbs = [];
this.traverse(obj => {
obj.geometry?.dispose?.();
if (obj.material?.isMaterial) {
if (obj.material.uniforms) {
Object.values(obj.material.uniforms).forEach(uniform => {
uniform.value?.isTexture && uniform.value.dispose?.();
});
} else {
Object.values(obj.material).forEach(val => {
val?.isTexture && val.dispose?.();
});
}
obj.material.dispose?.();
}
if (obj !== this) obj.dispose?.();
});
this.clear();
}
}
资源加载
加载我们获取的资源,在加载完成后初始化事件管理、Flower和动画循环。
async initLoader() {
this.manager = new THREE.LoadingManager();
this.fileLoader = (new THREE.FileLoader(this.manager)).setResponseType('arraybuffer')
this.dracoLoader = (new DRACOLoader(this.manager)).setDecoderPath('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/draco/gltf/').setWorkerLimit(1).preload();
this.ktx2Loader = (new KTX2Loader(this.manager)).setTranscoderPath('https://cdn.jsdelivr.net/npm/three@0.160.0/examples/jsm/libs/basis/').detectSupport(this.renderer.webgl);
this.emailTexture = await this.ktx2Loader.loadAsync('https://embrace-oyo.github.io/studyThreejs/ktx2/email-C08JwW2m.ktx2');
this.headLineTexture = await this.ktx2Loader.loadAsync('https://embrace-oyo.github.io/studyThreejs/ktx2/headline-sVnFdBpn.ktx2');
this.leafTexture = await this.ktx2Loader.loadAsync('https://embrace-oyo.github.io/studyThreejs/ktx2/leaf-BuS2QNRk.ktx2');
this.noiseSimplexLayeredTexture = await this.ktx2Loader.loadAsync('https://embrace-oyo.github.io/studyThreejs/ktx2/noise-simplex-layered-Bhb_a_In.ktx2');
this.petalTexture = await this.ktx2Loader.loadAsync('https://embrace-oyo.github.io/studyThreejs/ktx2/petal-Bq8WLeBB.ktx2');
this.transitionNomipmapsTexture = await (new THREE.TextureLoader(this.manager)).load('https://embrace-oyo.github.io/studyThreejs/jpg/transition-nomipmaps-oWShjGwJ.jpg')
this.noiseSimplexLayeredTexture.colorSpace = THREE.SRGBColorSpace; // 设置 sRGB 颜色空间
this.noiseSimplexLayeredTexture.wrapS = this.noiseSimplexLayeredTexture.wrapT = THREE.RepeatWrapping; // 启用 Repeat
this.petalTexture.colorSpace = THREE.SRGBColorSpace; // 设置 sRGB 颜色空间
this.petalTexture.wrapS = this.petalTexture.wrapT = THREE.ClampToEdgeWrapping; // 边界处理
this.leafTexture.colorSpace = THREE.SRGBColorSpace; // 设置 sRGB 颜色空间
this.leafTexture.wrapS = this.leafTexture.wrapT = THREE.ClampToEdgeWrapping; // 边界处理
let total = 0;
const geometryArray = [
{path: 'https://embrace-oyo.github.io/studyThreejs/drc/needle.drc', key: 'needle'},
{path: 'https://embrace-oyo.github.io/studyThreejs/drc/border.drc', key: 'border'},
{path: 'https://embrace-oyo.github.io/studyThreejs/drc/leaf.drc', key: 'leaf'},
{path: 'https://embrace-oyo.github.io/studyThreejs/drc/petal.drc', key: 'petal'},
]
for (let i = 0; i < geometryArray.length; i++) {
this[geometryArray[i].key] = await this.dracoLoader.loadAsync(geometryArray[i].path)
this[geometryArray[i].key].name = geometryArray[i].key
total += 1;
if (total === geometryArray.length) {
// 加载完成 .... 做些什么
this.initEvents()
this.initWorld();
this.resize();
this.initAnimation();
this.loadTarget.setAttribute('class', 'loadOk')
}
}
}
初始化Flower并激活动画
initWorld() {
this.flower = new FlowerCreate(this);
this.eventManage.emit("webgl_render_active", true);
}
FlowerCreate 类 —— 动态生成并动画控制花朵的系统
构造函数结构
constructor(base, t = {}) {
this.base = base;
this.scene = this.base.scene;
this.baseTime = 0
this.additionalTime = 0
this.additionalTimeTarget = 0
this.additionalHold = 0
this.additionalHoldTarget = 0
this.touching = !1
this.options = {petalCount: 38, ...t}
this.init()
}
-
base:核心依赖对象,包含scene、petal几何体、纹理、统一缓冲UBO、eventManage和工具类。 -
t:可选配置,默认提供petalCount: 38。
初始化逻辑 init()
几何配置
- 使用
base.petal作为基础花瓣几何体。 - 添加每个实例的
random属性,用于动画差异化。
init(){
this.geometry = this.base.petal;
this.count = this.options.petalCount
const float32Array = new Float32Array(this.count);
for (let l = 0; l < this.count; l++) {
float32Array[l] = Math.random();
}
const s = new THREE.InstancedBufferAttribute(float32Array, 1, !1, 1)
this.geometry.setAttribute("random", s);
}
材质 ShaderMaterial
- 使用自定义顶点 / 片元着色器进行每个花瓣的控制。
顶点着色器核心逻辑:
- 每个花瓣的时间偏移:基于
uFlowerTime+gl_InstanceID。 - 弯曲与展开:通过
uv.y和progress01控制弯曲、偏移、旋转。 - 环状排列:通过
index * 4.854计算花瓣位置角度。 - 抖动感:花瓣横向摆动模拟风吹。
片元着色器核心逻辑:
- 线条纹理 + 噪声扰动:叠加 Simplex 噪声 + Petal 纹理。
- 边缘柔和描边:根据
lines = texture2D(tPetal, uv).r+aastep平滑边缘。 - 法线编码输出:写入
gInfoMRT buffer,用于后续效果(比如 Outline)。
场景构建
- 创建一个
THREE.Group添加InstancedMesh,调整旋转并提前设置好 renderOrder。 - 将
mesh添加到主场景。
动画更新逻辑(绑定在 onBeforeRender)
this.instancedMesh.onBeforeRender = () => {
this.additionalTime = lerp(this.additionalTime, this.additionalTimeTarget, 0.035);
this.baseTime += this.base.timeStats.delta * 0.001;
this.additionalHoldTarget += this.touching ? this.base.timeStats.delta * 0.0025 : 0;
this.additionalHold = lerp(this.additionalHold, this.additionalHoldTarget, 0.035);
this.material.uniforms.uFlowerTime.value = this.baseTime + this.additionalTime + this.additionalHold;
// 响应相机的旋转调整花朵整体旋转
this.mesh.rotation.y = -this.scene.camera._additionalSphericalPosition.theta * 7;
this.mesh.rotation.x = this.scene.camera._additionalSphericalPosition.phi * 7;
}
-
基于时间叠加计算
uFlowerTime,控制花瓣动画展开。 -
花朵整体朝向也响应相机角度,使之拥有“动态朝向”的感觉。
用户交互事件
this.base.eventManage.on("wheel", this.onWheel.bind(this));
this.base.eventManage.on("touch_drag", this.onTouchDrag.bind(this));
this.base.eventManage.on("touch_start", this.onTouchStart.bind(this));
this.base.eventManage.on("touch_end", this.onTouchEnd.bind(this));
鼠标滚轮事件
- 滚动花朵:改变
additionalTimeTarget,加速时间推进。
拖动事件
- 拖动时会减少时间值,让花朵反向“缩回”或“转动”。
- 拖动太多时将
touching设置为 false。
触摸事件
- 按下表示用户正在“触摸”,
additionalHold将持续增加,模拟“长按开放”感。
动画循环
初始化动画主循环逻辑,并计算并平滑输出 FPS(帧率)信息,同时在每帧触发多个 WebGL 渲染相关事件。
| 功能 | 描述 |
|---|---|
| 🎞 帧控制 | 用 GSAP ticker 驱动每帧更新 |
| 📈 平滑 FPS 计算 | 根据最近几帧的间隔动态计算当前 FPS 并广播 |
| 🛰️ 渲染事件分发 | 按阶段触发 prerender / render / postrender 回调 |
| 🧩 插件扩展 | 支持模块化监听不同阶段的渲染钩子 |
| 📤 数据共享 | 帧信息统一存储在 timeStats 结构中 |
initAnimation() {
let ho = []
let ym = 0
let RI = 0.5
let LI = 5
this.gsap.ticker.add((i, e, t) => {
const n = Math.round((i - this.timeStats.internalTime) * 1e3);
ho.push(n)
if (i - ym >= RI && ho.length >= LI) {
const s = Math.round(1e3 / (ho.reduce((r, a) => r + a, 0) / ho.length));
this.timeStats.recordedMaxFPS = Math.max(this.timeStats.recordedMaxFPS, s)
this.timeStats.smoothedFPS = s
ho.length = 0
ym = i
this.eventManage.emit("webgl_average_fps_update", this.timeStats.smoothedFPS)
}
this.timeStats.internalTime = i
this.timeStats.frameDelta = n
this.timeStats.frameCount++
["webgl_prerender", "webgl_render", "webgl_postrender"].forEach(event => {
this.eventManage.emit(event, i, n)
})
})
}
阶段展示 - 1
好了到这一步我们就可以看见花朵绽放了。但是和原网站依旧相差甚远,别担心后面让我们继续一步步还原。
添加叶子
fluidSim 的属性后面会用到所以这里先初始化一下
initScene() {
this.scene = new customScene(this);
this.fluidSim = {
velUniform: {value: null}
}
}
initWorld 函数里添加
this.leaves = new LeavesCreate(this);
LeavesCreate 实现了一个高级 GPU 粒子系统的核心类,用于在 Three.js 中实现“叶子飘落效果”的 GPU 动画。它通过两个 framebuffer 交替计算粒子状态,用 InstancedMesh 渲染大量实例,并结合 Shader 实现动画模拟与展示。
- 粒子数据纹理初始化(
init())
this.dataTextureR = new THREE.DataTexture(...); // 初始位置 + 随机数
this.dataTextureA = new THREE.DataTexture(...); // 另一个数据通道
-
每个像素表示一个粒子。
-
使用 RGBA float 格式存储位置、速度、随机数等。
- 几何构建(
createGeometry())
this.geometry = new THREE.InstancedBufferGeometry()
- 设置
rand: 每个实例的随机值(影响偏移、旋转等) - 设置
texuv: 每个实例在数据纹理中的坐标
- 材质与计算 Shader
material: 用于渲染的 Shader,读取tTexture1/tTexture2来驱动动画。computationMaterial: 用于模拟粒子行为的全屏片元着色器。
- GPU 粒子计算核心逻辑(
CreateMesh.compute())
// 每帧执行 compute
this.rt1, this.rt2: 多重 RenderTarget,交替作为输入/输出
this.fsQuad.render(renderer): 执行 fragment shader 计算下一帧
关键流程:
上一帧结果作为输入纹理 ➜ computationMaterial ➜ 写入 rt1 / rt2 ➜ 作为下次输入
- 帧间数据管理
// 更新 render shader 使用的 tTexture1, tTexture1Prev
A.value = l.texture[f]
g.value = c.texture[f]
compute() 流程图
[rt1] ← 上帧数据
↓
computationMaterial
↓
[rt2] ← 计算后新数据
↓
更新渲染 material 中的 tTexture1/tTexture1Prev
| 关键词 | 含义/作用 |
|---|---|
| InstancedMesh | 批量渲染所有叶子 |
| DataTexture | 粒子状态存储纹理(位置/速度等) |
| WebGLMultipleRenderTargets | 一次性写入多个纹理输出 |
| FullScreenQuad | 用于 GPU 运算的全屏幕渲染片元 |
| ShaderMaterial | 渲染和计算都依赖自定义 Shader |
| compute() | 核心粒子更新逻辑,每帧执行 |
阶段展示 - 2
添加针叶和前景落叶
这一步的生成逻辑基本和添加落叶一致这里就不过多赘述了。
initWorld(){
...
this.needles = new NeedlesCreate(this);
this.foregroundLeavesCreate = new ForegroundLeavesCreate(this);
}
阶段展示 - 3
到了这我们算是完成50%了,但是目前和原网站差距还是不小,那么下面我们开始后期处理的编写。
添加后处理
创建后处理管线
initComposer() {
this.composer = new EffectComposer(this.renderer.webgl);
this.renderPass = new RenderPass(this.scene, this.camera);
this.SMAAPass = new SMAAPass(this.screen.width, this.screen.height);
this.multipleRenderPass = new MultipleRenderPass(this);
this.finalPass = (new FinalPass(this)).pass
// 获取多个渲染目标的贴图
const n = this.multipleRenderPass.multipleRenderTarget.texture;
// 命名多个输出贴图,便于调试
n[0].name = "color";
n[1].name = "info";
// 将第二个贴图 (info) 传入 shader 的 tInfo
this.finalPass.uniforms.tInfo.value = n[1];
this.finalPass.uniforms.uCameraNear.value = this.camera.near
this.finalPass.uniforms.uCameraFar.value = this.camera.far;
this.gammaCorrectionPass = new ShaderPass(GammaCorrectionShader);
// this.outPass = new OutputPass();
this.composer.addPass(this.renderPass)
this.composer.addPass(this.SMAAPass)
this.composer.addPass(this.multipleRenderPass)
this.composer.addPass(this.finalPass)
this.composer.addPass(this.gammaCorrectionPass)
}
然后放入中
...
if (total === geometryArray.length) {
this.initComposer();
...
}
...
与此同时在 initEvents 中我们需要设置 composer 的尺寸并开启渲染
initEvents() {
this.eventManage.on("resize", ({w, h}) => {
...
this.composer.setSize(this.uniforms.resolution.value.x, this.uniforms.resolution.value.y);
});
...
this.eventManage.on("webgl_render", (delta) => {
...
this.composer?.render(delta);
});
}
准备工作做完了 下面我们来看看这个后处理是怎么工作的:
1. 初始化 EffectComposer 和各个 Pass
- EffectComposer:创建效果合成器,使用 WebGL 渲染器
- RenderPass:基础渲染通道,将场景渲染到缓冲区
- SMAAPass:抗锯齿通道(Subpixel Morphological Antialiasing)
- MultipleRenderPass:自定义的多渲染目标通道(分析过其实现)
- FinalPass:最终处理通道,可能是自定义的着色器通道
- gammaCorrectionPassThree.js 后期处理管线中用于进行伽马校正的通道
2. 多渲染目标处理
-
从
MultipleRenderPass获取多渲染目标的纹理数组 -
为纹理命名以便调试:
- 第一个纹理(索引0)命名为 "color"(颜色信息)
- 第二个纹理(索引1)命名为 "info"(可能是法线、深度等附加信息)
-
将 "info" 纹理传递给 finalPass 的
tInfouniform -
传递相机的近、远平面距离给 finalPass
3. 构建渲染管线
渲染管线的执行顺序:
- RenderPass:基础场景渲染
- SMAAPass:抗锯齿处理
- MultipleRenderPass:多目标渲染(输出到多个纹理)
- FinalPass:使用多渲染结果进行最终合成/处理
- GammaCorrectionPass:伽马校正
深入分析 MultipleRenderPass 类
这是一个用于 Three.js 的扩展渲染通道,实现了多渲染目标(MRT)功能。
什么是多渲染目标:
多渲染目标(Multiple Render Targets, MRT)指的是在图形渲染过程中,同时将渲染结果写入多个不同的目标缓冲区(通常是纹理) 。 这种技术允许一个渲染操作产生多个输出,而不仅仅是传统的颜色缓冲区。
创建MRT
// 创建MRT (2个目标,半浮点格式)
this.multipleRenderTarget = new THREE.WebGLMultipleRenderTargets(
this.base.uniforms.resolution.x,
this.base.uniforms.resolution.y,
2,
{type: THREE.HalfFloatType}
);
render方法
render(renderer, writeBuffer, readBuffer) {
...
// 核心渲染操作
renderer.setRenderTarget(this.multipleRenderTarget); // 切换到MRT
if (this.clear) renderer.clear(renderer.autoClearColor, renderer.autoClearDepth, renderer.autoClearStencil);
renderer.render(this.scene, this.camera); // 执行渲染
...
}
深入分析 FinalPass
FinalPass 它是一个最终合成 Pass,用于绘制主画面时,添加轮廓线、高光、颜色过渡、LOGO叠加等效果。
class FinalPass {
constructor(base) {
// 保存基础对象引用,包含场景、渲染器、纹理等资源
this.base = base;
// 创建ShaderPass,使用自定义的ShaderMaterial
this.pass = new ShaderPass(
new THREE.ShaderMaterial({
// 使用基础对象中的统一缓冲区对象(UBO)
uniformsGroups: [this.base.UBO],
// 定义所有着色器uniform变量
uniforms: {
// 纹理相关uniforms
tLogo: {value: this.base.headLineTexture}, // Logo纹理
tNoise: {value: this.base.noiseSimplexLayeredTexture}, // 噪声纹理
tDiffuse: {value: null}, // 主颜色纹理(后期传入)
tInfo: {value: null}, // 信息纹理(如法线/深度,后期传入)
tSim: this.base.fluidSim.dyeUniform, // 流体模拟纹理
tTransition: {value: this.base.transitionNomipmapsTexture}, // 过渡效果纹理
// 颜色相关uniforms
uBgColor: {value: new THREE.Color("#ffec95")}, // 背景颜色
uOutlineColor: {value: new THREE.Color("#9f4a16")}, // 轮廓线颜色
// 动画进度控制
uProgress1: {value: 0}, // 进度参数1
uProgress2: {value: 0}, // 进度参数2
uProgress3: {value: 0}, // 进度参数3
uProgress4: {value: 0}, // 进度参数4
// 相机参数
uCameraNear: {value: this.base.camera.near, ignore: !0}, // 近平面(不可修改)
uCameraFar: {value: this.base.camera.far, ignore: !0}, // 远平面(不可修改)
// 轮廓效果参数
uOutlineFade: {value: new THREE.Vector2(10, 80)}, // 轮廓淡出范围
uOutlineThickness: {value: 1}, // 轮廓厚度
uOutlineScale: {value: 1}, // 轮廓缩放
// 信息处理参数(可能用于法线/深度等处理)
uInfoRange: {value: new THREE.Vector3(1e-4, 2e-4, .1)}, // 信息范围
uInfoMinScale: {value: .6}, // 信息最小缩放
// 深度处理参数
uDepthRange: {value: new THREE.Vector3(1e-4, .001, .5)}, // 深度范围
// 法线处理参数
uNormalRange: {value: new THREE.Vector3(.4, .5, .3)}, // 法线范围
// 边缘平滑参数
uSmoothMargin: {value: .2} // 平滑边距
},
// 着色器代码
vertexShader: vert, // 顶点着色器代码(外部定义)
fragmentShader: frag // 片段着色器代码(外部定义)
})
);
}
}
好写到这里你会发现屏幕是白色的并且没有任何内容,那是因为我们还需要控制 FinalPass 的 四个 uProgress
initWorld() {
...
const t = this.finalPass.material;
this.gsap.fromTo(t.uniforms.uProgress1, {value: 0}, {
value: 1,
duration: 5,
ease: "power2.out",
delay: .25
})
this.gsap.fromTo(t.uniforms.uProgress2, {value: 0}, {
value: 1,
duration: 5,
ease: "power2.out",
delay: .25 + .2
})
this.gsap.fromTo(t.uniforms.uProgress3, {value: 0}, {
value: 1,
duration: 5,
ease: "power2.out",
delay: .25 + .6
})
this.gsap.fromTo(t.uniforms.uProgress4, {value: 0}, {
value: 1,
duration: 5,
ease: "power2.out",
delay: .25 + .9
})
}
这段代码是使用 GSAP 对 FinalPass 着色器中的四个渐变控制 uniform(uProgress1 ~ uProgress4)执行一个渐进式过渡动画,用于 LOGO、文字、背景或者特效的逐步淡入或展开
阶段展示
总结
到这里我们完成了大部分网站的模拟,但是还缺少边框、鼠标轨迹这些功能。但是由于时间的关系,剩下的就留着下一篇继续。
这是我第一次写文章,有些东西我想表达但是限于笔力、限于水平没法写出来,这是我的不足。
希望你能从这篇文章中得到些什么,这就是我的的快乐~
感谢你的阅读,我是西伯利亚小火龙,也是乌鲁木齐大海龟,我们再见!