Complete V1 performance and OOM release gates
This commit is contained in:
210
web/protocol/geometry-stream.ts
Normal file
210
web/protocol/geometry-stream.ts
Normal file
@@ -0,0 +1,210 @@
|
||||
export const MESH_GEOMETRY_STREAM_SCHEMA = 1 as const;
|
||||
export const MESH_GEOMETRY_STREAM_MAX_TRIANGLES = 20_000_000;
|
||||
export const MESH_GEOMETRY_STREAM_MAX_CHUNK_TRIANGLES = 500_000;
|
||||
export const MESH_GEOMETRY_STREAM_MAX_LOD_TRIANGLES = 100_000;
|
||||
|
||||
export interface MeshGeometryStreamRequestIR {
|
||||
schemaVersion: typeof MESH_GEOMETRY_STREAM_SCHEMA;
|
||||
streamId: string;
|
||||
meshId: string;
|
||||
triangleCount: number;
|
||||
chunkTriangleCount: number;
|
||||
lodTriangleCount: number;
|
||||
}
|
||||
|
||||
export interface MeshGeometryStreamChunkIR {
|
||||
schemaVersion: typeof MESH_GEOMETRY_STREAM_SCHEMA;
|
||||
streamId: string;
|
||||
meshId: string;
|
||||
chunkIndex: number;
|
||||
chunkCount: number;
|
||||
triangleOffset: number;
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
byteLength: number;
|
||||
positions: ArrayBuffer;
|
||||
indices: ArrayBuffer;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface MeshGeometryStreamChunkRecordIR {
|
||||
chunkIndex: number;
|
||||
triangleOffset: number;
|
||||
triangleCount: number;
|
||||
vertexCount: number;
|
||||
byteLength: number;
|
||||
sha256: string;
|
||||
}
|
||||
|
||||
export interface MeshGeometryStreamReportIR {
|
||||
streamId: string;
|
||||
meshId: string;
|
||||
status: "COMPLETED" | "CANCELLED";
|
||||
triangleCount: number;
|
||||
chunkCount: number;
|
||||
transferredBytes: number;
|
||||
peakWorkingSetBytes: number;
|
||||
manifestSha256?: string;
|
||||
}
|
||||
|
||||
export type MeshGeometryStreamWorkerRequest =
|
||||
| { type: "start"; request: MeshGeometryStreamRequestIR }
|
||||
| { type: "ack"; streamId: string; chunkIndex: number }
|
||||
| { type: "cancel"; streamId: string };
|
||||
|
||||
export type MeshGeometryStreamWorkerResponse =
|
||||
| { type: "lod"; streamId: string; geometry: MeshGeometryStreamChunkIR }
|
||||
| { type: "chunk"; streamId: string; chunk: MeshGeometryStreamChunkIR }
|
||||
| { type: "detached"; streamId: string; chunkIndex: number; positionsByteLength: number; indicesByteLength: number }
|
||||
| { type: "complete"; report: MeshGeometryStreamReportIR; chunks: MeshGeometryStreamChunkRecordIR[] }
|
||||
| { type: "cancelled"; report: MeshGeometryStreamReportIR }
|
||||
| { type: "error"; streamId?: string; code: string; message: string };
|
||||
|
||||
export class MeshGeometryStreamError extends Error {
|
||||
constructor(readonly code: "GEOMETRY_STREAM_INVALID" | "GEOMETRY_STREAM_RANGE_INVALID" | "GEOMETRY_STREAM_BUDGET_EXCEEDED", message: string) {
|
||||
super(`${code}: ${message}`);
|
||||
this.name = "MeshGeometryStreamError";
|
||||
}
|
||||
}
|
||||
|
||||
function record(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function boundedInteger(value: unknown, name: string, minimum: number, maximum: number): number {
|
||||
if (!Number.isSafeInteger(value) || (value as number) < minimum || (value as number) > maximum) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", `${name} must be an integer in [${minimum}, ${maximum}]`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function identifier(value: unknown, name: string): string {
|
||||
if (typeof value !== "string" || !/^[A-Za-z0-9:._-]{1,128}$/.test(value)) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", `${name} is invalid`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
export function parseMeshGeometryStreamRequest(value: unknown): MeshGeometryStreamRequestIR {
|
||||
if (!record(value) || value.schemaVersion !== MESH_GEOMETRY_STREAM_SCHEMA) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "unsupported stream request schema");
|
||||
}
|
||||
const request: MeshGeometryStreamRequestIR = {
|
||||
schemaVersion: MESH_GEOMETRY_STREAM_SCHEMA,
|
||||
streamId: identifier(value.streamId, "streamId"),
|
||||
meshId: identifier(value.meshId, "meshId"),
|
||||
triangleCount: boundedInteger(value.triangleCount, "triangleCount", 1, MESH_GEOMETRY_STREAM_MAX_TRIANGLES),
|
||||
chunkTriangleCount: boundedInteger(value.chunkTriangleCount, "chunkTriangleCount", 1, MESH_GEOMETRY_STREAM_MAX_CHUNK_TRIANGLES),
|
||||
lodTriangleCount: boundedInteger(value.lodTriangleCount, "lodTriangleCount", 1, MESH_GEOMETRY_STREAM_MAX_LOD_TRIANGLES),
|
||||
};
|
||||
if (request.lodTriangleCount > request.triangleCount) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_INVALID", "lodTriangleCount exceeds triangleCount");
|
||||
}
|
||||
return request;
|
||||
}
|
||||
|
||||
function hex(bytes: ArrayBuffer): string {
|
||||
return Array.from(new Uint8Array(bytes), (value) => value.toString(16).padStart(2, "0")).join("");
|
||||
}
|
||||
|
||||
async function sha256(value: BufferSource): Promise<string> {
|
||||
return hex(await crypto.subtle.digest("SHA-256", value));
|
||||
}
|
||||
|
||||
export async function meshGeometryStreamChunkSha256(chunk: Omit<MeshGeometryStreamChunkIR, "sha256">): Promise<string> {
|
||||
const metadata = JSON.stringify([
|
||||
chunk.schemaVersion,
|
||||
chunk.streamId,
|
||||
chunk.meshId,
|
||||
chunk.chunkIndex,
|
||||
chunk.chunkCount,
|
||||
chunk.triangleOffset,
|
||||
chunk.triangleCount,
|
||||
chunk.vertexCount,
|
||||
chunk.byteLength,
|
||||
await sha256(chunk.positions),
|
||||
await sha256(chunk.indices),
|
||||
]);
|
||||
return sha256(new TextEncoder().encode(metadata));
|
||||
}
|
||||
|
||||
export async function meshGeometryStreamManifestSha256(
|
||||
request: MeshGeometryStreamRequestIR,
|
||||
chunks: readonly MeshGeometryStreamChunkRecordIR[],
|
||||
): Promise<string> {
|
||||
const canonical = JSON.stringify({
|
||||
schemaVersion: request.schemaVersion,
|
||||
streamId: request.streamId,
|
||||
meshId: request.meshId,
|
||||
triangleCount: request.triangleCount,
|
||||
chunkTriangleCount: request.chunkTriangleCount,
|
||||
lodTriangleCount: request.lodTriangleCount,
|
||||
chunks,
|
||||
});
|
||||
return sha256(new TextEncoder().encode(canonical));
|
||||
}
|
||||
|
||||
export class MeshGeometryStreamValidator {
|
||||
private readonly request: MeshGeometryStreamRequestIR;
|
||||
private readonly expectedChunkCount: number;
|
||||
private nextChunkIndex = 0;
|
||||
private nextTriangleOffset = 0;
|
||||
private transferredBytes = 0;
|
||||
private peakWorkingSetBytes = 0;
|
||||
private readonly chunks: MeshGeometryStreamChunkRecordIR[] = [];
|
||||
|
||||
constructor(value: MeshGeometryStreamRequestIR) {
|
||||
this.request = parseMeshGeometryStreamRequest(value);
|
||||
this.expectedChunkCount = Math.ceil(this.request.triangleCount / this.request.chunkTriangleCount);
|
||||
}
|
||||
|
||||
async accept(value: MeshGeometryStreamChunkIR): Promise<void> {
|
||||
if (!record(value) || value.schemaVersion !== MESH_GEOMETRY_STREAM_SCHEMA || value.streamId !== this.request.streamId || value.meshId !== this.request.meshId) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk envelope does not match the active stream");
|
||||
}
|
||||
const expectedTriangles = Math.min(this.request.chunkTriangleCount, this.request.triangleCount - this.nextTriangleOffset);
|
||||
if (value.chunkIndex !== this.nextChunkIndex || value.chunkCount !== this.expectedChunkCount || value.triangleOffset !== this.nextTriangleOffset || value.triangleCount !== expectedTriangles) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk ranges are not contiguous and complete");
|
||||
}
|
||||
if (!(value.positions instanceof ArrayBuffer) || !(value.indices instanceof ArrayBuffer) ||
|
||||
!Number.isSafeInteger(value.vertexCount) || value.vertexCount < 3 ||
|
||||
value.positions.byteLength !== value.vertexCount * 3 * Float32Array.BYTES_PER_ELEMENT ||
|
||||
value.indices.byteLength !== value.triangleCount * 3 * Uint32Array.BYTES_PER_ELEMENT ||
|
||||
value.byteLength !== value.positions.byteLength + value.indices.byteLength) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk binary lengths are invalid");
|
||||
}
|
||||
const indices = new Uint32Array(value.indices);
|
||||
for (const index of indices) {
|
||||
if (index >= value.vertexCount) throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk index exceeds its local vertex range");
|
||||
}
|
||||
const { sha256: declaredSha256, ...unsigned } = value;
|
||||
const digest = await meshGeometryStreamChunkSha256(unsigned);
|
||||
if (!/^[a-f0-9]{64}$/.test(declaredSha256) || digest !== declaredSha256) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "chunk SHA-256 mismatch");
|
||||
}
|
||||
this.chunks.push({
|
||||
chunkIndex: value.chunkIndex,
|
||||
triangleOffset: value.triangleOffset,
|
||||
triangleCount: value.triangleCount,
|
||||
vertexCount: value.vertexCount,
|
||||
byteLength: value.byteLength,
|
||||
sha256: declaredSha256,
|
||||
});
|
||||
this.nextChunkIndex += 1;
|
||||
this.nextTriangleOffset += value.triangleCount;
|
||||
this.transferredBytes += value.byteLength;
|
||||
this.peakWorkingSetBytes = Math.max(this.peakWorkingSetBytes, value.byteLength);
|
||||
}
|
||||
|
||||
async finish(): Promise<{ chunks: MeshGeometryStreamChunkRecordIR[]; transferredBytes: number; peakWorkingSetBytes: number; manifestSha256: string }> {
|
||||
if (this.nextChunkIndex !== this.expectedChunkCount || this.nextTriangleOffset !== this.request.triangleCount) {
|
||||
throw new MeshGeometryStreamError("GEOMETRY_STREAM_RANGE_INVALID", "stream ended before all triangle ranges arrived");
|
||||
}
|
||||
return {
|
||||
chunks: [...this.chunks],
|
||||
transferredBytes: this.transferredBytes,
|
||||
peakWorkingSetBytes: this.peakWorkingSetBytes,
|
||||
manifestSha256: await meshGeometryStreamManifestSha256(this.request, this.chunks),
|
||||
};
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user