Add static-simple collision mode for model instances

This commit is contained in:
2026-04-11 16:30:55 +02:00
parent 27664d9074
commit f4150ea4a8
8 changed files with 521 additions and 23 deletions

View File

@@ -5,6 +5,7 @@ import {
Matrix4,
Mesh,
Quaternion,
Triangle,
Vector3,
type BufferGeometry
} from "three";
@@ -17,6 +18,11 @@ import type { Vec3 } from "../core/vector";
const TERRAIN_GRID_EPSILON = 1e-4;
const DYNAMIC_TRIANGLE_TARGET = 48;
const DYNAMIC_SPLIT_DEPTH_LIMIT = 3;
const STATIC_SIMPLE_VOXEL_LONGEST_AXIS_TARGET = 24;
const STATIC_SIMPLE_MIN_VOXEL_LONGEST_AXIS_TARGET = 10;
const STATIC_SIMPLE_VOXEL_AXIS_LIMIT = 28;
const STATIC_SIMPLE_SURFACE_BAND_SCALE = 0.75;
const STATIC_SIMPLE_MIN_THICKNESS = 0.05;
interface LocalTriangle {
readonly a: Vector3;
@@ -74,16 +80,29 @@ export interface GeneratedModelHeightfieldCollider extends GeneratedModelCollide
maxZ: number;
}
export interface GeneratedModelCompoundColliderPiece {
export interface GeneratedModelCompoundConvexHullColliderPiece {
id: string;
kind: "convexHull";
points: Float32Array;
localBounds: GeneratedColliderBounds;
}
export interface GeneratedModelCompoundBoxColliderPiece {
id: string;
kind: "box";
center: Vec3;
size: Vec3;
localBounds: GeneratedColliderBounds;
}
export type GeneratedModelCompoundColliderPiece =
| GeneratedModelCompoundConvexHullColliderPiece
| GeneratedModelCompoundBoxColliderPiece;
export interface GeneratedModelCompoundCollider extends GeneratedModelColliderBase {
kind: "compound";
pieces: GeneratedModelCompoundColliderPiece[];
decomposition: "spatial-bisect";
decomposition: "spatial-bisect" | "surface-voxel-boxes";
runtimeBehavior: "fixedQueryOnly";
}
@@ -126,6 +145,21 @@ function createBounds(min: Vector3, max: Vector3): GeneratedColliderBounds {
};
}
function createBoundsFromCenterAndSize(center: Vec3, size: Vec3): GeneratedColliderBounds {
return {
min: {
x: center.x - size.x * 0.5,
y: center.y - size.y * 0.5,
z: center.z - size.z * 0.5
},
max: {
x: center.x + size.x * 0.5,
y: center.y + size.y * 0.5,
z: center.z + size.z * 0.5
}
};
}
function createModelTransform(modelInstance: ModelInstance): GeneratedModelColliderTransform {
return {
position: cloneVec3(modelInstance.position),
@@ -205,6 +239,22 @@ function computeWorldBoundsFromLocalBox(localBounds: GeneratedColliderBounds, mo
return computeBoundsFromPoints(corners.map((corner) => corner.applyMatrix4(modelMatrix)));
}
function computeCompoundColliderLocalBounds(pieces: GeneratedModelCompoundColliderPiece[]): GeneratedColliderBounds {
const min = new Vector3(Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY);
const max = new Vector3(Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY);
for (const piece of pieces) {
min.x = Math.min(min.x, piece.localBounds.min.x);
min.y = Math.min(min.y, piece.localBounds.min.y);
min.z = Math.min(min.z, piece.localBounds.min.z);
max.x = Math.max(max.x, piece.localBounds.max.x);
max.y = Math.max(max.y, piece.localBounds.max.y);
max.z = Math.max(max.z, piece.localBounds.max.z);
}
return createBounds(min, max);
}
interface PositionLikeAttribute {
getX(index: number): number;
getY(index: number): number;
@@ -418,6 +468,376 @@ function dedupeTriangleClusterPoints(triangles: LocalTriangle[]): Float32Array {
);
}
interface StaticSimpleVoxelAxis {
origin: number;
cellSize: number;
dimension: number;
}
interface StaticSimpleVoxelGrid {
origin: Vec3;
cellSize: Vec3;
dimensions: {
x: number;
y: number;
z: number;
};
occupancy: Uint8Array;
}
interface StaticSimpleVoxelBox {
minX: number;
minY: number;
minZ: number;
maxX: number;
maxY: number;
maxZ: number;
}
function createStaticSimpleVoxelAxis(min: number, max: number, baseCellSize: number): StaticSimpleVoxelAxis {
const extent = max - min;
const minimumThickness = Math.min(baseCellSize, STATIC_SIMPLE_MIN_THICKNESS);
if (extent <= minimumThickness) {
const center = (min + max) * 0.5;
return {
origin: center - minimumThickness * 0.5,
cellSize: minimumThickness,
dimension: 1
};
}
const dimension = Math.min(STATIC_SIMPLE_VOXEL_AXIS_LIMIT, Math.max(1, Math.ceil(extent / baseCellSize)));
return {
origin: min,
cellSize: extent / dimension,
dimension
};
}
function createStaticSimpleVoxelGrid(bounds: GeneratedColliderBounds, targetLongestAxis: number): StaticSimpleVoxelGrid {
const extentX = bounds.max.x - bounds.min.x;
const extentY = bounds.max.y - bounds.min.y;
const extentZ = bounds.max.z - bounds.min.z;
const longestExtent = Math.max(extentX, extentY, extentZ, STATIC_SIMPLE_MIN_THICKNESS);
const baseCellSize = longestExtent / Math.max(1, targetLongestAxis);
const xAxis = createStaticSimpleVoxelAxis(bounds.min.x, bounds.max.x, baseCellSize);
const yAxis = createStaticSimpleVoxelAxis(bounds.min.y, bounds.max.y, baseCellSize);
const zAxis = createStaticSimpleVoxelAxis(bounds.min.z, bounds.max.z, baseCellSize);
return {
origin: {
x: xAxis.origin,
y: yAxis.origin,
z: zAxis.origin
},
cellSize: {
x: xAxis.cellSize,
y: yAxis.cellSize,
z: zAxis.cellSize
},
dimensions: {
x: xAxis.dimension,
y: yAxis.dimension,
z: zAxis.dimension
},
occupancy: new Uint8Array(xAxis.dimension * yAxis.dimension * zAxis.dimension)
};
}
function getStaticSimpleVoxelIndex(grid: StaticSimpleVoxelGrid, x: number, y: number, z: number): number {
return x + y * grid.dimensions.x + z * grid.dimensions.x * grid.dimensions.y;
}
function clampGridRangeStart(value: number, origin: number, cellSize: number, dimension: number): number {
return Math.max(0, Math.min(dimension - 1, Math.floor((value - origin) / cellSize)));
}
function clampGridRangeEnd(value: number, origin: number, cellSize: number, dimension: number): number {
return Math.max(0, Math.min(dimension - 1, Math.floor((value - origin) / cellSize)));
}
function setStaticSimpleVoxelOccupied(grid: StaticSimpleVoxelGrid, x: number, y: number, z: number) {
grid.occupancy[getStaticSimpleVoxelIndex(grid, x, y, z)] = 1;
}
function isStaticSimpleVoxelOccupied(grid: StaticSimpleVoxelGrid, x: number, y: number, z: number, claimed?: Uint8Array): boolean {
const index = getStaticSimpleVoxelIndex(grid, x, y, z);
return grid.occupancy[index] === 1 && (claimed === undefined || claimed[index] === 0);
}
function voxelCenterCoordinate(origin: number, cellSize: number, index: number): number {
return origin + (index + 0.5) * cellSize;
}
function voxelBoxToBounds(grid: StaticSimpleVoxelGrid, box: StaticSimpleVoxelBox): GeneratedColliderBounds {
return {
min: {
x: grid.origin.x + box.minX * grid.cellSize.x,
y: grid.origin.y + box.minY * grid.cellSize.y,
z: grid.origin.z + box.minZ * grid.cellSize.z
},
max: {
x: grid.origin.x + box.maxX * grid.cellSize.x,
y: grid.origin.y + box.maxY * grid.cellSize.y,
z: grid.origin.z + box.maxZ * grid.cellSize.z
}
};
}
function voxelBoxToCenterAndSize(grid: StaticSimpleVoxelGrid, box: StaticSimpleVoxelBox): { center: Vec3; size: Vec3 } {
const bounds = voxelBoxToBounds(grid, box);
return {
center: {
x: (bounds.min.x + bounds.max.x) * 0.5,
y: (bounds.min.y + bounds.max.y) * 0.5,
z: (bounds.min.z + bounds.max.z) * 0.5
},
size: {
x: bounds.max.x - bounds.min.x,
y: bounds.max.y - bounds.min.y,
z: bounds.max.z - bounds.min.z
}
};
}
function markStaticSimpleSurfaceVoxels(grid: StaticSimpleVoxelGrid, triangles: LocalTriangle[]) {
const triangle = new Triangle();
const triangleBoundsMin = new Vector3();
const triangleBoundsMax = new Vector3();
const center = new Vector3();
const closestPoint = new Vector3();
const band =
Math.hypot(grid.cellSize.x, grid.cellSize.y, grid.cellSize.z) *
0.5 *
STATIC_SIMPLE_SURFACE_BAND_SCALE;
for (const sourceTriangle of triangles) {
triangle.set(sourceTriangle.a, sourceTriangle.b, sourceTriangle.c);
triangleBoundsMin.copy(sourceTriangle.a).min(sourceTriangle.b).min(sourceTriangle.c);
triangleBoundsMax.copy(sourceTriangle.a).max(sourceTriangle.b).max(sourceTriangle.c);
const startX = clampGridRangeStart(triangleBoundsMin.x - band, grid.origin.x, grid.cellSize.x, grid.dimensions.x);
const startY = clampGridRangeStart(triangleBoundsMin.y - band, grid.origin.y, grid.cellSize.y, grid.dimensions.y);
const startZ = clampGridRangeStart(triangleBoundsMin.z - band, grid.origin.z, grid.cellSize.z, grid.dimensions.z);
const endX = clampGridRangeEnd(triangleBoundsMax.x + band, grid.origin.x, grid.cellSize.x, grid.dimensions.x);
const endY = clampGridRangeEnd(triangleBoundsMax.y + band, grid.origin.y, grid.cellSize.y, grid.dimensions.y);
const endZ = clampGridRangeEnd(triangleBoundsMax.z + band, grid.origin.z, grid.cellSize.z, grid.dimensions.z);
for (let zIndex = startZ; zIndex <= endZ; zIndex += 1) {
for (let yIndex = startY; yIndex <= endY; yIndex += 1) {
for (let xIndex = startX; xIndex <= endX; xIndex += 1) {
center.set(
voxelCenterCoordinate(grid.origin.x, grid.cellSize.x, xIndex),
voxelCenterCoordinate(grid.origin.y, grid.cellSize.y, yIndex),
voxelCenterCoordinate(grid.origin.z, grid.cellSize.z, zIndex)
);
triangle.closestPointToPoint(center, closestPoint);
if (closestPoint.distanceToSquared(center) <= band * band) {
setStaticSimpleVoxelOccupied(grid, xIndex, yIndex, zIndex);
}
}
}
}
}
}
function dilateStaticSimpleSurfaceVoxels(grid: StaticSimpleVoxelGrid): Uint8Array {
const dilated = new Uint8Array(grid.occupancy);
for (let zIndex = 0; zIndex < grid.dimensions.z; zIndex += 1) {
for (let yIndex = 0; yIndex < grid.dimensions.y; yIndex += 1) {
for (let xIndex = 0; xIndex < grid.dimensions.x; xIndex += 1) {
if (!isStaticSimpleVoxelOccupied(grid, xIndex, yIndex, zIndex)) {
continue;
}
for (let zOffset = -1; zOffset <= 1; zOffset += 1) {
const nextZ = zIndex + zOffset;
if (nextZ < 0 || nextZ >= grid.dimensions.z) {
continue;
}
for (let yOffset = -1; yOffset <= 1; yOffset += 1) {
const nextY = yIndex + yOffset;
if (nextY < 0 || nextY >= grid.dimensions.y) {
continue;
}
for (let xOffset = -1; xOffset <= 1; xOffset += 1) {
const nextX = xIndex + xOffset;
if (nextX < 0 || nextX >= grid.dimensions.x) {
continue;
}
dilated[getStaticSimpleVoxelIndex(grid, nextX, nextY, nextZ)] = 1;
}
}
}
}
}
}
return dilated;
}
function canExpandStaticSimpleBoxAlongZ(
grid: StaticSimpleVoxelGrid,
claimed: Uint8Array,
minX: number,
maxX: number,
y: number,
z: number
): boolean {
for (let xIndex = minX; xIndex <= maxX; xIndex += 1) {
if (!isStaticSimpleVoxelOccupied(grid, xIndex, y, z, claimed)) {
return false;
}
}
return true;
}
function canExpandStaticSimpleBoxAlongY(
grid: StaticSimpleVoxelGrid,
claimed: Uint8Array,
minX: number,
maxX: number,
minZ: number,
maxZ: number,
y: number
): boolean {
for (let zIndex = minZ; zIndex <= maxZ; zIndex += 1) {
for (let xIndex = minX; xIndex <= maxX; xIndex += 1) {
if (!isStaticSimpleVoxelOccupied(grid, xIndex, y, zIndex, claimed)) {
return false;
}
}
}
return true;
}
function packStaticSimpleVoxelBoxes(grid: StaticSimpleVoxelGrid): StaticSimpleVoxelBox[] {
const claimed = new Uint8Array(grid.occupancy.length);
const boxes: StaticSimpleVoxelBox[] = [];
for (let yIndex = 0; yIndex < grid.dimensions.y; yIndex += 1) {
for (let zIndex = 0; zIndex < grid.dimensions.z; zIndex += 1) {
for (let xIndex = 0; xIndex < grid.dimensions.x; xIndex += 1) {
if (!isStaticSimpleVoxelOccupied(grid, xIndex, yIndex, zIndex, claimed)) {
continue;
}
let maxX = xIndex;
while (
maxX + 1 < grid.dimensions.x &&
isStaticSimpleVoxelOccupied(grid, maxX + 1, yIndex, zIndex, claimed)
) {
maxX += 1;
}
let maxZ = zIndex;
while (
maxZ + 1 < grid.dimensions.z &&
canExpandStaticSimpleBoxAlongZ(grid, claimed, xIndex, maxX, yIndex, maxZ + 1)
) {
maxZ += 1;
}
let maxY = yIndex;
while (
maxY + 1 < grid.dimensions.y &&
canExpandStaticSimpleBoxAlongY(grid, claimed, xIndex, maxX, zIndex, maxZ, maxY + 1)
) {
maxY += 1;
}
for (let fillY = yIndex; fillY <= maxY; fillY += 1) {
for (let fillZ = zIndex; fillZ <= maxZ; fillZ += 1) {
for (let fillX = xIndex; fillX <= maxX; fillX += 1) {
claimed[getStaticSimpleVoxelIndex(grid, fillX, fillY, fillZ)] = 1;
}
}
}
boxes.push({
minX: xIndex,
minY: yIndex,
minZ: zIndex,
maxX: maxX + 1,
maxY: maxY + 1,
maxZ: maxZ + 1
});
}
}
}
return boxes;
}
function collectStaticSimpleBoxPieces(triangles: LocalTriangle[], modelInstanceId: string): GeneratedModelCompoundBoxColliderPiece[] {
const bounds = getTriangleBounds(triangles);
let targetLongestAxis = STATIC_SIMPLE_VOXEL_LONGEST_AXIS_TARGET;
let bestPieces: GeneratedModelCompoundBoxColliderPiece[] = [];
while (targetLongestAxis >= STATIC_SIMPLE_MIN_VOXEL_LONGEST_AXIS_TARGET) {
const grid = createStaticSimpleVoxelGrid(bounds, targetLongestAxis);
markStaticSimpleSurfaceVoxels(grid, triangles);
grid.occupancy = dilateStaticSimpleSurfaceVoxels(grid);
const pieces = packStaticSimpleVoxelBoxes(grid).map((box, index) => {
const { center, size } = voxelBoxToCenterAndSize(grid, box);
return {
id: `${modelInstanceId}-box-piece-${index + 1}`,
kind: "box" as const,
center,
size,
localBounds: createBoundsFromCenterAndSize(center, size)
};
});
if (pieces.length > 0) {
bestPieces = pieces;
}
if (pieces.length > 0 && pieces.length <= STATIC_SIMPLE_VOXEL_AXIS_LIMIT) {
break;
}
if (targetLongestAxis === STATIC_SIMPLE_MIN_VOXEL_LONGEST_AXIS_TARGET) {
break;
}
targetLongestAxis = Math.max(
STATIC_SIMPLE_MIN_VOXEL_LONGEST_AXIS_TARGET,
Math.floor(targetLongestAxis * 0.7)
);
}
if (bestPieces.length === 0) {
throw new ModelColliderGenerationError(
"unsupported-static-simple-model-collider",
`Model instance ${modelInstanceId} could not derive any voxel-box pieces for static-simple collision.`
);
}
return bestPieces;
}
function buildSimpleBoxCollider(modelInstance: ModelInstance, asset: ModelAssetRecord): GeneratedModelBoxCollider {
const boundingBox = asset.metadata.boundingBox;
@@ -623,6 +1043,7 @@ function buildDynamicCollider(
.flatMap((cluster) => collectConvexHullPointClouds(cluster.triangles))
.map((points, index) => ({
id: `${modelInstance.id}-piece-${index + 1}`,
kind: "convexHull" as const,
points,
localBounds: computeBoundsFromFloat32Points(points)
}));
@@ -634,17 +1055,7 @@ function buildDynamicCollider(
);
}
const localBounds = computeBoundsFromPoints(
pieces.flatMap((piece) => {
const points: Vector3[] = [];
for (let pointIndex = 0; pointIndex < piece.points.length; pointIndex += 3) {
points.push(new Vector3(piece.points[pointIndex], piece.points[pointIndex + 1], piece.points[pointIndex + 2]));
}
return points;
})
);
const localBounds = computeCompoundColliderLocalBounds(pieces);
return {
source: "modelInstance",
@@ -662,6 +1073,54 @@ function buildDynamicCollider(
};
}
function buildStaticSimpleCollider(
modelInstance: ModelInstance,
asset: ModelAssetRecord,
loadedAsset: LoadedModelAsset | undefined
): GeneratedModelCompoundCollider {
if (loadedAsset === undefined) {
throw new ModelColliderGenerationError(
"missing-model-collider-geometry",
`Model instance ${modelInstance.id} cannot build static-simple collision until asset geometry has loaded.`
);
}
const triangleClusters = collectMeshTriangleClusters(loadedAsset.template);
if (triangleClusters.length === 0) {
throw new ModelColliderGenerationError(
"missing-model-collider-geometry",
`Model instance ${modelInstance.id} cannot use static-simple collision because the asset has no mesh triangles.`
);
}
const pieces = triangleClusters.flatMap((cluster) => collectStaticSimpleBoxPieces(cluster.triangles, modelInstance.id));
if (pieces.length === 0) {
throw new ModelColliderGenerationError(
"unsupported-static-simple-model-collider",
`Model instance ${modelInstance.id} could not derive any static-simple box pieces.`
);
}
const localBounds = computeCompoundColliderLocalBounds(pieces);
return {
source: "modelInstance",
instanceId: modelInstance.id,
assetId: asset.id,
mode: "static-simple",
kind: "compound",
visible: modelInstance.collision.visible,
transform: createModelTransform(modelInstance),
pieces,
decomposition: "surface-voxel-boxes",
runtimeBehavior: "fixedQueryOnly",
localBounds,
worldBounds: computeWorldBoundsFromLocalBox(localBounds, createModelTransformMatrix(modelInstance))
};
}
export function buildGeneratedModelCollider(
modelInstance: ModelInstance,
asset: ModelAssetRecord,
@@ -674,6 +1133,8 @@ export function buildGeneratedModelCollider(
return buildSimpleBoxCollider(modelInstance, asset);
case "static":
return buildTriMeshCollider(modelInstance, asset, loadedAsset);
case "static-simple":
return buildStaticSimpleCollider(modelInstance, asset, loadedAsset);
case "terrain":
return buildTerrainCollider(modelInstance, asset, loadedAsset);
case "dynamic":