运动模糊 - MotionBlurPost
真实相机在快门打开期间,相机或被拍摄的物体发生移动,画面就会沿运动方向拖出拖影。MotionBlurPost 计算每个像素从上一帧到当前帧在屏幕上移动的路径,并沿该路径多次采样取平均,产生真实的运动模糊。
像素的移动路径有两种来源:
| 模式 | 开启方式 | 效果 |
|---|---|---|
| 相机运动模糊 | 默认 | 由深度和上一帧的视图投影矩阵重建,只模糊相机平移、环绕、推拉造成的位移;相机静止时,自身在运动的物体保持清晰 |
| 对象运动模糊 | setting.render.objectMotionVectors = true | 每个物体额外绘制自身的屏幕空间速度,相机静止时运动的物体也会产生拖影 |
// 初始化引擎,可在此配置默认参数
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 配置参数:
| 参数 | 类型 | 默认值 | 描述 |
|---|---|---|---|
enable | boolean | false | 是否启用,添加 / 移除 MotionBlurPost 时自动设置 |
intensity | number | 0.5 | 快门系数。1 沿像素一整帧的移动距离模糊(60 Hz 下的 360° 快门),0.5 为电影常用的 180° 快门,0 关闭 |
samples | number | 12 | 沿运动方向的采样次数,2 ~ 32,越多越平滑,开销也越大 |
maxBlurPixels | number | 64 | 模糊长度的上限(像素),避免镜头切换或瞬移时整个画面被拖花 |
frameRateIndependent | boolean | true | 按 60 Hz 的帧时长归一化模糊长度,使不同帧率下的效果一致;关闭后直接按每帧的实际位移模糊 |
镜头切换
相机瞬间切换到另一个位置时,上一帧与当前帧的位移很大,会产生一帧不自然的拖影(虽然已被 maxBlurPixels 限制)。切换后调用 resetHistory(),下一帧将不做模糊:
camera.object3D.localPosition = newPosition;
motionBlur.resetHistory();开启 对象运动模糊 时,resetHistory() 会同时重置 MotionVectorPass,重置后的那一帧也不绘制逐物体速度,跟随镜头一起瞬移的物体同样不会产生拖影。
示例:相机运动模糊
下面的示例中相机自动环绕场景旋转,转速越快拖影越长。拖动鼠标转动视角同样会产生模糊;把 orbitSpeed 调为 0 后画面恢复清晰。
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,即可让运动的物体按各自的速度产生模糊:
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 形变动画的网格只计算整体的刚体运动(上一帧的模型矩阵 + 当前姿态),骨骼与形变本身的运动不产生模糊;
- 每个不透明物体每帧多绘制一次,场景物体较多时需要关注性能开销。
示例:对象运动模糊
下面的示例中相机保持静止,螺旋桨旋转、单摆摆动、一排小球来回移动、立方体翻滚,它们各自按速度产生拖影,静止的地面和立柱保持清晰。
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();
