From 110f35ccb86ec5de4a7ea31f6c431efefa6ce5da Mon Sep 17 00:00:00 2001 From: hhhhkrx Date: Wed, 15 Apr 2026 21:01:45 +0800 Subject: [PATCH] test(particle): add unit tests and e2e case for shape transform --- e2e/case/particleRenderer-shape-transform.ts | 89 ++++++ e2e/config.ts | 6 + ...ticle_particleRenderer-shape-transform.jpg | 3 + .../core/particle/ParticleBoundingBox.test.ts | 67 +++++ .../particle/ParticleShapeTransform.test.ts | 279 ++++++++++++++++++ 5 files changed, 444 insertions(+) create mode 100644 e2e/case/particleRenderer-shape-transform.ts create mode 100644 e2e/fixtures/originImage/Particle_particleRenderer-shape-transform.jpg create mode 100644 tests/src/core/particle/ParticleShapeTransform.test.ts diff --git a/e2e/case/particleRenderer-shape-transform.ts b/e2e/case/particleRenderer-shape-transform.ts new file mode 100644 index 000000000..3fc0bf00d --- /dev/null +++ b/e2e/case/particleRenderer-shape-transform.ts @@ -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(); +} diff --git a/e2e/config.ts b/e2e/config.ts index 05c61ad75..f8e924c09 100644 --- a/e2e/config.ts +++ b/e2e/config.ts @@ -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: { diff --git a/e2e/fixtures/originImage/Particle_particleRenderer-shape-transform.jpg b/e2e/fixtures/originImage/Particle_particleRenderer-shape-transform.jpg new file mode 100644 index 000000000..6fb96ec8f --- /dev/null +++ b/e2e/fixtures/originImage/Particle_particleRenderer-shape-transform.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:aa7a4bc8c57d966ad7ca49f6ea4f7177bb5d4e9c4c666f3c92ecc600c08b28f5 +size 23645 diff --git a/tests/src/core/particle/ParticleBoundingBox.test.ts b/tests/src/core/particle/ParticleBoundingBox.test.ts index 8b4e78b8f..24f38f6e9 100644 --- a/tests/src/core/particle/ParticleBoundingBox.test.ts +++ b/tests/src/core/particle/ParticleBoundingBox.test.ts @@ -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( diff --git a/tests/src/core/particle/ParticleShapeTransform.test.ts b/tests/src/core/particle/ParticleShapeTransform.test.ts new file mode 100644 index 000000000..d528422b8 --- /dev/null +++ b/tests/src/core/particle/ParticleShapeTransform.test.ts @@ -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; + }); + }); +});