Skip to content

运动模糊 - MotionBlurPost ​

真实相机在快门打开期间,相机或被拍摄的物体发生移动,画面就会沿运动方向拖出拖影。MotionBlurPost 计算每个像素从上一帧到当前帧在屏幕上移动的路径,并沿该路径多次采样取平均,产生真实的运动模糊。

像素的移动路径有两种来源:

模式开启方式效果
相机运动模糊默认由深度和上一帧的视图投影矩阵重建,只模糊相机平移、环绕、推拉造成的位移;相机静止时,自身在运动的物体保持清晰
对象运动模糊setting.render.objectMotionVectors = true每个物体额外绘制自身的屏幕空间速度,相机静止时运动的物体也会产生拖影
ts
// 初始化引擎,可在此配置默认参数
let engine = await Engine3D.init({
    setting: {
        render: {
            postProcessing: {
                motionBlur: {
                    intensity: 0.5,
                    samples: 12,
                    maxBlurPixels: 64
                }
            }
        }
    }
});

// 添加后处理组件
let postProcessing = scene.addComponent(PostProcessingComponent);

// 添加 MotionBlurPost
let motionBlur = postProcessing.addPost(MotionBlurPost);

// 也可以通过 motionBlur 对象设置,与上面的引擎配置等价
motionBlur.intensity = 0.5; // 180° 快门
motionBlur.samples = 16;

engine.setting.render.postProcessing.motionBlur 配置参数:

参数类型默认值描述
enablebooleanfalse是否启用,添加 / 移除 MotionBlurPost 时自动设置
intensitynumber0.5快门系数。1 沿像素一整帧的移动距离模糊(60 Hz 下的 360° 快门),0.5 为电影常用的 180° 快门,0 关闭
samplesnumber12沿运动方向的采样次数,2 ~ 32,越多越平滑,开销也越大
maxBlurPixelsnumber64模糊长度的上限(像素),避免镜头切换或瞬移时整个画面被拖花
frameRateIndependentbooleantrue按 60 Hz 的帧时长归一化模糊长度,使不同帧率下的效果一致;关闭后直接按每帧的实际位移模糊

镜头切换 ​

相机瞬间切换到另一个位置时,上一帧与当前帧的位移很大,会产生一帧不自然的拖影(虽然已被 maxBlurPixels 限制)。切换后调用 resetHistory(),下一帧将不做模糊:

ts
camera.object3D.localPosition = newPosition;
motionBlur.resetHistory();

开启 对象运动模糊 时,resetHistory() 会同时重置 MotionVectorPass,重置后的那一帧也不绘制逐物体速度,跟随镜头一起瞬移的物体同样不会产生拖影。

示例:相机运动模糊 ​

下面的示例中相机自动环绕场景旋转,转速越快拖影越长。拖动鼠标转动视角同样会产生模糊;把 orbitSpeed 调为 0 后画面恢复清晰。

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
import { AtmosphericComponent, BoxGeometry, CameraUtil, Color, ComponentBase, DirectLight, Engine3D, HoverCameraController, KelvinUtil, LitMaterial, MeshRenderer, MotionBlurPost, Object3D, PostProcessingComponent, Scene3D, Time, View3D } from '@orillusion/core';
import * as dat from 'dat.gui';

/**
 * MotionBlurPost: the camera orbits a field of pillars on its own; the faster it turns,
 * the longer the streaks. Drag to look around (the blur follows your camera motion too),
 * or set orbitSpeed to 0 and the image snaps back to sharp.
 */
class Sample_MotionBlur {
    private scene: Scene3D;
    private post: PostProcessingComponent;
    private blur: MotionBlurPost;

    async run() {
        const engine = await Engine3D.init({ setting: { shadow: { enable: true, shadowBound: 140 } } });
        this.scene = new Scene3D();
        this.scene.addComponent(AtmosphericComponent).sunY = 0.65;

        const camera = CameraUtil.createCamera3DObject(this.scene);
        camera.perspective(60, engine.aspect, 0.1, 2000);
        const ctrl = camera.object3D.addComponent(HoverCameraController);
        ctrl.setCamera(30, -14, 75);
        const orbit = camera.object3D.addComponent(AutoOrbit);
        orbit.ctrl = ctrl;

        const lightObj = new Object3D();
        lightObj.rotationX = 50;
        lightObj.rotationY = 130;
        const light = lightObj.addComponent(DirectLight);
        light.lightColor = KelvinUtil.color_temperature_to_rgb(5600);
        light.intensity = 3;
        light.castShadow = true;
        light.enableCSM = true;
        this.scene.addChild(lightObj);

        this.build();

        const view = new View3D();
        view.scene = this.scene;
        view.camera = camera;
        engine.startRenderView(view);

        this.post = this.scene.addComponent(PostProcessingComponent);
        this.blur = this.post.addPost(MotionBlurPost);
        this.blur.intensity = 1;
        this.initGUI(orbit);
    }

