fix(simulation): standardize streamed rendering and UGV proxies

This commit is contained in:
DCCONSTRUCTIONS
2026-08-29 17:39:40 +03:00
parent 76694cc869
commit 856b61be99
3 changed files with 41 additions and 8 deletions
@@ -118,6 +118,9 @@ export class PlayCanvasRuntime implements SimulationRuntime {
private renderIntervalMs = 1000 / 60;
private renderAccumulatorMs = 0;
private disposed = false;
private requestGsplatFrame = (): void => {
this.requestRender();
};
private updateFrame = (deltaSeconds: number): void => {
if (!this.app || !this.camera || this.disposed) return;
this.ugvController?.update(deltaSeconds);
@@ -158,6 +161,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
this.app = app;
app.autoRender = false;
app.on("update", this.updateFrame);
app.systems.gsplat?.on("frame:request", this.requestGsplatFrame);
app.setCanvasFillMode(FILLMODE_NONE, 1, 1);
app.setCanvasResolution(RESOLUTION_AUTO);
app.scene.ambientLight = new Color(0.35, 0.37, 0.42);
@@ -379,6 +383,7 @@ export class PlayCanvasRuntime implements SimulationRuntime {
if (this.app) {
this.app.autoRender = false;
this.app.renderNextFrame = false;
this.app.systems.gsplat?.off("frame:request", this.requestGsplatFrame);
this.app.off("update", this.updateFrame);
this.app.destroy();
}
@@ -232,7 +232,6 @@ export class SimulationUgvController {
private active = false;
private vehicleEntity: Entity | null = null;
private vehicle: NativeRaycastVehicle | null = null;
private vehicleTuning: NativeObject | null = null;
private vehicleRaycaster: NativeObject | null = null;
private tyreImpulseNative: NativeVector3 | null = null;
private tyreRelativePositionNative: NativeVector3 | null = null;
@@ -599,7 +598,12 @@ export class SimulationUgvController {
this.app,
meshInstance.mesh,
new Mat4().mul2(rootInverse, meshInstance.node.getWorldTransform()),
targetRatio,
Math.max(
1,
Math.floor(
Math.floor((meshInstance.mesh.primitive[0]?.count ?? 0) / 3) * targetRatio,
),
),
);
if (!physicsMesh) continue;
const entity = new Entity(`UGV physics proxy ${index + 1}`);
@@ -745,7 +749,6 @@ export class SimulationUgvController {
dynamicsWorld.addAction(nativeVehicle);
this.vehicleEntity = vehicle;
this.vehicleTuning = tuning;
this.vehicleRaycaster = raycaster;
this.vehicle = nativeVehicle;
this.dynamicsWorld = dynamicsWorld;
@@ -861,12 +864,10 @@ export class SimulationUgvController {
}
if (this.vehicle) runCleanup("destroy vehicle", () => this.ammo.destroy(this.vehicle as NativeObject));
if (this.vehicleRaycaster) runCleanup("destroy vehicle raycaster", () => this.ammo.destroy(this.vehicleRaycaster as NativeObject));
if (this.vehicleTuning) runCleanup("destroy vehicle tuning", () => this.ammo.destroy(this.vehicleTuning as NativeObject));
if (this.tyreImpulseNative) runCleanup("destroy tyre impulse vector", () => this.ammo.destroy(this.tyreImpulseNative as NativeObject));
if (this.tyreRelativePositionNative) runCleanup("destroy tyre relative-position vector", () => this.ammo.destroy(this.tyreRelativePositionNative as NativeObject));
this.vehicle = null;
this.vehicleRaycaster = null;
this.vehicleTuning = null;
this.tyreImpulseNative = null;
this.tyreRelativePositionNative = null;
this.dynamicsWorld = null;
@@ -1005,7 +1006,7 @@ function createPhysicsProxyMesh(
app: Application,
source: Mesh,
localTransform: Mat4,
targetRatio: number,
targetTriangleCount: number,
): Mesh | null {
const primitive = source.primitive[0];
const sourceVertexCount = source.vertexBuffer?.numVertices ?? 0;
@@ -1039,8 +1040,11 @@ function createPhysicsProxyMesh(
if (triangleIndexCount < 3) return null;
if (triangleIndexCount !== indices.length) indices = indices.slice(0, triangleIndexCount);
if (targetRatio < 1) {
const targetIndexCount = Math.max(3, Math.floor(indices.length * targetRatio / 3) * 3);
const targetIndexCount = Math.max(
3,
Math.min(indices.length, Math.floor(targetTriangleCount) * 3),
);
if (indices.length > targetIndexCount) {
const [simplifiedIndices] = MeshoptSimplifier.simplify(
indices,
transformed,
@@ -1049,6 +1053,26 @@ function createPhysicsProxyMesh(
PHYSICS_PROXY_ERROR,
);
indices = new Uint32Array(simplifiedIndices);
if (indices.length > targetIndexCount) {
// Topologically noisy scanner meshes can make the quality simplifier stop
// above its requested target. Ammo must never receive that unbounded result:
// the spatial fallback preserves the surface envelope while enforcing the
// same deterministic physics budget for every imported location.
const [boundedIndices] = MeshoptSimplifier.simplifySloppy(
indices,
transformed,
3,
null,
targetIndexCount,
1,
);
indices = new Uint32Array(boundedIndices);
}
if (indices.length > targetIndexCount) {
throw new Error(
`Physics proxy превысил лимит: ${Math.floor(indices.length / 3)} > ${Math.floor(targetIndexCount / 3)} треугольников.`,
);
}
}
const compactedIndices = new Uint32Array(indices);
@@ -100,6 +100,8 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
assert.match(runtime, /maximum: \{[^}]*lodRangeMin: 0, lodRangeMax: 5, pixelRatio: 2, splatBudget: 4_000_000, targetFps: 60/);
assert.match(runtime, /app\.scene\.gsplat\.splatBudget = profile\.splatBudget/);
assert.match(runtime, /app\.autoRender = false/);
assert.match(runtime, /app\.systems\.gsplat\?\.on\("frame:request", this\.requestGsplatFrame\)/);
assert.match(runtime, /app\.systems\.gsplat\?\.off\("frame:request", this\.requestGsplatFrame\)/);
assert.match(runtime, /app\.on\("update", this\.updateFrame\)/);
assert.match(runtime, /if \(!cameraChanged && this\.controlMode === "free"\) return/);
assert.doesNotMatch(runtime, /app\.on\("frameupdate"/);
@@ -162,6 +164,8 @@ test("PlayCanvas owns the realtime scene graph without an iframe or React entity
assert.match(ugv, /new this\.ammo\.btRaycastVehicle/);
assert.match(ugv, /type: "mesh"/);
assert.match(ugv, /MeshoptSimplifier\.simplify/);
assert.match(ugv, /MeshoptSimplifier\.simplifySloppy/);
assert.match(ugv, /Physics proxy превысил лимит/);
assert.match(ugv, /PHYSICS_PROXY_MAX_TRIANGLES = 240_000/);
assert.match(ugv, /createPhysicsProxyMesh/);
assert.match(ugv, /findSafeSpawnPosition/);