Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
86 changes: 62 additions & 24 deletions src/framework/components/collision/system.js
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ const p2 = new Vec3();
const p3 = new Vec3();
const quat = new Quat();
const quat2 = new Quat();
const worldScale = new Vec3();

const SCALE_CHANGE_TOLERANCE = 1e-5;

// Note that `shape` is deliberately absent from this list - it is runtime
// state created and owned by the type implementation, not component data
Expand Down Expand Up @@ -87,8 +90,7 @@ const collisionImpls = {
// the model comes from assets - no placeholder is created
beforeInitialize() {},
createPhysicalShape: createMeshShape,
recreatePhysicalShapes: recreateMeshShapes,
updateTransform: updateMeshTransform
recreatePhysicalShapes: recreateMeshShapes
},

compound: {
Expand Down Expand Up @@ -150,6 +152,11 @@ function destroyShape(system, component) {
}

function beforeRemove(system, entity, component) {
const watchIndex = system._meshComponents.indexOf(component);
if (watchIndex !== -1) {
system._meshComponents.splice(watchIndex, 1);
}

if (component._shape) {
if (component._compoundParent && !component._compoundParent.entity._destroying) {
system._removeCompoundChild(component._compoundParent, component._shape);
Expand Down Expand Up @@ -234,12 +241,6 @@ function recreateShapes(system, component) {
}
}

function updateShapeTransform(system, component, position, rotation, scale) {
if (component.entity.trigger) {
component.entity.trigger.updateTransform();
}
}

// Builds a PhysicsMeshSource for one mesh. Vertex and index data are exposed through lazy
// accessors so they are only extracted when the backend actually builds triangle data -
// sources whose geometry is already cached (by mesh id) never touch the vertex buffer.
Expand Down Expand Up @@ -332,8 +333,12 @@ function createMeshShape(system, entity, component) {
shapeScale = scale;
}

// record the scale the shape was built with, for the rebuild-on-scale-change check
// record the scale the shape was built with and watch it for changes - a runtime
// rescale of the entity rebuilds the shape (see _updateMeshScales)
component._builtWorldScale = scale;
if (!system._meshComponents.includes(component)) {
system._meshComponents.push(component);
}

return world.createShape({
type: 'mesh',
Expand Down Expand Up @@ -428,18 +433,14 @@ function recreateMeshShapes(system, component) {
doRecreateMeshShape(system, component);
}

function updateMeshTransform(system, component, position, rotation, scale) {
if (component.shape && component._builtWorldScale) {
const entityTransform = component.entity.getWorldTransform();
const worldScale = entityTransform.getScale();

// if the scale changed then recreate the shape
if (!worldScale.equals(component._builtWorldScale)) {
doRecreateMeshShape(system, component);
}
}

updateShapeTransform(system, component, position, rotation, scale);
// Returns whether a freshly extracted world scale differs from the scale a mesh shape was
// built with. The scale is derived from the world matrix, so merely rotating an entity
// perturbs the extracted values by float noise - compare with a relative tolerance so only
// real scale changes trigger a rebuild
function scaleChanged(scale, builtScale) {
return Math.abs(scale.x - builtScale.x) > SCALE_CHANGE_TOLERANCE * Math.max(1, Math.abs(builtScale.x)) ||
Math.abs(scale.y - builtScale.y) > SCALE_CHANGE_TOLERANCE * Math.max(1, Math.abs(builtScale.y)) ||
Math.abs(scale.z - builtScale.z) > SCALE_CHANGE_TOLERANCE * Math.max(1, Math.abs(builtScale.z));
}

/**
Expand All @@ -448,6 +449,15 @@ function updateMeshTransform(system, component, position, rotation, scale) {
* @category Physics
*/
class CollisionComponentSystem extends ComponentSystem {
/**
* Mesh components with a built shape, watched for entity world scale changes. Maintained
* by createMeshShape and beforeRemove.
*
* @type {CollisionComponent[]}
* @private
*/
_meshComponents = [];

/**
* Creates a new CollisionComponentSystem instance.
*
Expand Down Expand Up @@ -564,9 +574,37 @@ class CollisionComponentSystem extends ComponentSystem {
this.physicsWorld.removeCompoundChild(collision.shape, shape);
}

onTransformChanged(component, position, rotation, scale) {
const impl = getImpl(component.type);
(impl.updateTransform ?? updateShapeTransform)(this, component, position, rotation, scale);
/**
* Rebuilds mesh shapes whose entity world scale no longer matches the scale they were
* built with. Driven by the rigid body system at the start of each physics update.
*
* @ignore
*/
_updateMeshScales() {
if (!this.physicsWorld) {
return;
}

const components = this._meshComponents;
// walk backwards - a rebuild that has lost its mesh sources removes the component
for (let i = components.length - 1; i >= 0; i--) {
const component = components[i];
if (!component._shape || !component.enabled || !component.entity.enabled) {
continue;
}

// skip compound children - the mesh rebuild path does not detach the old shape
// from the parent compound, so rebuilding one here would leave the parent
// referencing a destroyed shape
if (component._compoundParent) {
continue;
}

const scale = component.entity.getWorldTransform().getScale(worldScale);
if (scaleChanged(scale, component._builtWorldScale)) {
doRecreateMeshShape(this, component);
}
}
}

// Destroys the previous collision type and creates a new one based on the new type provided
Expand Down
3 changes: 3 additions & 0 deletions src/framework/components/rigid-body/system.js
Original file line number Diff line number Diff line change
Expand Up @@ -759,6 +759,9 @@ class RigidBodyComponentSystem extends ComponentSystem {
// Check to see whether we need to update gravity on the physics world
this._world.setGravity(this.gravity);

// rebuild mesh collision shapes whose entity world scale changed since they were built
this.app.systems.collision?._updateMeshScales();

const triggers = this._triggers;
for (i = 0, len = triggers.length; i < len; i++) {
triggers[i].updateTransform();
Expand Down
63 changes: 55 additions & 8 deletions src/framework/physics/ammo/ammo-physics-shape.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,25 @@ import { Debug } from '../../../core/debug.js';
* @import { Vec3 } from '../../../core/math/vec3.js'
*/

// Bake scales within this tolerance of unity are treated as unscaled. The world scale of a
// rotated but unscaled entity is extracted from its world matrix, so it carries float noise -
// without the tolerance such entities would needlessly bypass the triangle data cache
const UNIT_SCALE_TOLERANCE = 1e-5;

/**
* Returns whether a bake scale is close enough to unity to be ignored.
*
* @param {Vec3|null} scale - The bake scale, or null.
* @returns {boolean} True if the scale is null or within tolerance of (1, 1, 1).
*/
function isUnitScale(scale) {
return !scale || (
Math.abs(scale.x - 1) <= UNIT_SCALE_TOLERANCE &&
Math.abs(scale.y - 1) <= UNIT_SCALE_TOLERANCE &&
Math.abs(scale.z - 1) <= UNIT_SCALE_TOLERANCE
);
}

/**
* Writes a position/rotation pair into the world's cached btTransform and returns it.
*
Expand All @@ -33,12 +52,20 @@ function getTransform(world, position, rotation) {
* cache is keyed by the source id so sources sharing geometry share triangle data - source
* data accessors are only read on a cache miss.
*
* Only unscaled triangle data enters the cache: an id can be shared by sources with different
* bake scales, so baked-scale data would leak one component's scale into another's shape and
* make a rebuild after a scale change return stale geometry. Scaled builds are appended to
* ownedTriMeshes instead, and are destroyed with the shape that owns them.
*
* @param {AmmoPhysicsWorld} world - The owning world.
* @param {PhysicsMeshSource} source - The geometry source.
* @param {object[]} ownedTriMeshes - Receives the built trimesh when it cannot be cached.
* @returns {object} The btTriangleMesh.
*/
function getTriMesh(world, source) {
let triMesh = world._triMeshCache.get(source.id);
function getTriMesh(world, source, ownedTriMeshes) {
const bakeScale = isUnitScale(source.bakeScale) ? null : source.bakeScale;

let triMesh = bakeScale ? null : world._triMeshCache.get(source.id);
if (!triMesh) {
const positions = source.positions;
const stride = source.stride;
Expand All @@ -51,14 +78,17 @@ function getTriMesh(world, source) {
let i1, i2, i3;

triMesh = new Ammo.btTriangleMesh();
world._triMeshCache.set(source.id, triMesh);
if (bakeScale) {
ownedTriMeshes.push(triMesh);
} else {
world._triMeshCache.set(source.id, triMesh);
}

const vertexCache = new Map();
Debug.assert(typeof triMesh.getIndexedMeshArray === 'function', 'Ammo.js version is too old, please update to a newer Ammo.');
const indexedArray = triMesh.getIndexedMeshArray();
indexedArray.at(0).m_numTriangles = numTriangles;

const bakeScale = source.bakeScale;
const sx = bakeScale ? bakeScale.x : 1;
const sy = bakeScale ? bakeScale.y : 1;
const sz = bakeScale ? bakeScale.z : 1;
Expand Down Expand Up @@ -144,9 +174,10 @@ function createHullChild(world, compound, source) {
* @param {AmmoPhysicsWorld} world - The owning world.
* @param {object} compound - The btCompoundShape to add to.
* @param {PhysicsMeshSource} source - The geometry source.
* @param {object[]} ownedTriMeshes - Receives trimeshes owned by the compound.
*/
function createTriMeshChild(world, compound, source) {
const triMesh = getTriMesh(world, source);
function createTriMeshChild(world, compound, source, ownedTriMeshes) {
const triMesh = getTriMesh(world, source, ownedTriMeshes);

const triMeshShape = new Ammo.btBvhTriangleMeshShape(triMesh, true /* useQuantizedAabbCompression */);

Expand Down Expand Up @@ -224,16 +255,24 @@ const shapeFactories = {
mesh: (world, desc) => {
const shape = new Ammo.btCompoundShape();

// triangle data built with a baked scale is private to this shape - it stays out of
// the shared cache and is destroyed with the shape
const ownedTriMeshes = [];

const sources = desc.sources;
for (let i = 0; i < sources.length; i++) {
const source = sources[i];
if (source.convexHull) {
createHullChild(world, shape, source);
} else {
createTriMeshChild(world, shape, source);
createTriMeshChild(world, shape, source, ownedTriMeshes);
}
}

if (ownedTriMeshes.length > 0) {
shape._ownedTriMeshes = ownedTriMeshes;
}

if (desc.scale) {
const vec = new Ammo.btVector3(desc.scale.x, desc.scale.y, desc.scale.z);
shape.setLocalScaling(vec);
Expand Down Expand Up @@ -267,12 +306,20 @@ function createShape(world, desc) {
*/
function destroyShape(shape) {
// mesh shapes own their sub-shapes (compound children are owned by other components,
// and the cached triangle data outlives the shape)
// and cached triangle data outlives the shape)
if (shape._shapeType === 'mesh') {
const numShapes = shape.getNumChildShapes();
for (let i = 0; i < numShapes; i++) {
Ammo.destroy(shape.getChildShape(i));
}

// triangle data built with a baked scale is owned by the shape, not the cache
const ownedTriMeshes = shape._ownedTriMeshes;
if (ownedTriMeshes) {
for (let i = 0; i < ownedTriMeshes.length; i++) {
Ammo.destroy(ownedTriMeshes[i]);
}
}
}

Ammo.destroy(shape);
Expand Down
3 changes: 2 additions & 1 deletion src/framework/physics/physics-world.js
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,8 @@ import { PhysicsJoint } from './physics-joint.js';
/**
* @typedef {object} PhysicsMeshSource
* @property {number} id - A stable cache key for the source geometry (mesh id). Backends may
* cache built triangle data per id for the lifetime of the world.
* cache triangle data built without a bake scale per id for the lifetime of the world. An id
* can be shared by sources carrying different bake scales, so scaled data is not cacheable.
* @property {Float32Array|number[]} positions - Vertex positions, possibly interleaved.
* @property {number} stride - The number of floats between consecutive positions (3 when
* tightly packed).
Expand Down
Loading