    private build() {
        const palette = [new Color(0.95, 0.95, 0.95), new Color(0.05, 0.05, 0.05), new Color(0.9, 0.25, 0.15), new Color(0.15, 0.4, 0.9), new Color(0.95, 0.8, 0.2)];
        const mats = palette.map((c) => {
            const m = new LitMaterial();
            m.baseColor = c;
            m.roughness = 0.7;
            m.metallic = 0;
            return m;
        });
        const pillar = new BoxGeometry(3, 1, 3);
        for (let x = -8; x <= 8; x++) {
            for (let z = -8; z <= 8; z++) {
                if (Math.abs(x) < 2 && Math.abs(z) < 2) continue;
                const h = 5 + ((x * 7 + z * 13) & 7) * 2.5;
                const obj = new Object3D();
                const mr = obj.addComponent(MeshRenderer);
                mr.geometry = pillar;
                mr.material = mats[Math.abs(x * 3 + z * 5) % mats.length];
                mr.castShadow = true;
                obj.x = x * 8;
                obj.z = z * 8;
                obj.y = h / 2;
                obj.scaleY = h;
                this.scene.addChild(obj);
            }
        }
        const floor = new Object3D();
        const fm = floor.addComponent(MeshRenderer);
        fm.geometry = new BoxGeometry(400, 1, 400);
        const mat = new LitMaterial();
        mat.baseColor = new Color(0.5, 0.5, 0.5);
        mat.roughness = 0.9;
        mat.metallic = 0;
        fm.material = mat;
        fm.castShadow = false;
        floor.y = -0.5;
        this.scene.addChild(floor);
    }

    private initGUI(orbit: AutoOrbit) {
        const gui = new dat.GUI();
        const f = gui.addFolder('MotionBlurPost');
        // Attaching / detaching the post turns the effect on / off
        f.add({ enable: true }, 'enable').onChange((v: boolean) => {
            if (v) this.blur = this.post.addPost(MotionBlurPost);
            else this.post.removePost(MotionBlurPost);
        });
        f.add(orbit, 'speed', 0, 240, 1).name('orbitSpeed');
        const b = this.blur;
        f.add(b, 'intensity', 0, 2, 0.01);
        f.add(b, 'samples', 2, 32, 1);
        f.add(b, 'maxBlurPixels', 0, 200, 1);
        f.add(b, 'frameRateIndependent');
        f.open();
        // Start collapsed so the panel does not cover the scene
        gui.close();
    }
}

/** Turns the hover camera around its target at `speed` degrees per second. */
class AutoOrbit extends ComponentBase {
    public ctrl: HoverCameraController;
    public speed = 60;

    public onUpdate(): void {
        if (this.ctrl) this.ctrl.roll += this.speed * Time.delta * 0.001;
    }
}

new Sample_MotionBlur().run();

对象运动模糊 ​

默认模式下,模糊只根据相机的运动计算,场景中自己在运动的物体(旋转的螺旋桨、行驶的车辆等)在相机静止时不会产生拖影。在初始化引擎时开启 objectMotionVectors,即可让运动的物体按各自的速度产生模糊:

ts
let engine = await Engine3D.init({
    setting: {
        render: {
            // 必须在初始化时设置
            objectMotionVectors: true
        }
    }
});

// 用法与相机运动模糊相同
let motionBlur = scene.addComponent(PostProcessingComponent).addPost(MotionBlurPost);
motionBlur.intensity = 1;

开启后,引擎会保留每个物体上一帧的模型矩阵,并为每个不透明材质额外生成一个速度子通道,由 MotionVectorPass 把每个像素的屏幕空间速度(相机运动 + 物体自身运动)写入速度纹理,MotionBlurPost 再沿该速度模糊。可以通过 motionBlur.usesVelocityTexture 确认当前是否在使用逐物体速度。

