Moving against a tilemap whose cells block with an outline no longer costs more than a whole frame: an outlined cell is its own polygon, split into convex parts, so a map that is a handful of merged rectangles when built from plain solid tiles is hundreds of solids under one entity - and Move brought every one of them into world coordinates and tested it exactly, including the ones on the far side of the map, which measured 148 ms per Move call on a 20x20 map with 16-corner outlines (34 ms at 8 corners, 0.4 ms with plain solid tiles), matching the report that standing still holds 60 fps while anything that moves stutters; the fix is a broad phase in two stages that decides nothing and only leaves out what cannot be reached - a move never ends further from its start than it is long, because sliding always shortens the rest, so every collider of the mover gets a rectangle grown by the move length and an obstacle whose rectangle does not meet it is never tested, a ray taking the rectangle around itself, and that rectangle is carried back into the map's own units with invert(m) and turned into cell coordinates (cellsWithin), a rotated map through its own axes, so a far cell is not brought into world form in the first place - solidShapesOf was O(cells) per call, which was the second cost block and the reason the 60x60 case still grew; CollisionSystem.overlaps takes the same range, so the editor run asks a map only for the cells within reach of the other side each frame, two maps narrowing nothing, and a Move obstacle now falls away at build time when its layer does not meet the mover's (#152) instead of inside the loop; the measurements after: 148 -> 0.26 ms, 34 -> 0.27 ms, 60x60 20 -> 3.5 ms, plain tiles unchanged; this is no regression of the recent commits - the same benchmark in worktrees against 947a6b1 (#141), 6a31f5e, 4f55f85 and ae3aa7d gives the same 85-148 ms, the cost has been there since outlined cells arrived with #141; new pure functions shapeBounds/grownBounds/boundsMeet in collider.ts and cellsWithin/cellsMeet in tilemapCollision.ts, 11 new unit tests in tests/core/broadphase.test.ts (the rectangles of all four shapes including a rotated box measuring its diagonal reach, touching counts as meeting, cellsWithin straight and turned by 90 degrees, a map handing out 400 cells without a range and under 10 with one, an 800 unit move from far away still stopping at the first cell, a rotated map blocking where its one painted cell was turned to, a ray across the whole map, and the editor run seeing a collider on a far cell), and four counter-checks hold: dropping the cell filter fails the counting test, padding without the move length fails 4 move tests, ignoring the rotation in cellsWithin fails 3, and a wrong range in the detection fails the touching test; one blunt test on the way - the first rotated map was painted on every cell, where even a wrongly computed cell range blocks and the counter-check stayed green, so exactly one cell is painted now and the test measures where it turned to; no e2e check and the check sum stays 1157, because a timing check in the browser would flutter under CI load while modules 40 and 42 cover the correctness anyway, the speed evidence being the node benchmark (#184)
game-maker / gate (push) Successful in 2m4s
game-maker / suite (push) Successful in 7m21s

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
adminandClaude Opus 5 committed 2026-09-20 19:25:53 +02:00
1 parent ffdf0a8ddb
commit eac50e4194
5 files changed
+394 -14

No files matched your search

+7 -2
View File
@@ -5,6 +5,7 @@ import type { World } from '../World';
import type { Vec2 } from '../../core/model/Properties';
import {
bodyOf,
boundsAround,
type Contact,
type CollisionPair,
type CollisionSource,
@@ -67,8 +68,12 @@ export class CollisionSystem implements System, CollisionSource {
/** Live test of two bodies (world positions applied): any shape of one over any of the other. */
private overlaps(world: World, a: Entity, b: Entity): boolean {
const others = this.solids.shapesOf(world, b);
return this.solids.shapesOf(world, a).some((sa) => others.some((sb) => shapesOverlap(sa, sb)));
// A map takes part with a shape per cell, so only the cells within reach of the other side are
// worked out at all (#184); between two maps neither narrows the other down.
if (a.type === 'tilemap' && b.type !== 'tilemap') return this.overlaps(world, b, a);
const mine = this.solids.shapesOf(world, a);
const others = this.solids.shapesOf(world, b, a.type === 'tilemap' ? undefined : boundsAround(mine));
return mine.some((sa) => others.some((sb) => shapesOverlap(sa, sb)));
}
/** Whether any collider of `a` overlaps any collider of `b` (see `partsOf`). */
+65 -9
View File
@@ -8,6 +8,8 @@ import {
cellPolygonShape,
type CellRect,
cellRectShape,
cellsMeet,
cellsWithin,
oneWayAngles,
solidPolygons,
solidRects,
@@ -20,10 +22,12 @@ import {
maskHas,
} from '../../core/model/collisionLayers';
import {
boundsMeet,
type ColliderShape,
colliderShape,
contactBetween,
exitDistance,
grownBounds,
isTrigger,
landsOn,
movedShape,
@@ -31,6 +35,8 @@ import {
oneWayFacing,
type OneWaySide,
overlapDepth,
shapeBounds,
type ShapeBounds,
shapeExtent,
shapesOverlap,
} from '../../core/properties/collider';
@@ -47,6 +53,8 @@ const MAX_PASSES = 4;
const DEEPER = 1e-9;
/** A normal this close to square with an axis does not stand against a move along it. */
const SQUARE = 1e-6;
/** What the broad phase adds to the reach of a move, so rounding never drops a shape (#184). */
const REACH_SLACK = 1e-6;
/**
* Two bodies that overlap (or overlapped) – always the same pair order per key. Either side is a
@@ -217,15 +225,23 @@ export class Solids {
* top (#155) within their tile's angle (#159). A tilemap placed before the first tick is worked out
* on the spot.
*/
solidShapesOf(world: World, body: Entity): SolidShape[] {
solidShapesOf(world: World, body: Entity, within?: ShapeBounds): SolidShape[] {
const m = worldMatrixOf(body);
if (body.type !== 'tilemap')
return [{ shape: colliderShape(body.props, m), facing: oneWayFacing(body.props, m) }];
const { rects, polygons, oneWay } = this.blocksOf(world, body);
const up = oneWayFacing({ oneWay: true }, m);
// A map is many solids under one entity, so the cells out of reach are dropped before any of
// them is worked out in world units at all (#184).
const range = within ? cellsWithin(body.props, m, within) : null;
const near = (x: number, y: number, w?: number, h?: number) => !range || cellsMeet(range, x, y, w, h);
const shapes = (cells: CellRect[], outlines: CellPolygon[], facing: OneWaySide | null) => [
...cells.map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing })),
...outlines.map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing })),
...cells
.filter((rect) => near(rect.x, rect.y, rect.w, rect.h))
.map((rect) => ({ shape: cellRectShape(body.props, rect, m), facing })),
...outlines
.filter((polygon) => near(polygon.x, polygon.y))
.map((polygon) => ({ shape: cellPolygonShape(body.props, polygon, m), facing })),
];
return [
...shapes(rects, polygons, null),
@@ -235,8 +251,8 @@ export class Solids {
];
}
shapesOf(world: World, body: Entity): ColliderShape[] {
return this.solidShapesOf(world, body).map(({ shape }) => shape);
shapesOf(world: World, body: Entity, within?: ShapeBounds): ColliderShape[] {
return this.solidShapesOf(world, body, within).map(({ shape }) => shape);
}
/**
@@ -247,9 +263,10 @@ export class Solids {
raycast(world: World, from: Vec2, direction: Vec2, length: number, filter: RayFilter): RayResult | null {
const d = unitOf(direction);
if ((d.x === 0 && d.y === 0) || !(length > 0)) return null;
const area = rayBounds(from, d, length);
let best: RayResult | null = null;
for (const body of this.rayBodies(world, filter)) {
for (const { shape, facing } of this.solidShapesOf(world, body)) {
for (const { shape, facing } of this.solidShapesOf(world, body, area)) {
const hit = rayCast(shape, from, d, best?.distance ?? length);
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
if (best && hit.distance >= best.distance) continue;
@@ -275,13 +292,14 @@ export class Solids {
): RayResult[] {
const d = unitOf(direction);
if ((d.x === 0 && d.y === 0) || !(length > 0)) return [];
const area = rayBounds(from, d, length);
const hits: RayResult[] = [];
for (const body of this.rayBodies(world, filter)) {
// One solid shape is one hit, except for a collider: its pieces are one shape to the world,
// so only the nearest of them counts (a polygon in three parts is not three enemies).
const perShape = body.type === 'tilemap';
let nearest: RayResult | null = null;
for (const { shape, facing } of this.solidShapesOf(world, body)) {
for (const { shape, facing } of this.solidShapesOf(world, body, area)) {
const hit = rayCast(shape, from, d, length);
if (!hit || (facing && !landsOn(hit.normal, facing))) continue;
const result = this.rayResult(body, from, d, hit);
@@ -341,12 +359,21 @@ export class Solids {
entity.y += dy;
return NOT_BLOCKED;
}
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole move.
// Nothing that blocks moves while this one does, so its shapes are taken once for the whole
// move. Only what the move can reach is kept: sliding never lengthens what is left, so the
// mover never gets farther from where it starts than the whole move is long (#184).
const reach = own.map((a) =>
grownBounds(shapeBounds(shapeOf(a.collider)), Math.hypot(wanted.x, wanted.y) + REACH_SLACK),
);
const area = reach.reduce(unionBounds);
const obstacles: Obstacle[] = world.entities
.filter((c) => blocksMover(c, entity))
.flatMap((body) => {
const filter = collisionFilterOf(body.props);
return this.solidShapesOf(world, body).map((solid) => ({ ...solid, body, filter }));
if (!own.some((a) => layersMeet(a.filter, filter))) return [];
return this.solidShapesOf(world, body, area)
.filter((solid) => reach.some((near) => boundsMeet(near, shapeBounds(solid.shape))))
.map((solid) => ({ ...solid, body, filter }));
});
// A one-way solid the mover is inside already, or told to fall through, lets it pass for the
// whole move: it is on its way through, from below or from the side.
@@ -491,6 +518,35 @@ function frictionBetween(own: Entity, obstacle: Entity): number {
return (of(own) + of(obstacle)) / 2;
}
/** The rectangle around every shape, or null without one – nothing to narrow anything down with. */
export function boundsAround(shapes: readonly ColliderShape[]): ShapeBounds | undefined {
return shapes.length > 0 ? shapes.map(shapeBounds).reduce(unionBounds) : undefined;
}
/** The rectangle around both rectangles. */
function unionBounds(a: ShapeBounds, b: ShapeBounds): ShapeBounds {
return {
left: Math.min(a.left, b.left),
top: Math.min(a.top, b.top),
right: Math.max(a.right, b.right),
bottom: Math.max(a.bottom, b.bottom),
};
}
/** The rectangle around a ray, so a map only works out the cells it runs through (#184). */
function rayBounds(from: Vec2, d: Vec2, length: number): ShapeBounds {
const to = { x: from.x + d.x * length, y: from.y + d.y * length };
return grownBounds(
{
left: Math.min(from.x, to.x),
top: Math.min(from.y, to.y),
right: Math.max(from.x, to.x),
bottom: Math.max(from.y, to.y),
},
REACH_SLACK,
);
}
function unitOf(v: Vec2): Vec2 {
const length = Math.hypot(v.x, v.y);
return length > 0 ? { x: v.x / length, y: v.y / length } : v;