手把手带你使用Three.js Shader一步步实现炫酷Flower特效(上)

1,243 阅读16分钟

前言

这里是西伯利亚小火龙

最近闲来无事,发现了一个很有艺术感的网站,作为切图仔的我瞬间艺术细菌爆发,我想着这么好看我一定要探究下,好了不多说上图:

WeChat77a0f88cc0c69663e7f8ef466de3073c.jpg

光看上面的图可能感觉一般般,可以直接访问网站感受:abeto.co/

随着网站的加载,就像一副画卷缓缓展开在你的面前,它是生动的,美丽的,不间断绽放的花朵,飘零的落叶,移动的线条,这一切组成了这个作品。它让我沉醉,这一刻我想起了新海诚导演的电影《秒速5センチメートル》(秒速五厘米)中的台词:

“呐,你知道吗?听说 樱花飘落的速度是秒速五厘米哦。" 秒速5厘米,那是樱花飘落的速度,那么怎样的速度才能走完我与你之间的距离

好,言归正传,既然我们要实现这个网站,那么首先我们要知道它是用什么技术完成的,于是我直接F12查看源代码,

image.png

没想到直接就看到 Three.js,运气不错,那么我们主要的方向就定下来了,使用THREE.js来Copy这个网站,让我们开始吧。

用到的库

  • THREE.js
  • GSAP(缓动动画)
  • 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结尾的。于是我们可以直接下载到本地。

image.png

但是问题来了,你会发现drc文件下载完成后会变成HTML格式的文件完全不能使用,那我问你,这是为什么呢,其实这是因为请求类型的原因。

image.png

那我问你,那么现在该怎么办呢。经过我不断的探索,我们可以使用 postman 这个工具,请求资源然后保存为drc结尾的格式

请看下图:

image.png

好了,关于资源的获取就到这了,下面进入我们的重头戏,coding!

搭建场景

众所周知 renderscenecamera 是不可缺少的,然后我们还需要加载模型,最后观察原网站,我们发现它还有鼠标模型互动的效果、后期处理、和线条特效,由此可以得出我们需要的使用的工具。不过,后期处理和线条特效今天暂时不弄,今天的目标是创建场景、加载模型、让花朵动起来。

首先我们创建一个类 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:核心依赖对象,包含 scenepetal 几何体、纹理、统一缓冲 UBOeventManage 和工具类。

  • 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.yprogress01 控制弯曲、偏移、旋转。
  • 环状排列:通过 index * 4.854 计算花瓣位置角度。
  • 抖动感:花瓣横向摆动模拟风吹。

片元着色器核心逻辑:

  • 线条纹理 + 噪声扰动:叠加 Simplex 噪声 + Petal 纹理。
  • 边缘柔和描边:根据 lines = texture2D(tPetal, uv).r + aastep 平滑边缘。
  • 法线编码输出:写入 gInfo MRT 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 实现动画模拟与展示。

  1. 粒子数据纹理初始化(init()
this.dataTextureR = new THREE.DataTexture(...); // 初始位置 + 随机数
this.dataTextureA = new THREE.DataTexture(...); // 另一个数据通道
  • 每个像素表示一个粒子。

  • 使用 RGBA float 格式存储位置、速度、随机数等。

  1. 几何构建(createGeometry()
this.geometry = new THREE.InstancedBufferGeometry()
  • 设置 rand: 每个实例的随机值(影响偏移、旋转等)
  • 设置 texuv: 每个实例在数据纹理中的坐标
  1. 材质与计算 Shader
  • material: 用于渲染的 Shader,读取 tTexture1 / tTexture2 来驱动动画。
  • computationMaterial: 用于模拟粒子行为的全屏片元着色器。
  1. GPU 粒子计算核心逻辑(CreateMesh.compute()
// 每帧执行 compute
this.rt1, this.rt2: 多重 RenderTarget,交替作为输入/输出
this.fsQuad.render(renderer): 执行 fragment shader 计算下一帧

关键流程:

上一帧结果作为输入纹理 ➜ computationMaterial ➜ 写入 rt1 / rt2 ➜ 作为下次输入
  1. 帧间数据管理
// 更新 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 的 tInfo uniform

  • 传递相机的近、远平面距离给 finalPass

3. 构建渲染管线

渲染管线的执行顺序:

  1. RenderPass:基础场景渲染
  2. SMAAPass:抗锯齿处理
  3. MultipleRenderPass:多目标渲染(输出到多个纹理)
  4. FinalPass:使用多渲染结果进行最终合成/处理
  5. 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
    })
}

这段代码是使用 GSAPFinalPass 着色器中的四个渐变控制 uniformuProgress1 ~ uProgress4)执行一个渐进式过渡动画,用于 LOGO、文字、背景或者特效的逐步淡入或展开

阶段展示

总结

到这里我们完成了大部分网站的模拟,但是还缺少边框、鼠标轨迹这些功能。但是由于时间的关系,剩下的就留着下一篇继续。

这是我第一次写文章,有些东西我想表达但是限于笔力、限于水平没法写出来,这是我的不足。

希望你能从这篇文章中得到些什么,这就是我的的快乐~

感谢你的阅读,我是西伯利亚小火龙,也是乌鲁木齐大海龟,我们再见!