注意

  • objectMotionVectors 需要在 Engine3D.init 时设置:速度子通道在材质设置时生成,运行时再修改不会生效;
  • 只有写入深度的不透明材质会生成速度,透明物体不会被模糊;
  • 蒙皮动画与 Morph 形变动画的网格只计算整体的刚体运动(上一帧的模型矩阵 + 当前姿态),骨骼与形变本身的运动不产生模糊;
  • 每个不透明物体每帧多绘制一次,场景物体较多时需要关注性能开销。

示例:对象运动模糊 ​

下面的示例中相机保持静止,螺旋桨旋转、单摆摆动、一排小球来回移动、立方体翻滚,它们各自按速度产生拖影,静止的地面和立柱保持清晰。

WebGPU is not supported in your browser
Please upgrade to latest Chrome/Edge

<
ts
import { AtmosphericComponent, BoxGeometry, CameraUtil, Color, ComponentBase, CylinderGeometry, DirectLight, Engine3D, GeometryBase, HoverCameraController, KelvinUtil, LitMaterial, MeshRenderer, MotionBlurPost, Object3D, PostProcessingComponent, Scene3D, SphereGeometry, Time, View3D } from '@orillusion/core';
import * as dat from 'dat.gui';

/**
 * Per-object motion blur (setting.render.objectMotionVectors).
 * The camera stands still while the scene moves: a propeller spins, a pendulum swings, a row of spheres
 * shuttles back and forth and a cube tumbles. Each streaks according to its own speed, while the static
 * ground and pillars stay sharp.
 */
class Sample_ObjectMotionBlur {
    private scene: Scene3D;
    private blur: MotionBlurPost;

    async run() {
        const engine = await Engine3D.init({
            setting: {
                shadow: { enable: true, shadowBound: 80 },
                // Must be set at init: every opaque material gets its velocity pass when it is set up
                render: { objectMotionVectors: true }
            }
        });
        this.scene = new Scene3D();
        this.scene.addComponent(AtmosphericComponent).sunY = 0.65;

        const camera = CameraUtil.createCamera3DObject(this.scene);
        camera.perspective(50, engine.aspect, 0.1, 500);
        camera.object3D.addComponent(HoverCameraController).setCamera(20, -12, 40);

        const lightObj = new Object3D();
        lightObj.rotationX = 45;
        lightObj.rotationY = 140;
        const light = lightObj.addComponent(DirectLight);
        light.lightColor = KelvinUtil.color_temperature_to_rgb(5600);
        light.intensity = 3;
        light.castShadow = true;
        light.enableCSM = true;
        this.scene.addChild(lightObj);

        const anim = this.scene.addComponent(SceneAnimation);
        this.build(anim);

        const view = new View3D();
        view.scene = this.scene;
        view.camera = camera;
        engine.startRenderView(view);

        const post = this.scene.addComponent(PostProcessingComponent);
        this.blur = post.addPost(MotionBlurPost);
        this.blur.intensity = 1;
        this.blur.samples = 16;
        this.blur.maxBlurPixels = 96;
        this.initGUI(anim);
    }

    private lit(r: number, g: number, b: number, roughness = 0.6, metallic = 0): LitMaterial {
        const m = new LitMaterial();
        m.baseColor = new Color(r, g, b);
        m.roughness = roughness;
        m.metallic = metallic;
        return m;
    }

    private mesh(parent: Object3D, geo: GeometryBase, mat: LitMaterial, x = 0, y = 0, z = 0): Object3D {
        const obj = new Object3D();
        const mr = obj.addComponent(MeshRenderer);
        mr.geometry = geo;
        mr.material = mat;
        mr.castShadow = true;
        obj.x = x;
        obj.y = y;
        obj.z = z;
        parent.addChild(obj);
        return obj;
    }

