diff --git a/docs/PERFORMANCE_REPORT.md b/docs/PERFORMANCE_REPORT.md index 43fc990..5da1753 100644 --- a/docs/PERFORMANCE_REPORT.md +++ b/docs/PERFORMANCE_REPORT.md @@ -1,20 +1,36 @@ # MillOS v0.40 Performance Report -**Baseline commit:** `dd414cf53fcb648908a5227cae7ac8d7a62c413d` +**Source baseline:** `0235c8985e67e91b7c4f9a90d1d696e9e8689d70` **Measured:** 2026-08-10 -**Status:** Green on the versioned Vite preview baseline +**Status:** Green on the targeted local v0.40 preview and accepted full-view baseline ## Current acceptance baseline -| View | Average FPS | p95 frame time | Draw calls | Result | -|---|---:|---:|---:|---| -| Overview | 84.0 | 13.8 ms | 1,245 | Pass | -| Interior | 107.2 | 10.8 ms | 822 | Pass | -| Shipping | 89.2 | 13.0 ms | 1,070 | Pass | -| Receiving | 92.5 | 12.1 ms | 1,021 | Pass | -| Water | 75.3 | 14.8 ms | 1,407 | Pass | +| View | Average FPS | p95 frame time | Draw calls | Evidence | Result | +|---|---:|---:|---:|---|---| +| Overview | 78.0 | 15.0 ms | 1,232 | Current candidate | Pass | +| Interior | 101.9 | 11.5 ms | 822 | Accepted source baseline | Pass | +| Shipping | 85.4 | 13.6 ms | 1,078 | Accepted source baseline | Pass | +| Receiving | 91.4 | 12.2 ms | 1,021 | Accepted source baseline | Pass | +| Water | 71.8 | 15.8 ms | 1,269 | Current candidate | Pass | -All five views remain above 60 FPS and below the 16.7 ms p95 frame budget. The water view is the next draw-call target, despite meeting the frame-time budget. +All five views remain above 60 FPS and below the 16.7 ms p95 frame budget. The +water view dropped from 1,426 calls on the accepted source baseline to 1,269, +an 11.0 percent reduction. The factory machine bodies remain mounted and +visible through the windows. Only distant spouting, flow effects, and conveyor +detail are culled beyond 200 metres, with a 190-metre return threshold to avoid +visibility chatter. + +Static exterior batching now orders compatible candidates before bounded +startup chunking. Two independent scene loads produced the same 2,126 +candidates, 1,945 optimized originals, and 192 batches. This removes the lazy +module resolution order from steady-state batching results. + +Interior, shipping, and receiving retain their last accepted source-baseline +samples in this report. Their declared camera positions are inside the +190-metre detail return threshold. Browser acceptance on the current candidate +passed separately; a host-saturated aggregate sample was excluded rather than +presented as comparable frame-time evidence. ## Startup @@ -28,18 +44,18 @@ These measurements use the versioned Vite preview path. A bare Python static ser ## Delivery - Initial JavaScript: 0.42 MiB gzip across five files. -- Production build: 3,587 transformed modules. +- Production build: 3,588 transformed modules. - Physics, WebGPU, SCADA, charts, and post-processing remain deferred chunks. - The service worker isolates caches by deployment scope and build identity. - Historical release payload size is tracked separately from current v0.40 startup transfer. ## Current optimization priorities -1. Reduce the water-view draw calls by at least 10 percent without changing its authored appearance. -2. Preserve or improve each view's p95 frame time while integrating visible geometry changes. +1. Keep the water view at or below 1,283 draw calls while preserving the authored machine silhouettes. +2. Preserve each view's p95 frame time while integrating visible geometry changes. 3. Keep native first useful frame at or below 350 ms and Fast 3G at or below 3.2 seconds. 4. Reject shader cache keys containing time, randomness, or other per-frame values. -5. Measure runtime, effective DPR, and visual output after every geometry or shader wave. +5. Measure runtime, effective DPR, static-batch diagnostics, and visual output after every geometry or shader wave. ## Required commands diff --git a/src/components/MillScene.tsx b/src/components/MillScene.tsx index d7839df..f0a9c6d 100644 --- a/src/components/MillScene.tsx +++ b/src/components/MillScene.tsx @@ -19,6 +19,10 @@ import { useAIConfigStore } from '../stores/aiConfigStore'; import { recoverableLazy } from '../utils/recoverableLazy'; import ErrorBoundary from './ErrorBoundary'; import { StaticMeshBatch } from './performance/StaticMeshBatch'; +import { + DISTANT_FACTORY_INTERIOR_LOD, + resolveFactoryInteriorDetailVisibility, +} from './performance/distantFactoryInteriorLod'; // Lazy load heavy optional layers while preserving the complete authored world. // Quality changes may reduce effects and geometry density, but never swap the @@ -79,6 +83,37 @@ const OperationalWorldSignals = recoverableLazy(() => default: module.OperationalWorldSignals, })) ); + +/** + * Keep the unified factory mounted while omitting sub-pixel service detail + * behind the distant shell and glazing. Machine bodies remain visible through + * the windows. The hysteresis avoids visibility chatter near the boundary, and + * the named contract groups remain visible so this is a conventional detail + * LOD rather than an alternate world. + */ +const DistantFactoryInteriorDetail: React.FC = ({ children }) => { + const groupRef = useRef(null); + const visibleRef = useRef(true); + const sampleFrameRef = useRef(0); + + useFrame(({ camera }) => { + sampleFrameRef.current = + (sampleFrameRef.current + 1) % DISTANT_FACTORY_INTERIOR_LOD.sampleEveryFrames; + if (sampleFrameRef.current !== 0) return; + + const nextVisible = resolveFactoryInteriorDetailVisibility( + visibleRef.current, + camera.position.x, + camera.position.z + ); + if (nextVisible === visibleRef.current) return; + + visibleRef.current = nextVisible; + if (groupRef.current) groupRef.current.visible = nextVisible; + }); + + return {children}; +}; import { MachineData, MachineType } from '../types'; import { useGraphicsStore, isPostProcessingActive } from '../stores/graphicsStore'; import { useProductionStore } from '../stores/productionStore'; @@ -829,14 +864,16 @@ export const MillScene: React.FC = ({ {!perfDebug?.disableMachines && ( )} - {!isLowGraphics && !perfDebug?.disableMachines && ( - - - - - - )} - {!isLowGraphics && } + + {!isLowGraphics && !perfDebug?.disableMachines && ( + + + + + + )} + {!isLowGraphics && } + @@ -844,9 +881,11 @@ export const MillScene: React.FC = ({ {/* Dynamic Elements - Respect perfDebug toggles */} - {authoredSiteReady && !perfDebug?.disableConveyorSystem && ( - - )} + + {authoredSiteReady && !perfDebug?.disableConveyorSystem && ( + + )} + {authoredSiteReady && !perfDebug?.disableForkliftSystem && ( diff --git a/src/components/performance/StaticMeshBatch.test.ts b/src/components/performance/StaticMeshBatch.test.ts index a1c7aa7..f0aad86 100644 --- a/src/components/performance/StaticMeshBatch.test.ts +++ b/src/components/performance/StaticMeshBatch.test.ts @@ -1,6 +1,10 @@ import { describe, expect, it } from 'vitest'; import * as THREE from 'three'; -import { collectStaticBatchCandidates, createStaticMeshBatches } from './StaticMeshBatch'; +import { + collectStaticBatchCandidates, + createStaticMeshBatches, + orderStaticBatchCandidatesForChunking, +} from './StaticMeshBatch'; const makeBox = (x: number, color: string = '#778899'): THREE.Mesh => { const mesh = new THREE.Mesh( @@ -221,4 +225,29 @@ describe('StaticMeshBatch', () => { instancedBatches: 2, }); }); + + it('orders lazy traversal results by batching affinity before startup slicing', () => { + const root = new THREE.Group(); + const standardLeft = makeBox(1); + const basicLeft = new THREE.Mesh( + new THREE.BoxGeometry(1, 2, 3), + new THREE.MeshBasicMaterial({ color: '#778899' }) + ); + basicLeft.position.x = 2; + const standardRight = makeBox(3); + const basicRight = new THREE.Mesh( + new THREE.BoxGeometry(1, 2, 3), + new THREE.MeshBasicMaterial({ color: '#778899' }) + ); + basicRight.position.x = 4; + root.add(standardLeft, basicLeft, standardRight, basicRight); + + const candidates = collectStaticBatchCandidates(root); + const ordered = orderStaticBatchCandidatesForChunking(root, candidates); + const materialTypes = ordered.map(({ mesh }) => (mesh.material as THREE.Material).type); + + expect(materialTypes[0]).toBe(materialTypes[1]); + expect(materialTypes[2]).toBe(materialTypes[3]); + expect(new Set(materialTypes)).toEqual(new Set(['MeshBasicMaterial', 'MeshStandardMaterial'])); + }); }); diff --git a/src/components/performance/StaticMeshBatch.tsx b/src/components/performance/StaticMeshBatch.tsx index c2ba45f..48b0e76 100644 --- a/src/components/performance/StaticMeshBatch.tsx +++ b/src/components/performance/StaticMeshBatch.tsx @@ -384,6 +384,50 @@ const finalizeCandidateCollection = ( return collection.candidates; }; +/** + * Keep compatible candidates adjacent before bounded startup chunking. Lazy + * module resolution can change scene-traversal order without changing the + * authored world; slicing that incidental order allowed a compatible group to + * straddle a 512-candidate boundary and made the final draw-call count vary. + * + * Spatial cell and render-state fields remain ahead of material affinity so + * the existing culling and compatibility contracts are unchanged. Sorting a + * copy also preserves the collection order used by diagnostics and callers. + */ +export const orderStaticBatchCandidatesForChunking = ( + root: THREE.Group, + candidates: readonly BatchCandidate[] +): BatchCandidate[] => { + const inverseRoot = root.matrixWorld.clone().invert(); + const relativeMatrix = new THREE.Matrix4(); + const relativePosition = new THREE.Vector3(); + const keyed = candidates.map((candidate) => { + relativeMatrix.multiplyMatrices(inverseRoot, candidate.matrixWorld); + relativePosition.setFromMatrixPosition(relativeMatrix); + const cellX = Math.floor(relativePosition.x / MERGE_CELL_SIZE_METRES); + const cellZ = Math.floor(relativePosition.z / MERGE_CELL_SIZE_METRES); + const { mesh } = candidate; + return { + candidate, + key: [ + cellX, + cellZ, + mesh.castShadow, + mesh.receiveShadow, + mesh.renderOrder, + mesh.layers.mask, + candidate.mergeMaterialSignature, + candidate.geometryAttributeSignature, + candidate.batchMaterialSignature, + candidate.geometrySignature, + ].join('||'), + }; + }); + + keyed.sort((left, right) => left.key.localeCompare(right.key)); + return keyed.map(({ candidate }) => candidate); +}; + export const collectStaticBatchCandidates = (root: THREE.Group): BatchCandidate[] => { root.updateWorldMatrix(true, true); const collection = createCandidateCollection(); @@ -691,14 +735,15 @@ export const StaticMeshBatch: React.FC = ({ (candidates) => { sampleTimer = window.setTimeout(() => { if (cancelled) return; + const orderedCandidates = orderStaticBatchCandidatesForChunking(root, candidates); const candidateChunks: BatchCandidate[][] = []; for ( let start = 0; - start < candidates.length; + start < orderedCandidates.length; start += MAX_BATCH_CANDIDATES_PER_TASK ) { candidateChunks.push( - candidates.slice(start, start + MAX_BATCH_CANDIDATES_PER_TASK) + orderedCandidates.slice(start, start + MAX_BATCH_CANDIDATES_PER_TASK) ); } diff --git a/src/components/performance/distantFactoryInteriorLod.test.ts b/src/components/performance/distantFactoryInteriorLod.test.ts new file mode 100644 index 0000000..7311c5a --- /dev/null +++ b/src/components/performance/distantFactoryInteriorLod.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from 'vitest'; +import { + DISTANT_FACTORY_INTERIOR_LOD, + resolveFactoryInteriorDetailVisibility, +} from './distantFactoryInteriorLod'; + +describe('distant factory interior detail LOD', () => { + it('keeps factory detail visible for operational and overview cameras', () => { + expect(resolveFactoryInteriorDetailVisibility(true, 36, 32)).toBe(true); + expect(resolveFactoryInteriorDetailVisibility(true, 112, 112)).toBe(true); + }); + + it('hides factory detail for the distant water and farm evidence cameras', () => { + expect(resolveFactoryInteriorDetailVisibility(true, 158, 154)).toBe(false); + expect(resolveFactoryInteriorDetailVisibility(true, 128, 174)).toBe(false); + }); + + it('uses hysteresis to avoid visibility chatter at the boundary', () => { + const midpoint = + (DISTANT_FACTORY_INTERIOR_LOD.hideDistance + DISTANT_FACTORY_INTERIOR_LOD.showDistance) / 2; + + expect(resolveFactoryInteriorDetailVisibility(true, midpoint, 0)).toBe(true); + expect(resolveFactoryInteriorDetailVisibility(false, midpoint, 0)).toBe(false); + expect( + resolveFactoryInteriorDetailVisibility( + false, + DISTANT_FACTORY_INTERIOR_LOD.showDistance - 1, + 0 + ) + ).toBe(true); + }); +}); diff --git a/src/components/performance/distantFactoryInteriorLod.ts b/src/components/performance/distantFactoryInteriorLod.ts new file mode 100644 index 0000000..c299ad7 --- /dev/null +++ b/src/components/performance/distantFactoryInteriorLod.ts @@ -0,0 +1,22 @@ +export const DISTANT_FACTORY_INTERIOR_LOD = { + hideDistance: 200, + showDistance: 190, + sampleEveryFrames: 12, +} as const; + +/** + * Resolve distant factory-detail visibility with a ten-metre hysteresis band. + * Distances use the factory-centred world X/Z plane because camera height does + * not affect whether the shell occludes interior process detail. + */ +export function resolveFactoryInteriorDetailVisibility( + currentlyVisible: boolean, + cameraX: number, + cameraZ: number +): boolean { + const threshold = currentlyVisible + ? DISTANT_FACTORY_INTERIOR_LOD.hideDistance + : DISTANT_FACTORY_INTERIOR_LOD.showDistance; + + return cameraX ** 2 + cameraZ ** 2 < threshold ** 2; +}