mirror of
https://github.com/galacean/engine.git
synced 2026-05-23 01:40:11 +08:00
test(particle): add unit tests and e2e case for shape transform
This commit is contained in:
89
e2e/case/particleRenderer-shape-transform.ts
Normal file
89
e2e/case/particleRenderer-shape-transform.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
/**
|
||||
* @title Particle Shape Transform
|
||||
* @category Particle
|
||||
*/
|
||||
import {
|
||||
Camera,
|
||||
Color,
|
||||
ConeShape,
|
||||
BoxShape,
|
||||
Engine,
|
||||
Entity,
|
||||
Logger,
|
||||
ParticleMaterial,
|
||||
ParticleRenderer,
|
||||
ParticleSimulationSpace,
|
||||
Vector3,
|
||||
WebGLEngine
|
||||
} from "@galacean/engine";
|
||||
import { initScreenshot, updateForE2E } from "./.mockForE2E";
|
||||
|
||||
WebGLEngine.create({
|
||||
canvas: "canvas"
|
||||
}).then((engine) => {
|
||||
Logger.enable();
|
||||
engine.canvas.resizeByClientSize();
|
||||
|
||||
const rootEntity = engine.sceneManager.activeScene.createRootEntity("Root");
|
||||
|
||||
const cameraEntity = rootEntity.createChild("Camera");
|
||||
cameraEntity.transform.position = new Vector3(0, 0, 30);
|
||||
const camera = cameraEntity.addComponent(Camera);
|
||||
camera.fieldOfView = 60;
|
||||
camera.nearClipPlane = 0.3;
|
||||
camera.farClipPlane = 1000;
|
||||
|
||||
// Cone with shape position offset
|
||||
createParticle(rootEntity, engine, -6, () => {
|
||||
const shape = new ConeShape();
|
||||
shape.position.set(0, 3, 0);
|
||||
return shape;
|
||||
});
|
||||
|
||||
// Cone with shape rotation
|
||||
createParticle(rootEntity, engine, -2, () => {
|
||||
const shape = new ConeShape();
|
||||
shape.rotation.set(0, 0, 90);
|
||||
return shape;
|
||||
});
|
||||
|
||||
// Box with shape scale
|
||||
createParticle(rootEntity, engine, 2, () => {
|
||||
const shape = new BoxShape();
|
||||
shape.scale.set(3, 1, 1);
|
||||
return shape;
|
||||
});
|
||||
|
||||
// Cone with combined transform
|
||||
createParticle(rootEntity, engine, 6, () => {
|
||||
const shape = new ConeShape();
|
||||
shape.position.set(0, 2, 0);
|
||||
shape.rotation.set(0, 0, 45);
|
||||
shape.scale.set(2, 1, 1);
|
||||
return shape;
|
||||
});
|
||||
|
||||
updateForE2E(engine, 500);
|
||||
initScreenshot(engine, camera);
|
||||
});
|
||||
|
||||
function createParticle(rootEntity: Entity, engine: Engine, xPos: number, createShape: () => any): void {
|
||||
const particleEntity = rootEntity.createChild("Particle");
|
||||
particleEntity.transform.position.set(xPos, 0, 0);
|
||||
|
||||
const particleRenderer = particleEntity.addComponent(ParticleRenderer);
|
||||
|
||||
const material = new ParticleMaterial(engine);
|
||||
material.baseColor = new Color(1.0, 1.0, 1.0, 1.0);
|
||||
particleRenderer.setMaterial(material);
|
||||
|
||||
const generator = particleRenderer.generator;
|
||||
generator.useAutoRandomSeed = false;
|
||||
|
||||
const { main, emission } = generator;
|
||||
main.startSpeed.constant = 3;
|
||||
main.startSize.constant = 0.15;
|
||||
main.simulationSpace = ParticleSimulationSpace.Local;
|
||||
|
||||
emission.shape = createShape();
|
||||
}
|
||||
@@ -436,6 +436,12 @@ export const E2E_CONFIG = {
|
||||
caseFileName: "particleRenderer-noise",
|
||||
threshold: 0,
|
||||
diffPercentage: 0
|
||||
},
|
||||
shapeTransform: {
|
||||
category: "Particle",
|
||||
caseFileName: "particleRenderer-shape-transform",
|
||||
threshold: 0,
|
||||
diffPercentage: 0
|
||||
}
|
||||
},
|
||||
PostProcess: {
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:aa7a4bc8c57d966ad7ca49f6ea4f7177bb5d4e9c4c666f3c92ecc600c08b28f5
|
||||
size 23645
|
||||
@@ -404,6 +404,73 @@ describe("ParticleBoundingBox", function () {
|
||||
);
|
||||
});
|
||||
|
||||
it("ShapeTransform-Position", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.position.set(5, 0, 0);
|
||||
particleRenderer.generator.emission.shape = shape;
|
||||
|
||||
// Same as default BoxShape bounds shifted by (5,0,0)
|
||||
testParticleRendererBounds(
|
||||
engine,
|
||||
particleRenderer,
|
||||
{ x: 3.086, y: -1.914, z: -26.914 },
|
||||
{ x: 6.914, y: 1.914, z: 1.914 },
|
||||
delta
|
||||
);
|
||||
});
|
||||
|
||||
it("ShapeTransform-Rotation", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.size.set(1, 2, 1);
|
||||
shape.rotation.set(0, 0, 90);
|
||||
particleRenderer.generator.emission.shape = shape;
|
||||
|
||||
// size(1,2,1): local pos range (-0.5,-1,-0.5)~(0.5,1,0.5)
|
||||
// rotated 90 Z: x<->y swapped -> (-1,-0.5,-0.5)~(1,0.5,0.5)
|
||||
testParticleRendererBounds(
|
||||
engine,
|
||||
particleRenderer,
|
||||
{ x: -2.414, y: -1.914, z: -26.914 },
|
||||
{ x: 2.414, y: 1.914, z: 1.914 },
|
||||
delta
|
||||
);
|
||||
});
|
||||
|
||||
it("ShapeTransform-Scale", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.scale.set(3, 1, 1);
|
||||
particleRenderer.generator.emission.shape = shape;
|
||||
|
||||
// Default box pos range (-0.5,-0.5,-0.5)~(0.5,0.5,0.5), X scaled 3x -> (-1.5,...)~(1.5,...)
|
||||
testParticleRendererBounds(
|
||||
engine,
|
||||
particleRenderer,
|
||||
{ x: -2.914, y: -1.914, z: -26.914 },
|
||||
{ x: 2.914, y: 1.914, z: 1.914 },
|
||||
delta
|
||||
);
|
||||
});
|
||||
|
||||
it("ShapeTransform-Combined", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.position.set(0, 0, 5);
|
||||
shape.rotation.set(0, 0, 90);
|
||||
shape.scale.set(2, 1, 1);
|
||||
particleRenderer.generator.emission.shape = shape;
|
||||
|
||||
// Default size(1,1,1): local (-0.5,-0.5,-0.5)~(0.5,0.5,0.5)
|
||||
// scale(2,1,1) -> (-1,-0.5,-0.5)~(1,0.5,0.5)
|
||||
// rotate 90 Z: x<->y -> (-0.5,-1,-0.5)~(0.5,1,0.5)
|
||||
// + position(0,0,5) -> (-0.5,-1,4.5)~(0.5,1,5.5)
|
||||
testParticleRendererBounds(
|
||||
engine,
|
||||
particleRenderer,
|
||||
{ x: -1.914, y: -2.414, z: -21.914 },
|
||||
{ x: 1.914, y: 2.414, z: 6.914 },
|
||||
delta
|
||||
);
|
||||
});
|
||||
|
||||
it("Transform", function () {
|
||||
entity.transform.position.set(1, 2, 3);
|
||||
testParticleRendererBounds(
|
||||
|
||||
279
tests/src/core/particle/ParticleShapeTransform.test.ts
Normal file
279
tests/src/core/particle/ParticleShapeTransform.test.ts
Normal file
@@ -0,0 +1,279 @@
|
||||
import { BoxShape, SphereShape, ConeShape } from "@galacean/engine-core";
|
||||
import { Rand, Vector3 } from "@galacean/engine-math";
|
||||
import { describe, beforeEach, expect, it } from "vitest";
|
||||
|
||||
describe("ParticleShapeTransform", function () {
|
||||
const position = new Vector3();
|
||||
const direction = new Vector3();
|
||||
const rand = new Rand(0, 1234);
|
||||
const epsilon = 1e-5;
|
||||
|
||||
describe("Position offset", function () {
|
||||
it("should offset generated position by shape position", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.size.set(0, 0, 0);
|
||||
shape.position.set(3, 5, 7);
|
||||
|
||||
shape._generatePositionAndDirection(rand, 0, position, direction);
|
||||
|
||||
expect(position.x).to.be.closeTo(3, epsilon);
|
||||
expect(position.y).to.be.closeTo(5, epsilon);
|
||||
expect(position.z).to.be.closeTo(7, epsilon);
|
||||
});
|
||||
|
||||
it("should offset position range by shape position", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.size.set(2, 2, 2);
|
||||
shape.position.set(10, 0, 0);
|
||||
|
||||
const min = new Vector3();
|
||||
const max = new Vector3();
|
||||
shape._getPositionRange(min, max);
|
||||
|
||||
expect(min.x).to.be.closeTo(9, epsilon);
|
||||
expect(max.x).to.be.closeTo(11, epsilon);
|
||||
expect(min.y).to.be.closeTo(-1, epsilon);
|
||||
expect(max.y).to.be.closeTo(1, epsilon);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Rotation", function () {
|
||||
it("should rotate position range by shape rotation", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.size.set(2, 0, 0);
|
||||
shape.rotation.set(0, 0, 90);
|
||||
|
||||
const min = new Vector3();
|
||||
const max = new Vector3();
|
||||
shape._getPositionRange(min, max);
|
||||
|
||||
// Local range: (-1,0,0) to (1,0,0), rotated 90 around Z -> (0,-1,0) to (0,1,0)
|
||||
expect(min.x).to.be.closeTo(0, epsilon);
|
||||
expect(max.x).to.be.closeTo(0, epsilon);
|
||||
expect(min.y).to.be.closeTo(-1, epsilon);
|
||||
expect(max.y).to.be.closeTo(1, epsilon);
|
||||
});
|
||||
|
||||
it("should rotate box position range", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.size.set(2, 4, 2);
|
||||
shape.rotation.set(0, 0, 90);
|
||||
|
||||
const min = new Vector3();
|
||||
const max = new Vector3();
|
||||
shape._getPositionRange(min, max);
|
||||
|
||||
// Original range: (-1,-2,-1) to (1,2,1)
|
||||
// After 90 Z rotation: x<->y swapped
|
||||
expect(min.x).to.be.closeTo(-2, epsilon);
|
||||
expect(max.x).to.be.closeTo(2, epsilon);
|
||||
expect(min.y).to.be.closeTo(-1, epsilon);
|
||||
expect(max.y).to.be.closeTo(1, epsilon);
|
||||
});
|
||||
|
||||
it("sphere bounds should be conservative after rotation", function () {
|
||||
const shape = new SphereShape();
|
||||
shape.radius = 2;
|
||||
|
||||
const minBefore = new Vector3();
|
||||
const maxBefore = new Vector3();
|
||||
shape._getPositionRange(minBefore, maxBefore);
|
||||
|
||||
shape.rotation.set(45, 30, 60);
|
||||
const minAfter = new Vector3();
|
||||
const maxAfter = new Vector3();
|
||||
shape._getPositionRange(minAfter, maxAfter);
|
||||
|
||||
// Arvo rotates the AABB (cube), which expands it. Bounds should be >= original.
|
||||
expect(minAfter.x).to.be.lessThanOrEqual(minBefore.x + epsilon);
|
||||
expect(minAfter.y).to.be.lessThanOrEqual(minBefore.y + epsilon);
|
||||
expect(minAfter.z).to.be.lessThanOrEqual(minBefore.z + epsilon);
|
||||
expect(maxAfter.x).to.be.greaterThanOrEqual(maxBefore.x - epsilon);
|
||||
expect(maxAfter.y).to.be.greaterThanOrEqual(maxBefore.y - epsilon);
|
||||
expect(maxAfter.z).to.be.greaterThanOrEqual(maxBefore.z - epsilon);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Scale", function () {
|
||||
it("should scale generated position", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.size.set(0, 0, 0);
|
||||
shape.position.set(1, 0, 0);
|
||||
shape.scale.set(3, 1, 1);
|
||||
|
||||
shape._generatePositionAndDirection(rand, 0, position, direction);
|
||||
|
||||
// position (0,0,0) scaled then rotated then + position offset (1,0,0)
|
||||
expect(position.x).to.be.closeTo(1, epsilon);
|
||||
expect(position.y).to.be.closeTo(0, epsilon);
|
||||
});
|
||||
|
||||
it("should scale position range", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.size.set(2, 2, 2);
|
||||
shape.scale.set(3, 1, 1);
|
||||
|
||||
const min = new Vector3();
|
||||
const max = new Vector3();
|
||||
shape._getPositionRange(min, max);
|
||||
|
||||
// Original: (-1,-1,-1) to (1,1,1), scaled X by 3
|
||||
expect(min.x).to.be.closeTo(-3, epsilon);
|
||||
expect(max.x).to.be.closeTo(3, epsilon);
|
||||
expect(min.y).to.be.closeTo(-1, epsilon);
|
||||
expect(max.y).to.be.closeTo(1, epsilon);
|
||||
});
|
||||
|
||||
it("negative scale should flip and reorder min/max", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.size.set(2, 2, 2);
|
||||
shape.scale.set(-1, 1, 1);
|
||||
|
||||
const min = new Vector3();
|
||||
const max = new Vector3();
|
||||
shape._getPositionRange(min, max);
|
||||
|
||||
// After negative X scale, reorder ensures min < max
|
||||
expect(min.x).to.be.closeTo(-1, epsilon);
|
||||
expect(max.x).to.be.closeTo(1, epsilon);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Combined transform", function () {
|
||||
it("should apply scale then rotation then position", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.size.set(0, 0, 0);
|
||||
shape.position.set(0, 0, 5);
|
||||
shape.rotation.set(0, 90, 0);
|
||||
shape.scale.set(2, 1, 1);
|
||||
|
||||
// Generate from zero-size box at origin
|
||||
shape._generatePositionAndDirection(rand, 0, position, direction);
|
||||
|
||||
// Local pos (0,0,0) -> scale -> (0,0,0) -> rotate -> (0,0,0) -> + offset (0,0,5)
|
||||
expect(position.x).to.be.closeTo(0, epsilon);
|
||||
expect(position.y).to.be.closeTo(0, epsilon);
|
||||
expect(position.z).to.be.closeTo(5, epsilon);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Default transform (no-op fast path)", function () {
|
||||
it("should produce identical results when no transform set", function () {
|
||||
const shape1 = new BoxShape();
|
||||
const shape2 = new BoxShape();
|
||||
shape1.size.set(2, 3, 4);
|
||||
shape2.size.set(2, 3, 4);
|
||||
|
||||
const min1 = new Vector3();
|
||||
const max1 = new Vector3();
|
||||
const min2 = new Vector3();
|
||||
const max2 = new Vector3();
|
||||
|
||||
shape1._getPositionRange(min1, max1);
|
||||
shape2._getPositionRange(min2, max2);
|
||||
|
||||
expect(min1.x).to.be.closeTo(min2.x, epsilon);
|
||||
expect(min1.y).to.be.closeTo(min2.y, epsilon);
|
||||
expect(min1.z).to.be.closeTo(min2.z, epsilon);
|
||||
expect(max1.x).to.be.closeTo(max2.x, epsilon);
|
||||
expect(max1.y).to.be.closeTo(max2.y, epsilon);
|
||||
expect(max1.z).to.be.closeTo(max2.z, epsilon);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Direction range", function () {
|
||||
it("should rotate direction range by shape rotation", function () {
|
||||
const shape = new BoxShape();
|
||||
// Default direction range: min=(0,0,-1), max=(0,0,0)
|
||||
shape.rotation.set(90, 0, 0);
|
||||
|
||||
const min = new Vector3();
|
||||
const max = new Vector3();
|
||||
shape._getDirectionRange(min, max);
|
||||
|
||||
// (0,0,-1) rotated 90 around X -> (0,1,0)
|
||||
// Rotated AABB: min=(0,0,0), max=(0,1,0)
|
||||
expect(min.x).to.be.closeTo(0, epsilon);
|
||||
expect(min.y).to.be.closeTo(0, epsilon);
|
||||
expect(min.z).to.be.closeTo(0, epsilon);
|
||||
expect(max.y).to.be.closeTo(1, epsilon);
|
||||
});
|
||||
});
|
||||
|
||||
describe("Clone", function () {
|
||||
it("cloned shape should have correct transform values", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.position.set(1, 2, 3);
|
||||
shape.rotation.set(45, 0, 0);
|
||||
shape.scale.set(2, 2, 2);
|
||||
shape.size.set(4, 4, 4);
|
||||
|
||||
// Clone via _cloneTo
|
||||
const clone = new BoxShape();
|
||||
// @ts-ignore - access internal _cloneTo
|
||||
shape._cloneTo(clone);
|
||||
|
||||
expect(clone.position.x).to.be.closeTo(1, epsilon);
|
||||
expect(clone.position.y).to.be.closeTo(2, epsilon);
|
||||
expect(clone.position.z).to.be.closeTo(3, epsilon);
|
||||
expect(clone.rotation.x).to.be.closeTo(45, epsilon);
|
||||
expect(clone.scale.x).to.be.closeTo(2, epsilon);
|
||||
});
|
||||
|
||||
it("cloned shape should rebuild rotation quaternion correctly", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.size.set(2, 0, 0);
|
||||
shape.rotation.set(0, 0, 90);
|
||||
|
||||
const clone = new BoxShape();
|
||||
// @ts-ignore
|
||||
shape._cloneTo(clone);
|
||||
clone.size.set(2, 0, 0);
|
||||
|
||||
// Verify clone's rotation produces same bounds as original
|
||||
const minOrig = new Vector3();
|
||||
const maxOrig = new Vector3();
|
||||
shape._getPositionRange(minOrig, maxOrig);
|
||||
|
||||
const minClone = new Vector3();
|
||||
const maxClone = new Vector3();
|
||||
clone._getPositionRange(minClone, maxClone);
|
||||
|
||||
// Both should have: local (-1,0,0)~(1,0,0) rotated 90Z -> (0,-1,0)~(0,1,0)
|
||||
expect(minClone.x).to.be.closeTo(minOrig.x, epsilon);
|
||||
expect(minClone.y).to.be.closeTo(minOrig.y, epsilon);
|
||||
expect(maxClone.x).to.be.closeTo(maxOrig.x, epsilon);
|
||||
expect(maxClone.y).to.be.closeTo(maxOrig.y, epsilon);
|
||||
});
|
||||
|
||||
it("modifying clone should not affect original", function () {
|
||||
const shape = new BoxShape();
|
||||
shape.position.set(1, 2, 3);
|
||||
|
||||
const clone = new BoxShape();
|
||||
// @ts-ignore
|
||||
shape._cloneTo(clone);
|
||||
|
||||
clone.position.set(10, 20, 30);
|
||||
|
||||
expect(shape.position.x).to.be.closeTo(1, epsilon);
|
||||
expect(shape.position.y).to.be.closeTo(2, epsilon);
|
||||
expect(shape.position.z).to.be.closeTo(3, epsilon);
|
||||
});
|
||||
|
||||
it("clone callback should trigger on cloned shape", function () {
|
||||
const shape = new BoxShape();
|
||||
const clone = new BoxShape();
|
||||
// @ts-ignore
|
||||
shape._cloneTo(clone);
|
||||
|
||||
let notified = false;
|
||||
clone._registerOnValueChanged(() => {
|
||||
notified = true;
|
||||
});
|
||||
|
||||
clone.position.x = 10;
|
||||
expect(notified).to.be.true;
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user