    private build(anim: SceneAnimation) {
        this.mesh(this.scene, new BoxGeometry(80, 0.4, 80), this.lit(0.5, 0.5, 0.52, 0.9), 0, -0.2, 0);
        // Static pillars: a sharp reference next to the moving things
        const pillar = new BoxGeometry(1, 8, 1);
        for (let i = -3; i <= 3; i++) this.mesh(this.scene, pillar, this.lit(0.85, 0.85, 0.85, 0.7), i * 6, 4, -12);

        // Propeller: hub + three blades, rotating around z
        const propeller = new Object3D();
        propeller.x = -10;
        propeller.y = 8;
        this.scene.addChild(propeller);
        this.mesh(propeller, new CylinderGeometry(0.8, 0.8, 1.2, 24), this.lit(0.2, 0.2, 0.22, 0.4, 1)).rotationX = 90;
        const blade = new BoxGeometry(0.4, 7, 1.2);
        const bladeMats = [this.lit(0.9, 0.2, 0.15), this.lit(0.95, 0.8, 0.2), this.lit(0.2, 0.5, 0.95)];
        for (let i = 0; i < 3; i++) {
            const arm = new Object3D();
            arm.rotationZ = i * 120;
            propeller.addChild(arm);
            this.mesh(arm, blade, bladeMats[i], 0, 3.8, 0);
        }
        this.mesh(this.scene, new BoxGeometry(0.6, 8, 0.6), this.lit(0.3, 0.3, 0.3), -10, 4, -1);

        // Pendulum: pivot at the top, bob at the end of a rod
        const pendulum = new Object3D();
        pendulum.y = 12;
        this.scene.addChild(pendulum);
        this.mesh(pendulum, new CylinderGeometry(0.12, 0.12, 9, 12), this.lit(0.6, 0.6, 0.6, 0.4, 1), 0, -4.5, 0);
        this.mesh(pendulum, new SphereGeometry(1.4, 32, 32), this.lit(0.95, 0.55, 0.15, 0.3, 1), 0, -9.5, 0);
        this.mesh(this.scene, new BoxGeometry(6, 0.4, 0.4), this.lit(0.3, 0.3, 0.3), 0, 12.2, 0);

        // Shuttle: five spheres sliding along x, each with a different phase
        const ball = new SphereGeometry(0.9, 32, 32);
        const shuttle: Object3D[] = [];
        for (let i = 0; i < 5; i++) shuttle.push(this.mesh(this.scene, ball, this.lit(0.2 + i * 0.15, 0.8, 0.3), 0, 1.2, 4 + i * 2.2));

        const tumbler = this.mesh(this.scene, new BoxGeometry(3, 3, 3), this.lit(0.85, 0.3, 0.6, 0.5), 11, 4, 0);

        anim.propeller = propeller;
        anim.pendulum = pendulum;
        anim.shuttle = shuttle;
        anim.tumbler = tumbler;
    }

    private initGUI(anim: SceneAnimation) {
        const gui = new dat.GUI();
        const f = gui.addFolder('Object motion blur');
        f.add({ blur: true }, 'blur').onChange((v: boolean) => (this.blur.intensity = v ? 1 : 0));
        f.add(this.blur, 'intensity', 0, 3, 0.05);
        f.add(this.blur, 'samples', 2, 32, 1);
        f.add(this.blur, 'maxBlurPixels', 4, 256, 1);
        f.add(anim, 'propellerRpm', 0, 300, 1);
        f.add(anim, 'swing');
        f.add(anim, 'shuttleSpeed', 0, 3, 0.05);
        f.add(anim, 'tumble');
        f.open();
        // Start collapsed so the panel does not cover the scene
        gui.close();
    }
}

/** Moves the objects every frame. */
class SceneAnimation extends ComponentBase {
    public propellerRpm = 90;
    public swing = true;
    public shuttleSpeed = 1;
    public tumble = true;
    public propeller: Object3D;
    public pendulum: Object3D;
    public shuttle: Object3D[] = [];
    public tumbler: Object3D;
    private t = 0;

    public onUpdate(): void {
        if (!this.propeller) return;
        const dt = Math.min(Time.delta, 50) / 1000;
        this.t += dt;
        // rpm to degrees per second
        this.propeller.rotationZ += this.propellerRpm * 6 * dt;
        if (this.swing) this.pendulum.rotationZ = Math.sin(this.t * 2.2) * 50;
        for (let i = 0; i < this.shuttle.length; i++) {
            this.shuttle[i].x = Math.sin((this.t * this.shuttleSpeed + i * 0.6) * 1.8) * 12;
        }
        if (this.tumble) {
            this.tumbler.rotationX += 140 * dt;
            this.tumbler.rotationY += 90 * dt;
        }
    }
}

new Sample_ObjectMotionBlur().run();