diff --git a/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts b/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts index 327fe72..52f1bb3 100644 --- a/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts +++ b/apps/control-station/src/components/simulation/PlayCanvasRuntime.ts @@ -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(); } diff --git a/apps/control-station/src/components/simulation/SimulationUgvController.ts b/apps/control-station/src/components/simulation/SimulationUgvController.ts index 9ba310d..d3b5553 100644 --- a/apps/control-station/src/components/simulation/SimulationUgvController.ts +++ b/apps/control-station/src/components/simulation/SimulationUgvController.ts @@ -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); diff --git a/apps/control-station/test/simulationWorkspace.test.mjs b/apps/control-station/test/simulationWorkspace.test.mjs index f4176cd..63dee87 100644 --- a/apps/control-station/test/simulationWorkspace.test.mjs +++ b/apps/control-station/test/simulationWorkspace.test.mjs @@ -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/);