Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
44 changes: 30 additions & 14 deletions docs/PERFORMANCE_REPORT.md
Original file line number Diff line number Diff line change
@@ -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

Expand All @@ -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

Expand Down
61 changes: 50 additions & 11 deletions src/components/MillScene.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<React.PropsWithChildren> = ({ children }) => {
const groupRef = useRef<THREE.Group>(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 <group ref={groupRef}>{children}</group>;
};
import { MachineData, MachineType } from '../types';
import { useGraphicsStore, isPostProcessingActive } from '../stores/graphicsStore';
import { useProductionStore } from '../stores/productionStore';
Expand Down Expand Up @@ -829,24 +864,28 @@ export const MillScene: React.FC<MillSceneProps> = ({
{!perfDebug?.disableMachines && (
<MachinesContainer initialMachines={displayMachines} onSelect={onSelectMachine} />
)}
{!isLowGraphics && !perfDebug?.disableMachines && (
<ErrorBoundary fallback={null} resetKeys={[graphicsQuality]}>
<Suspense fallback={null}>
<HighDetailSpoutingSystem machines={displayMachines} />
</Suspense>
</ErrorBoundary>
)}
{!isLowGraphics && <ProductionFlowVisualization />}
<DistantFactoryInteriorDetail>
{!isLowGraphics && !perfDebug?.disableMachines && (
<ErrorBoundary fallback={null} resetKeys={[graphicsQuality]}>
<Suspense fallback={null}>
<HighDetailSpoutingSystem machines={displayMachines} />
</Suspense>
</ErrorBoundary>
)}
{!isLowGraphics && <ProductionFlowVisualization />}
</DistantFactoryInteriorDetail>
</group>
<group name="world-factory-infrastructure">
<OptimizedFactoryInfrastructure showZones={showZones} />
</group>

{/* Dynamic Elements - Respect perfDebug toggles */}
<group name="world-conveyors">
{authoredSiteReady && !perfDebug?.disableConveyorSystem && (
<OperationalConveyors productionSpeed={productionSpeed} />
)}
<DistantFactoryInteriorDetail>
{authoredSiteReady && !perfDebug?.disableConveyorSystem && (
<OperationalConveyors productionSpeed={productionSpeed} />
)}
</DistantFactoryInteriorDetail>
</group>
<group name="world-forklifts">
{authoredSiteReady && !perfDebug?.disableForkliftSystem && (
Expand Down
31 changes: 30 additions & 1 deletion src/components/performance/StaticMeshBatch.test.ts
Original file line number Diff line number Diff line change
@@ -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(
Expand Down Expand Up @@ -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']));
});
});
49 changes: 47 additions & 2 deletions src/components/performance/StaticMeshBatch.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down Expand Up @@ -691,14 +735,15 @@ export const StaticMeshBatch: React.FC<StaticMeshBatchProps> = ({
(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)
);
}

Expand Down
32 changes: 32 additions & 0 deletions src/components/performance/distantFactoryInteriorLod.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
22 changes: 22 additions & 0 deletions src/components/performance/distantFactoryInteriorLod.ts
Original file line number Diff line number Diff line change
@@ -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;
}