diff --git a/apps/web/benchmarks/README.md b/apps/web/benchmarks/README.md index 0915aa1c..35078cb2 100644 --- a/apps/web/benchmarks/README.md +++ b/apps/web/benchmarks/README.md @@ -18,6 +18,36 @@ The default load radius is 1, unload radius is 2, viewport is 1280×720 at pixel Chrome uses its selected GPU by default. `--software-rendering` requests SwiftShader for validation on machines without usable GPU rendering; those results describe software rendering. `--headed` shows the browser. The report records the browser, actual WebGL renderer, operating system, revision, settings, and hashes of every world file requested. Missing files are listed separately. Benchmark results and save files do not belong in Git. +## Profile placement + +Add `--profile-placement` to the command above. This option records placement stages and requires section meshing. + +The runner instruments four built ESM modules in memory while bundling the benchmark. It leaves library source, package files, and the section worker unchanged. If a method or measured boundary has changed, bundling fails instead of silently omitting that measurement. Without the flag, the runner uses the ordinary benchmark bundle. + +The report's `placementProfile` contains: + +- `events`: timed spans with IDs, explicit parent IDs, pass, phase, and waypoint. Section builds and their child stages include absolute section coordinates, including the vertical section coordinate. State and model preparation events identify their block or model; they have no section assignment. +- `summary`: build totals per pass, split into `ownColumnBuilds`, `neighborBuilds`, `unloadBuilds`, and `otherBuilds`, plus `sectionsBuiltTwiceInOneOperation` for sections built at least twice within one operation. +- `sections`: counts by pass and section coordinates (`x`, `y`, `z`), with `builds`, `succeeded`, the same operation split, `maxBuildsPerOperation`, `inclusiveMs`, and `unionMs`. +- `stages`: call counts, successes, summed `inclusiveMs`, and `unionMs` by pass and stage, including cleanup. The union counts overlapping intervals of that stage once. Different stages can overlap or contain each other, so neither column can be added across stages to calculate total streaming time. Filter `events` by `phase` to exclude cleanup. +- `issues` and `incompleteSpans`: missing ownership or unfinished instrumentation. The runner exits unsuccessfully if it detects these issues. + +Stage boundaries are: + +| Stage | Includes | +|---|---| +| `state-preparation` | Selecting prepared state/templates and resolving tints, including cache hits and waits | +| `state-resolution` | The state cache's loader: model resolution, model atlases, and rotation data | +| `template-preparation` / `template-build` | Template cache lookup/wait / the cache loader's geometry creation | +| `model-atlas` | Creating a model texture atlas, including awaited texture loads; warm caches can skip this stage | +| `section-rebuild` | A rebuild attempt, including neighborhood preparation, mesh replacement, and attempts that need no build | +| `section-build` / `section-input` | A mesh build / conversion of its entries and templates to worker input | +| `worker-roundtrip` | Worker dispatch through reply, including queueing, message cloning, and main-thread scheduling; this is not worker CPU time | +| `synchronous-geometry` | Geometry built on the main thread when no worker is available or a worker rejects | +| `mesh-assembly` / `atlas-paint` | Page canvases, geometry, materials, bounds, and subscriptions / its nested canvas allocation and image painting | + +Profiling adds clock reads, span records, promise continuations, and retained JavaScript memory. Use ordinary runs for performance comparisons. Profiled times describe the instrumented run, and its heap includes profiler records. The worker hash stays unchanged; the instrumented main script has a different hash. Both are recorded in the report. + ## Read the report Each invocation has two passes. The first starts with fresh browser asset storage and a fresh `AnvilWorldSource`; the second keeps loaded assets and the renderer but creates a fresh world and source. Shared GPU resources and shader programs can also remain warm. Within each pass, returning to earlier stops reloads columns while raw region files can remain cached. The runner serves save files with `Cache-Control: no-store`. diff --git a/apps/web/benchmarks/placement-profile.ts b/apps/web/benchmarks/placement-profile.ts new file mode 100644 index 00000000..148915ea --- /dev/null +++ b/apps/web/benchmarks/placement-profile.ts @@ -0,0 +1,223 @@ +export interface PlacementProfileContext { + pass: "cold" | "warm"; + phase: "outbound" | "return" | "cleanup"; + waypoint: number | null; + operation: { id: number; kind: "placement" | "unload"; columns: { x: number; z: number }[] } | null; +} + +export type PlacementProfileKind = "section-rebuild" | "section-build" | "section-input" | "worker-roundtrip" + | "synchronous-geometry" | "mesh-assembly" | "atlas-paint" | "model-atlas" | "state-resolution" + | "state-preparation" | "template-preparation" | "template-build"; + +export interface PlacementProfileMetadata { + [key: string]: string | number | boolean | null | undefined; + x?: number; + y?: number; + z?: number; +} + +export interface PlacementProfileEvent extends PlacementProfileContext, Pick { + spanId: number; + parentSpanId?: number; + kind: PlacementProfileKind; + startMs: number; + durationMs: number; + success: boolean; + error?: string; +} + +interface PendingSpan { + spanId: number; + parentSpanId?: number; + kind: PlacementProfileKind; + context: PlacementProfileContext; + metadata: PlacementProfileMetadata; + startMs: number; +} + +export interface PlacementProfileHooks { + begin(kind: PlacementProfileKind, metadata?: PlacementProfileMetadata, parentId?: number): number; + end(spanId: number, success: boolean, error?: unknown): void; + measure(kind: PlacementProfileKind, metadata: PlacementProfileMetadata, parentId: number | undefined, action: (spanId: number) => T): T; + measureAsync(kind: PlacementProfileKind, metadata: PlacementProfileMetadata, parentId: number | undefined, action: (spanId: number) => Promise): Promise; +} + +interface StageSummary { + pass: PlacementProfileContext["pass"]; + kind: PlacementProfileKind; + count: number; + succeeded: number; + inclusiveMs: number; + unionMs: number; +} + +interface SectionSummary { + pass: PlacementProfileContext["pass"]; + x: number; + y: number; + z: number; + builds: number; + succeeded: number; + ownColumnBuilds: number; + neighborBuilds: number; + unloadBuilds: number; + otherBuilds: number; + maxBuildsPerOperation: number; + inclusiveMs: number; + unionMs: number; +} + +const GLOBAL_KEY = "__minerenderPlacementProfile"; +const errorMessage = (error: unknown): string => error instanceof Error ? error.message : String(error); + +function unionDuration(events: readonly PlacementProfileEvent[]): number { + const ordered = events.slice().sort((a, b) => a.startMs - b.startMs); + let total = 0, start = ordered[0]?.startMs ?? 0, end = start; + for (const event of ordered) { + const nextEnd = event.startMs + event.durationMs; + if (event.startMs > end) { + total += end - start; + start = event.startMs; + end = nextEnd; + } else end = Math.max(end, nextEnd); + } + return total + end - start; +} + +/** Installs hooks only for the benchmark bundle. Explicit parent IDs preserve ownership across awaits. */ +export function installPlacementProfiler(started: number, getContext: () => PlacementProfileContext) { + const events: PlacementProfileEvent[] = []; + const spans = new Map(); + const issues: string[] = []; + let nextId = 0; + + const metadataCopy = (metadata: PlacementProfileMetadata): PlacementProfileMetadata => { + const copy: PlacementProfileMetadata = {}; + for (const [key, value] of Object.entries(metadata)) { + if (value !== undefined) copy[key] = value; + } + return copy; + }; + + const hooks: PlacementProfileHooks = { + begin(kind, metadata = {}, parentId) { + const parent = parentId === undefined ? undefined : spans.get(parentId); + if (parentId !== undefined && !parent) issues.push(`Missing parent span ${parentId} for ${kind}`); + const spanId = ++nextId; + spans.set(spanId, { + spanId, parentSpanId: parentId, kind, context: { ...(parent?.context ?? getContext()) }, + metadata: { ...parent?.metadata, ...metadataCopy(metadata) }, startMs: performance.now() - started + }); + return spanId; + }, + end(spanId, success, error) { + const span = spans.get(spanId); + if (!span) return; + spans.delete(spanId); + const durationMs = performance.now() - started - span.startMs; + events.push({ + ...span.metadata, ...span.context, + spanId, parentSpanId: span.parentSpanId, kind: span.kind, startMs: span.startMs, durationMs, success, + ...(!success && { error: errorMessage(error) }) + }); + }, + measure(kind, metadata, parentId, action) { + const spanId = hooks.begin(kind, metadata, parentId); + let success = false, error: unknown; + try { + const result = action(spanId); + success = true; + return result; + } catch (caught) { + error = caught; + throw caught; + } finally { + hooks.end(spanId, success, error); + } + }, + async measureAsync(kind, metadata, parentId, action) { + const spanId = hooks.begin(kind, metadata, parentId); + let success = false, error: unknown; + try { + const result = await action(spanId); + success = true; + return result; + } catch (caught) { + error = caught; + throw caught; + } finally { + hooks.end(spanId, success, error); + } + } + }; + Object.defineProperty(globalThis, GLOBAL_KEY, { configurable: true, writable: true, value: hooks }); + + return { + report() { + const stageEvents = new Map(); + const sectionEvents = new Map(); + const reportIssues = issues.slice(); + for (const spanId of spans.keys()) reportIssues.push(`Span ${spanId} is still open`); + for (const event of events) { + const stageKey = JSON.stringify([event.pass, event.kind]); + let stage = stageEvents.get(stageKey); + if (!stage) stageEvents.set(stageKey, stage = []); + stage.push(event); + if (event.kind !== "section-build") continue; + if (![event.x, event.y, event.z].every(value => Number.isInteger(value))) { + reportIssues.push(`Section build ${event.spanId} has no integer section coordinates`); + continue; + } + const sectionKey = JSON.stringify([event.pass, event.x, event.y, event.z]); + let section = sectionEvents.get(sectionKey); + if (!section) sectionEvents.set(sectionKey, section = []); + section.push(event); + } + const stages: StageSummary[] = [...stageEvents.values()].map(group => ({ + pass: group[0].pass, kind: group[0].kind, count: group.length, + succeeded: group.filter(event => event.success).length, + inclusiveMs: group.reduce((total, event) => total + event.durationMs, 0), unionMs: unionDuration(group) + })); + const sections: SectionSummary[] = [...sectionEvents.values()].map(group => { + const operations = new Map(); + let ownColumnBuilds = 0, neighborBuilds = 0, unloadBuilds = 0, otherBuilds = 0; + for (const event of group) { + const operation = event.operation; + if (!operation) otherBuilds++; + else { + operations.set(operation.id, (operations.get(operation.id) ?? 0) + 1); + if (operation.kind === "unload") unloadBuilds++; + else if (operation.columns.some(column => column.x === event.x && column.z === event.z)) ownColumnBuilds++; + else neighborBuilds++; + } + } + return { + pass: group[0].pass, x: group[0].x!, y: group[0].y!, z: group[0].z!, builds: group.length, + succeeded: group.filter(event => event.success).length, + ownColumnBuilds, neighborBuilds, unloadBuilds, otherBuilds, + maxBuildsPerOperation: Math.max(0, ...operations.values()), + inclusiveMs: group.reduce((total, event) => total + event.durationMs, 0), unionMs: unionDuration(group) + }; + }); + const summary = (["cold", "warm"] as const).map(pass => sections.filter(section => section.pass === pass).reduce((total, section) => ({ + pass, builds: total.builds + section.builds, + ownColumnBuilds: total.ownColumnBuilds + section.ownColumnBuilds, + neighborBuilds: total.neighborBuilds + section.neighborBuilds, + unloadBuilds: total.unloadBuilds + section.unloadBuilds, + otherBuilds: total.otherBuilds + section.otherBuilds, + sectionsBuiltTwiceInOneOperation: total.sectionsBuiltTwiceInOneOperation + Number(section.maxBuildsPerOperation >= 2) + }), { pass, builds: 0, ownColumnBuilds: 0, neighborBuilds: 0, unloadBuilds: 0, otherBuilds: 0, sectionsBuiltTwiceInOneOperation: 0 })); + return { + events: events.slice().sort((a, b) => a.spanId - b.spanId), stages, sections, summary, + incompleteSpans: [...spans.values()].map(span => { + const { spanId } = span; + return { ...span.metadata, ...span.context, spanId, parentSpanId: span.parentSpanId, kind: span.kind, startMs: span.startMs }; + }), + issues: reportIssues + }; + }, + restore() { + delete (globalThis as typeof globalThis & { [GLOBAL_KEY]?: PlacementProfileHooks })[GLOBAL_KEY]; + } + }; +} diff --git a/apps/web/benchmarks/world.ts b/apps/web/benchmarks/world.ts index 6b160f69..2d3b43e4 100644 --- a/apps/web/benchmarks/world.ts +++ b/apps/web/benchmarks/world.ts @@ -2,6 +2,7 @@ import { AnvilParser, AnvilWorldSource, AssetLoader, Env, MineRenderWorld, Renderer, SectionMesh, SectionWorker, WorldStreamer } from "minerender"; +import { installPlacementProfiler, PlacementProfileContext } from "./placement-profile"; export interface WorldBenchmarkConfig { version: string; @@ -15,6 +16,7 @@ export interface WorldBenchmarkConfig { biomeBlendRadius: number; renderEntities: boolean; maxCachedRegions: number; + profilePlacement: boolean; } const CAMERA_HEIGHT = 160; @@ -114,6 +116,9 @@ async function runWorldBenchmark(config: WorldBenchmarkConfig) { const operations: Operation[] = []; const observedDataVersions = new Set(); let context: Pick = { pass: "cold", phase: "outbound", waypoint: null }; + let operation: PlacementProfileContext["operation"] = null; + let nextOperationId = 0; + const placementProfile = config.profilePlacement ? installPlacementProfiler(started, () => ({ ...context, operation })) : undefined; let frames: ReturnType | undefined; const worker = { created: 0, buildRequests: 0, buildReplies: 0, buildFailures: 0, synchronousBuilds: 0 }; const workerStats = () => ({ ...worker, fallbackBuilds: worker.buildFailures + worker.synchronousBuilds }); @@ -252,10 +257,18 @@ async function runWorldBenchmark(config: WorldBenchmarkConfig) { readEntityRegion: config.renderEntities ? (x, z, signal) => readFile("entity-region", x, z, `/world-files/entities/r.${x}.${z}.mca`, signal) : undefined }); const streamer = new WorldStreamer({ - placeChunk: chunk => timed("placement", { x: chunk.x, z: chunk.z }, () => world.placeChunk(chunk)), + placeChunk: chunk => timed("placement", { x: chunk.x, z: chunk.z }, async () => { + operation = { id: ++nextOperationId, kind: "placement", columns: [{ x: chunk.x, z: chunk.z }] }; + try { await world.placeChunk(chunk); } + finally { operation = null; } + }), unloadChunkColumns: columns => timed("unload", { columnCount: columns.length, columns: columns.map(({ x, z }) => ({ x, z })) - }, () => world.unloadChunkColumns(columns)) + }, async () => { + operation = { id: ++nextOperationId, kind: "unload", columns: columns.map(({ x, z }) => ({ x, z })) }; + try { await world.unloadChunkColumns(columns); } + finally { operation = null; } + }) }, { getChunk: (x, z, signal) => timed("source", { x, z }, () => source.getChunk(x, z, signal)) }, { loadRadius: config.loadRadius, unloadRadius: config.unloadRadius }); @@ -316,7 +329,8 @@ async function runWorldBenchmark(config: WorldBenchmarkConfig) { runs.push({ pass, cacheStatus: `fresh source; ${pass === "cold" ? "cold" : "warm"} assets`, waypoints, cleanupMs, heapBytesAfterCleanupGc }); } status.textContent = "Benchmark complete"; - return { settings, environment, observedDataVersions: [...observedDataVersions].sort((a, b) => a - b), runs, operations, worker: workerStats() }; + return { settings, environment, observedDataVersions: [...observedDataVersions].sort((a, b) => a - b), runs, operations, + worker: workerStats(), placementProfile: placementProfile?.report() }; } catch (error) { status.textContent = `Benchmark failed: ${errorMessage(error)}`; throw error; @@ -332,6 +346,7 @@ async function runWorldBenchmark(config: WorldBenchmarkConfig) { SectionWorker.prototype.build = original.buildWorker; if (createWorkerDescriptor) Object.defineProperty(provider, "createWorker", createWorkerDescriptor); else delete provider.createWorker; + placementProfile?.restore(); running = false; } } diff --git a/apps/web/build.mjs b/apps/web/build.mjs index 0eec1cb6..6852d5c0 100644 --- a/apps/web/build.mjs +++ b/apps/web/build.mjs @@ -4,6 +4,7 @@ import glob from "glob"; import { parseArgs } from "node:util"; import { copyFile, mkdir, readFile, rm, writeFile } from "node:fs/promises"; import { dirname, relative, sep } from "node:path"; +import { instrumentPlacementModule } from "./scripts/placement-profile.mjs"; const { values: args } = parseArgs({ options: { @@ -74,3 +75,8 @@ if (args.serve) { await writeFile(output, html); })); } + +for (const name of ["Chunk", "SectionMesh", "SectionModels", "UVMapper"]) { + const file = `../../packages/minerender/dist/browser/esm/${name === "UVMapper" ? "" : "world/"}${name}.mjs`; + instrumentPlacementModule(await readFile(file, "utf8"), name); +} diff --git a/apps/web/scripts/benchmark-world.mjs b/apps/web/scripts/benchmark-world.mjs index fe5ef97e..57fcc312 100644 --- a/apps/web/scripts/benchmark-world.mjs +++ b/apps/web/scripts/benchmark-world.mjs @@ -27,6 +27,7 @@ const { values: args } = parseArgs({ options: { "saved-lighting": { type: "boolean", default: false }, "ambient-occlusion": { type: "boolean", default: false }, "render-entities": { type: "boolean", default: false }, + "profile-placement": { type: "boolean", default: false }, "software-rendering": { type: "boolean", default: false }, headed: { type: "boolean", default: false }, "timeout-ms": { type: "string", default: "1800000" }, @@ -43,6 +44,7 @@ if (args.help) { --saved-lighting --ambient-occlusion --biome-blend-radius N --render-entities --no-section-meshing --max-cached-regions N --software-rendering Use SwiftShader instead of Chrome's selected GPU +--profile-placement Record section rebuilds and placement stages (adds timing overhead) --headed --timeout-ms N --output REPORT.json Set CHROME_PATH for a Chrome binary outside the default macOS installation. @@ -152,10 +154,12 @@ try { maxCachedRegions: integer("max-cached-regions", 0), biomeBlendRadius: integer("biome-blend-radius", 0, 7), sectionMeshing: !args["no-section-meshing"], savedLighting: args["saved-lighting"], ambientOcclusion: args["ambient-occlusion"], renderEntities: args["render-entities"], + profilePlacement: args["profile-placement"], }; if (!config.sectionMeshing && (config.savedLighting || config.ambientOcclusion || config.biomeBlendRadius)) { throw new Error("Saved lighting, ambient occlusion, and biome blending require section meshing"); } + if (config.profilePlacement && !config.sectionMeshing) throw new Error("Placement profiling requires section meshing"); const timeout = integer("timeout-ms", 1000); const chrome = process.env.CHROME_PATH ?? "/Applications/Google Chrome.app/Contents/MacOS/Google Chrome"; await stat(chrome); @@ -163,7 +167,7 @@ try { const bundle = await esbuild.build({ entryPoints: [path.join(root, "benchmarks/world.ts")], bundle: true, write: false, format: "esm", platform: "browser", target: "es2020", logLevel: "warning", - plugins: [polyfillNode({ polyfills: { zlib: true } })], + plugins: [...(config.profilePlacement ? [(await import("./placement-profile.mjs")).placementProfilePlugin()] : []), polyfillNode({ polyfills: { zlib: true } })], }); const requests = new Map(); const html = 'World streaming benchmark
Starting benchmark'; @@ -218,6 +222,10 @@ try { }), ]); } finally { clearTimeout(timeoutTimer); } + if (config.profilePlacement) { + if (!result.placementProfile) throw new Error("The browser returned no placement profile"); + problems.push(...result.placementProfile.issues.map(issue => `Placement profile: ${issue}`)); + } const manifest = []; for (const relative of [...requests.keys()].sort()) { const file = files.get(relative); @@ -246,6 +254,10 @@ try { const updateMs = run.waypoints.reduce((sum, point) => sum + point.updateMs, 0); const maxGap = Math.max(...run.waypoints.map(point => point.frames.maxMs ?? 0)); console.log(`${run.pass}: ${updateMs.toFixed(0)} ms streaming, ${maxGap.toFixed(1)} ms maximum frame gap`); + if (report.placementProfile) { + const summary = report.placementProfile.summary.find(summary => summary.pass === run.pass); + console.log(` placement profile: ${summary.builds} section builds (own column ${summary.ownColumnBuilds}, neighbouring columns ${summary.neighborBuilds}, unloads ${summary.unloadBuilds})`); + } } console.log(`Report: ${output}`); if (problems.length || report.runs.some(run => run.waypoints.some(point => point.failedChunks > 0))) process.exitCode = 1; diff --git a/apps/web/scripts/placement-profile.mjs b/apps/web/scripts/placement-profile.mjs new file mode 100644 index 00000000..8368fce0 --- /dev/null +++ b/apps/web/scripts/placement-profile.mjs @@ -0,0 +1,137 @@ +import { readFile } from "node:fs/promises"; +import ts from "typescript"; +import path from "node:path"; + +const modules = ["Chunk", "SectionMesh", "SectionModels", "UVMapper"]; +const hooks = "globalThis.__minerenderPlacementProfile"; + +/** Instruments the benchmark bundle; the library's source and package output stay unchanged. */ +export function instrumentPlacementModule(contents, name) { + const source = ts.createSourceFile(`${name}.mjs`, contents, ts.ScriptTarget.Latest, true, ts.ScriptKind.JS); + const edits = []; + const fail = detail => { throw new Error(`Placement profiling: ${name}: ${detail}; rebuild the library or update the instrumentation`); }; + function find(root, predicate, description) { + const matches = []; + function visit(node) { + if (predicate(node)) matches.push(node); + ts.forEachChild(node, visit); + } + visit(root); + if (matches.length !== 1) fail(`expected one ${description}, found ${matches.length}`); + return matches[0]; + } + const method = name => find(source, node => ts.isMethodDeclaration(node) && node.name.getText(source) === name, `${name} method`); + function bindings(target, names) { + const declared = new Set(); + function binding(name) { + if (ts.isIdentifier(name)) declared.add(name.text); + else for (const element of name.elements) if (ts.isBindingElement(element)) binding(element.name); + } + function visit(node) { + if (ts.isParameter(node) || ts.isVariableDeclaration(node)) binding(node.name); + ts.forEachChild(node, visit); + } + visit(target); + for (const name of names) if (!declared.has(name)) fail(`missing ${name} binding in ${target.name.getText(source)}`); + } + const call = (root, name) => find(root, node => ts.isCallExpression(node) && node.expression.getText(source) === name, `${name} call`); + const text = node => node.getText(source); + const measure = (kind, metadata, parent, action, asynchronous = false) => + `${hooks}.${asynchronous ? "measureAsync" : "measure"}(${JSON.stringify(kind)}, ${metadata}, ${parent}, ${action})`; + const wrap = (kind, metadata, body, parent = "undefined") => + `return ${measure(kind, metadata, parent, `async (__placementSpan) => {${body}}`, true)};`; + function body(target, changes = [], wrapper) { + const start = target.body.getStart(source) + 1, end = target.body.end - 1; + let value = contents.slice(start, end); + for (const { node, value: replacement } of changes.sort((a, b) => b.node.getStart(source) - a.node.getStart(source))) { + const from = node.getStart(source) - start, to = node.end - start; + value = value.slice(0, from) + replacement + value.slice(to); + } + edits.push({ start, end, value: wrapper ? wrapper(value) : value }); + } + function parameter(target, expected, value) { + if (target.parameters.length !== expected) fail(`expected ${expected} parameters in ${text(target.name)}`); + const end = target.parameters.at(-1).end; + edits.push({ start: end, end, value: `, ${value}` }); + } + if (name === "Chunk") { + const target = method("rebuildSectionMesh"), build = call(target, "SectionMesh.buildAsync"); + if (build.arguments.length !== 4) fail("expected four section build arguments"); + body(target, [{ node: build, value: `${text(build).slice(0, -1)}, __placementSpan)` }], value => + wrap("section-rebuild", "{x: this.x, y: this.y, z: this.z}", value)); + } else if (name === "SectionMesh") { + const target = method("buildAsync"); + bindings(target, ["entries", "fluids"]); + parameter(target, 4, "__placementParent"); + const input = call(target, "toInput"), worker = call(target, "worker.build"), assembly = call(target, "this.fromPages"); + const fallback = []; + function visit(node) { + if (ts.isCallExpression(node) && node.expression.getText(source) === "buildSectionGeometry") fallback.push(node); + ts.forEachChild(node, visit); + } + visit(target); + if (fallback.length !== 2) fail(`expected two geometry fallback calls, found ${fallback.length}`); + body(target, [ + { node: input, value: measure("section-input", "{}", "__placementSpan", `() => ${text(input)}`) }, + { node: worker, value: measure("worker-roundtrip", "{}", "__placementSpan", `() => ${text(worker)}`, true) }, + { node: assembly, value: `${text(assembly).slice(0, -1)}, __placementSpan)` }, + ...fallback.map(node => ({ node, value: measure("synchronous-geometry", "{}", "__placementSpan", `() => ${text(node)}`) })) + ], value => wrap("section-build", "{entries: entries.length, fluids: !!fluids}", value, "__placementParent")); + const assemble = method("fromPages"); + bindings(assemble, ["pages", "page", "canvas", "context"]); + parameter(assemble, 2, "__placementParent"); + const loop = find(assemble, node => ts.isForOfStatement(node) && node.expression.getText(source) === "pages", "page loop"); + const statements = loop.statement.statements; + if (!statements || statements.length < 4 || !text(statements[0]).includes("createCanvas(page.width, page.height)") + || !text(statements[1]).includes('canvas.getContext("2d")') || !ts.isForOfStatement(statements[2]) + || !text(statements[3]).includes("new BufferGeometry()")) fail("atlas painting boundary changed"); + const paintStart = statements[0].getStart(source), paintEnd = statements[2].end; + const originalPaint = contents.slice(paintStart, paintEnd); + const paintNode = { getStart: () => paintStart, end: paintEnd }; + body(assemble, [{ node: paintNode, value: + `const {canvas, context} = ${measure("atlas-paint", "{pixels: page.width * page.height}", "__placementSpan", + `() => {${originalPaint}\nreturn {canvas, context};}`)};` }], value => + `return ${measure("mesh-assembly", "{pages: pages.length, triangles: pages.reduce((sum, page) => sum + page.indices.length / 3, 0)}", + "__placementParent", `(__placementSpan) => {${value}}`)};`); + } else if (name === "SectionModels") { + for (const [name, kind] of [["prepareState", "state-preparation"], ["prepare", "state-resolution"], ["template", "template-preparation"]]) { + const target = method(name); + bindings(target, [name === "template" ? "model" : "blockState"]); + const changes = []; + if (name === "template") { + const load = call(target, "cached").arguments.at(-1); + if (!ts.isArrowFunction(load) || !ts.isBlock(load.body)) fail("expected a template cache loader"); + changes.push({ node: load.body, value: `{${wrap("template-build", "{model: model.key?.toNamespacedString()}", + contents.slice(load.body.getStart(source) + 1, load.body.end - 1), "__placementSpan")}}` }); + } + body(target, changes, value => wrap(kind, name === "template" ? "{model: model.key?.toNamespacedString()}" + : "{block: blockState.key?.toNamespacedString()}", value)); + } + } else if (name === "UVMapper") { + const target = method("createAtlas"); + bindings(target, ["originalModel"]); + body(target, [], value => wrap("model-atlas", "{model: originalModel.key?.toNamespacedString()}", value)); + } else fail("unexpected module"); + for (const edit of edits.sort((a, b) => b.start - a.start)) contents = contents.slice(0, edit.start) + edit.value + contents.slice(edit.end); + return contents; +} + +export function placementProfilePlugin() { + const instrumented = new Set(); + return { + name: "placement-profile", + setup(build) { + build.onLoad({ filter: /\/dist\/browser\/esm\/(?:world\/(?:Chunk|SectionMesh|SectionModels)|UVMapper)\.mjs$/ }, async ({ path: file }) => { + const name = path.basename(file, ".mjs"); + const contents = instrumentPlacementModule(await readFile(file, "utf8"), name); + instrumented.add(name); + return { contents, loader: "js" }; + }); + build.onEnd(result => { + if (result.errors.length) return; + const missing = modules.filter(name => !instrumented.has(name)); + if (missing.length) return { errors: [{ text: `Placement profiling: missing ESM modules: ${missing.join(", ")}` }] }; + }); + } + }; +}