From 175904b57eec66b25410184b801075dbacc73312 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Haylee=20Sch=C3=A4fer?= Date: Sat, 17 Oct 2026 12:48:49 +0200 Subject: [PATCH 1/2] Profile world placement stages and section rebuild ownership --- apps/web/benchmarks/README.md | 36 ++++ apps/web/benchmarks/placement-profile.ts | 220 +++++++++++++++++++++++ apps/web/benchmarks/world.ts | 7 +- apps/web/scripts/benchmark-world.mjs | 19 +- apps/web/scripts/placement-profile.mjs | 118 ++++++++++++ 5 files changed, 398 insertions(+), 2 deletions(-) create mode 100644 apps/web/benchmarks/placement-profile.ts create mode 100644 apps/web/scripts/placement-profile.mjs diff --git a/apps/web/benchmarks/README.md b/apps/web/benchmarks/README.md index 0915aa1c..7bd141e7 100644 --- a/apps/web/benchmarks/README.md +++ b/apps/web/benchmarks/README.md @@ -18,6 +18,42 @@ 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 record placement stages and identify sections built more than once during a stop. This option requires section meshing. For example: + +```sh +yarn workspace @minerender/web benchmark:world \ + --world "SAVE_FOLDER" --version 1.16.2 \ + --route '[[-15,8],[-7,8],[1,8],[9,8]]' \ + --profile-placement --output /tmp/world-placement.json +``` + +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. +- `sections`: build counts by pass, waypoint, and section. `repeatedBuilds` counts calls after the first at that stop. Loading an adjacent column can change a section's border, so repeated calls do not by themselves prove unnecessary work. Returning to a previously visited section is a separate stop. +- `stages`: call counts, failures, 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 or if the profiler's section-build count differs from the benchmark's count. + +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. Run at least three ordinary and three profiled invocations under matching conditions to assess its overhead. 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..73939e8d --- /dev/null +++ b/apps/web/benchmarks/placement-profile.ts @@ -0,0 +1,220 @@ +export interface PlacementProfileContext { + pass: "cold" | "warm"; + phase: "outbound" | "return" | "cleanup"; + waypoint: 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, PlacementProfileMetadata { + 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, metadata?: PlacementProfileMetadata): 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; + failed: number; + inclusiveMs: number; + unionMs: number; +} + +interface SectionSummary extends PlacementProfileContext { + x: number; + y: number; + z: number; + builds: number; + succeeded: number; + failed: number; + repeatedBuilds: 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) { + if (!Number.isFinite(started)) throw new RangeError("The placement profiler start time must be finite"); + if (globalThis.__minerenderPlacementProfile !== undefined) throw new Error("A placement profiler is already installed"); + const descriptor = Object.getOwnPropertyDescriptor(globalThis, GLOBAL_KEY); + const events: PlacementProfileEvent[] = []; + const spans = new Map(); + const active = new Set(); + const issues: string[] = []; + let nextId = 0, restored = false; + + const metadataCopy = (metadata: PlacementProfileMetadata): PlacementProfileMetadata => { + const copy: PlacementProfileMetadata = {}; + for (const [key, value] of Object.entries(metadata)) { + if (value === undefined) continue; + if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) copy[key] = value; + else issues.push(`Ignored invalid ${key} metadata`); + } + return copy; + }; + + const hooks: PlacementProfileHooks = { + begin(kind, metadata = {}, parentId) { + if (restored) return 0; + 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 + }); + active.add(spanId); + return spanId; + }, + end(spanId, success, error, metadata = {}) { + if (!spanId || restored) return; + const span = spans.get(spanId); + if (!span || !active.delete(spanId)) { + issues.push(`Unknown or completed span ${spanId}`); + return; + } + const durationMs = Math.max(0, performance.now() - started - span.startMs); + events.push({ + ...span.metadata, ...metadataCopy(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 { + ...hooks, + report() { + const stageEvents = new Map(); + const sectionEvents = new Map(); + const reportIssues = issues.slice(); + for (const spanId of active) 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.phase, event.waypoint, 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, failed: 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 => ({ + pass: group[0].pass, phase: group[0].phase, waypoint: group[0].waypoint, + x: group[0].x!, y: group[0].y!, z: group[0].z!, builds: group.length, + succeeded: group.filter(event => event.success).length, failed: group.filter(event => !event.success).length, + repeatedBuilds: Math.max(0, group.length - 1), + inclusiveMs: group.reduce((total, event) => total + event.durationMs, 0), unionMs: unionDuration(group) + })); + return { + events: events.slice().sort((a, b) => a.spanId - b.spanId), stages, sections, + incompleteSpans: [...active].map(spanId => { + const span = spans.get(spanId)!; + return { ...span.metadata, ...span.context, spanId, parentSpanId: span.parentSpanId, kind: span.kind, startMs: span.startMs }; + }), + issues: reportIssues, + notes: [ + "Inclusive stage durations overlap; interval unions count each stage's covered wall time once per pass.", + "Repeated section builds count extra calls at the same section coordinates within one waypoint; they do not prove redundant work." + ] + }; + }, + restore() { + if (restored) return; + restored = true; + if (descriptor) Object.defineProperty(globalThis, GLOBAL_KEY, descriptor); + else delete globalThis.__minerenderPlacementProfile; + } + }; +} + +declare global { + var __minerenderPlacementProfile: PlacementProfileHooks | undefined; +} diff --git a/apps/web/benchmarks/world.ts b/apps/web/benchmarks/world.ts index 6b160f69..c608824f 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 } 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,7 @@ async function runWorldBenchmark(config: WorldBenchmarkConfig) { const operations: Operation[] = []; const observedDataVersions = new Set(); let context: Pick = { pass: "cold", phase: "outbound", waypoint: null }; + const placementProfile = config.profilePlacement ? installPlacementProfiler(started, () => context) : 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 }); @@ -316,7 +319,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 +336,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/scripts/benchmark-world.mjs b/apps/web/scripts/benchmark-world.mjs index fe5ef97e..7a067660 100644 --- a/apps/web/scripts/benchmark-world.mjs +++ b/apps/web/scripts/benchmark-world.mjs @@ -10,6 +10,7 @@ import { parseArgs } from "node:util"; import * as esbuild from "esbuild"; import { polyfillNode } from "esbuild-plugin-polyfill-node"; import puppeteer from "puppeteer-core"; +import { placementProfilePlugin } from "./placement-profile.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const repository = path.resolve(root, "../.."); @@ -27,6 +28,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 +45,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 +155,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 +168,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 ? [placementProfilePlugin()] : []), polyfillNode({ polyfills: { zlib: true } })], }); const requests = new Map(); const html = 'World streaming benchmark
Starting benchmark'; @@ -218,6 +223,14 @@ 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 builds = result.operations.filter(operation => operation.kind === "section-build").length; + if (result.placementProfile.events.filter(event => event.kind === "section-build").length !== builds) { + problems.push("Placement profile: section build count does not match benchmark operations"); + } + } const manifest = []; for (const relative of [...requests.keys()].sort()) { const file = files.get(relative); @@ -246,6 +259,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 sections = report.placementProfile.sections.filter(section => section.pass === run.pass); + console.log(` placement profile: ${sections.reduce((sum, section) => sum + section.builds, 0)} section builds, ${sections.reduce((sum, section) => sum + section.repeatedBuilds, 0)} repeated within a waypoint`); + } } 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..215c9d7a --- /dev/null +++ b/apps/web/scripts/placement-profile.mjs @@ -0,0 +1,118 @@ +import { readFile } from "node:fs/promises"; +import ts from "typescript"; + +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`); + 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"); + 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"); + 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); + 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") { + body(method("createAtlas"), [], 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 }) => { + const name = path.split("/").at(-1).slice(0, -4); + const contents = instrumentPlacementModule(await readFile(path, "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(", ")}` }] }; + }); + } + }; +} From 216addfee5027f44e0ced6d8fa32ed1472581fe8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Haylee=20Sch=C3=A4fer?= Date: Sat, 17 Oct 2026 16:58:48 +0200 Subject: [PATCH 2/2] codex round 1: attribute section builds to own and neighbouring column placements, check drift in the web build --- apps/web/benchmarks/README.md | 18 ++-- apps/web/benchmarks/placement-profile.ts | 105 ++++++++++++----------- apps/web/benchmarks/world.ts | 18 +++- apps/web/build.mjs | 6 ++ apps/web/scripts/benchmark-world.mjs | 11 +-- apps/web/scripts/placement-profile.mjs | 27 +++++- 6 files changed, 106 insertions(+), 79 deletions(-) diff --git a/apps/web/benchmarks/README.md b/apps/web/benchmarks/README.md index 7bd141e7..35078cb2 100644 --- a/apps/web/benchmarks/README.md +++ b/apps/web/benchmarks/README.md @@ -20,23 +20,17 @@ Chrome uses its selected GPU by default. `--software-rendering` requests SwiftSh ## Profile placement -Add `--profile-placement` to record placement stages and identify sections built more than once during a stop. This option requires section meshing. For example: - -```sh -yarn workspace @minerender/web benchmark:world \ - --world "SAVE_FOLDER" --version 1.16.2 \ - --route '[[-15,8],[-7,8],[1,8],[9,8]]' \ - --profile-placement --output /tmp/world-placement.json -``` +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. -- `sections`: build counts by pass, waypoint, and section. `repeatedBuilds` counts calls after the first at that stop. Loading an adjacent column can change a section's border, so repeated calls do not by themselves prove unnecessary work. Returning to a previously visited section is a separate stop. -- `stages`: call counts, failures, 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 or if the profiler's section-build count differs from the benchmark's count. +- `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: @@ -52,7 +46,7 @@ Stage boundaries are: | `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. Run at least three ordinary and three profiled invocations under matching conditions to assess its overhead. 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. +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 diff --git a/apps/web/benchmarks/placement-profile.ts b/apps/web/benchmarks/placement-profile.ts index 73939e8d..148915ea 100644 --- a/apps/web/benchmarks/placement-profile.ts +++ b/apps/web/benchmarks/placement-profile.ts @@ -2,6 +2,7 @@ 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" @@ -15,7 +16,7 @@ export interface PlacementProfileMetadata { z?: number; } -export interface PlacementProfileEvent extends PlacementProfileContext, PlacementProfileMetadata { +export interface PlacementProfileEvent extends PlacementProfileContext, Pick { spanId: number; parentSpanId?: number; kind: PlacementProfileKind; @@ -36,7 +37,7 @@ interface PendingSpan { export interface PlacementProfileHooks { begin(kind: PlacementProfileKind, metadata?: PlacementProfileMetadata, parentId?: number): number; - end(spanId: number, success: boolean, error?: unknown, metadata?: PlacementProfileMetadata): void; + 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; } @@ -46,19 +47,22 @@ interface StageSummary { kind: PlacementProfileKind; count: number; succeeded: number; - failed: number; inclusiveMs: number; unionMs: number; } -interface SectionSummary extends PlacementProfileContext { +interface SectionSummary { + pass: PlacementProfileContext["pass"]; x: number; y: number; z: number; builds: number; succeeded: number; - failed: number; - repeatedBuilds: number; + ownColumnBuilds: number; + neighborBuilds: number; + unloadBuilds: number; + otherBuilds: number; + maxBuildsPerOperation: number; inclusiveMs: number; unionMs: number; } @@ -82,28 +86,21 @@ function unionDuration(events: readonly PlacementProfileEvent[]): number { /** Installs hooks only for the benchmark bundle. Explicit parent IDs preserve ownership across awaits. */ export function installPlacementProfiler(started: number, getContext: () => PlacementProfileContext) { - if (!Number.isFinite(started)) throw new RangeError("The placement profiler start time must be finite"); - if (globalThis.__minerenderPlacementProfile !== undefined) throw new Error("A placement profiler is already installed"); - const descriptor = Object.getOwnPropertyDescriptor(globalThis, GLOBAL_KEY); const events: PlacementProfileEvent[] = []; const spans = new Map(); - const active = new Set(); const issues: string[] = []; - let nextId = 0, restored = false; + let nextId = 0; const metadataCopy = (metadata: PlacementProfileMetadata): PlacementProfileMetadata => { const copy: PlacementProfileMetadata = {}; for (const [key, value] of Object.entries(metadata)) { - if (value === undefined) continue; - if (value === null || typeof value === "string" || typeof value === "boolean" || typeof value === "number" && Number.isFinite(value)) copy[key] = value; - else issues.push(`Ignored invalid ${key} metadata`); + if (value !== undefined) copy[key] = value; } return copy; }; const hooks: PlacementProfileHooks = { begin(kind, metadata = {}, parentId) { - if (restored) return 0; const parent = parentId === undefined ? undefined : spans.get(parentId); if (parentId !== undefined && !parent) issues.push(`Missing parent span ${parentId} for ${kind}`); const spanId = ++nextId; @@ -111,19 +108,15 @@ export function installPlacementProfiler(started: number, getContext: () => Plac spanId, parentSpanId: parentId, kind, context: { ...(parent?.context ?? getContext()) }, metadata: { ...parent?.metadata, ...metadataCopy(metadata) }, startMs: performance.now() - started }); - active.add(spanId); return spanId; }, - end(spanId, success, error, metadata = {}) { - if (!spanId || restored) return; + end(spanId, success, error) { const span = spans.get(spanId); - if (!span || !active.delete(spanId)) { - issues.push(`Unknown or completed span ${spanId}`); - return; - } - const durationMs = Math.max(0, performance.now() - started - span.startMs); + if (!span) return; + spans.delete(spanId); + const durationMs = performance.now() - started - span.startMs; events.push({ - ...span.metadata, ...metadataCopy(metadata), ...span.context, + ...span.metadata, ...span.context, spanId, parentSpanId: span.parentSpanId, kind: span.kind, startMs: span.startMs, durationMs, success, ...(!success && { error: errorMessage(error) }) }); @@ -160,12 +153,11 @@ export function installPlacementProfiler(started: number, getContext: () => Plac Object.defineProperty(globalThis, GLOBAL_KEY, { configurable: true, writable: true, value: hooks }); return { - ...hooks, report() { const stageEvents = new Map(); const sectionEvents = new Map(); const reportIssues = issues.slice(); - for (const spanId of active) reportIssues.push(`Span ${spanId} is still open`); + 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); @@ -176,45 +168,56 @@ export function installPlacementProfiler(started: number, getContext: () => Plac reportIssues.push(`Section build ${event.spanId} has no integer section coordinates`); continue; } - const sectionKey = JSON.stringify([event.pass, event.phase, event.waypoint, event.x, event.y, event.z]); + 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, failed: 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 => ({ - pass: group[0].pass, phase: group[0].phase, waypoint: group[0].waypoint, - x: group[0].x!, y: group[0].y!, z: group[0].z!, builds: group.length, - succeeded: group.filter(event => event.success).length, failed: group.filter(event => !event.success).length, - repeatedBuilds: Math.max(0, group.length - 1), + 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, - incompleteSpans: [...active].map(spanId => { - const span = spans.get(spanId)!; + 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, - notes: [ - "Inclusive stage durations overlap; interval unions count each stage's covered wall time once per pass.", - "Repeated section builds count extra calls at the same section coordinates within one waypoint; they do not prove redundant work." - ] + issues: reportIssues }; }, restore() { - if (restored) return; - restored = true; - if (descriptor) Object.defineProperty(globalThis, GLOBAL_KEY, descriptor); - else delete globalThis.__minerenderPlacementProfile; + delete (globalThis as typeof globalThis & { [GLOBAL_KEY]?: PlacementProfileHooks })[GLOBAL_KEY]; } }; } - -declare global { - var __minerenderPlacementProfile: PlacementProfileHooks | undefined; -} diff --git a/apps/web/benchmarks/world.ts b/apps/web/benchmarks/world.ts index c608824f..2d3b43e4 100644 --- a/apps/web/benchmarks/world.ts +++ b/apps/web/benchmarks/world.ts @@ -2,7 +2,7 @@ import { AnvilParser, AnvilWorldSource, AssetLoader, Env, MineRenderWorld, Renderer, SectionMesh, SectionWorker, WorldStreamer } from "minerender"; -import { installPlacementProfiler } from "./placement-profile"; +import { installPlacementProfiler, PlacementProfileContext } from "./placement-profile"; export interface WorldBenchmarkConfig { version: string; @@ -116,7 +116,9 @@ async function runWorldBenchmark(config: WorldBenchmarkConfig) { const operations: Operation[] = []; const observedDataVersions = new Set(); let context: Pick = { pass: "cold", phase: "outbound", waypoint: null }; - const placementProfile = config.profilePlacement ? installPlacementProfiler(started, () => context) : undefined; + 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 }); @@ -255,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 }); 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 7a067660..57fcc312 100644 --- a/apps/web/scripts/benchmark-world.mjs +++ b/apps/web/scripts/benchmark-world.mjs @@ -10,7 +10,6 @@ import { parseArgs } from "node:util"; import * as esbuild from "esbuild"; import { polyfillNode } from "esbuild-plugin-polyfill-node"; import puppeteer from "puppeteer-core"; -import { placementProfilePlugin } from "./placement-profile.mjs"; const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); const repository = path.resolve(root, "../.."); @@ -168,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: [...(config.profilePlacement ? [placementProfilePlugin()] : []), 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'; @@ -226,10 +225,6 @@ try { 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 builds = result.operations.filter(operation => operation.kind === "section-build").length; - if (result.placementProfile.events.filter(event => event.kind === "section-build").length !== builds) { - problems.push("Placement profile: section build count does not match benchmark operations"); - } } const manifest = []; for (const relative of [...requests.keys()].sort()) { @@ -260,8 +255,8 @@ try { 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 sections = report.placementProfile.sections.filter(section => section.pass === run.pass); - console.log(` placement profile: ${sections.reduce((sum, section) => sum + section.builds, 0)} section builds, ${sections.reduce((sum, section) => sum + section.repeatedBuilds, 0)} repeated within a waypoint`); + 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}`); diff --git a/apps/web/scripts/placement-profile.mjs b/apps/web/scripts/placement-profile.mjs index 215c9d7a..8368fce0 100644 --- a/apps/web/scripts/placement-profile.mjs +++ b/apps/web/scripts/placement-profile.mjs @@ -1,5 +1,6 @@ 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"; @@ -20,6 +21,19 @@ export function instrumentPlacementModule(contents, name) { 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) => @@ -47,6 +61,7 @@ export function instrumentPlacementModule(contents, name) { 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 = []; @@ -63,6 +78,7 @@ export function instrumentPlacementModule(contents, name) { ...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; @@ -80,6 +96,7 @@ export function instrumentPlacementModule(contents, name) { } 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); @@ -91,7 +108,9 @@ export function instrumentPlacementModule(contents, name) { : "{block: blockState.key?.toNamespacedString()}", value)); } } else if (name === "UVMapper") { - body(method("createAtlas"), [], value => wrap("model-atlas", "{model: originalModel.key?.toNamespacedString()}", value)); + 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; @@ -102,9 +121,9 @@ export function placementProfilePlugin() { return { name: "placement-profile", setup(build) { - build.onLoad({ filter: /\/dist\/browser\/esm\/(?:world\/(?:Chunk|SectionMesh|SectionModels)|UVMapper)\.mjs$/ }, async ({ path }) => { - const name = path.split("/").at(-1).slice(0, -4); - const contents = instrumentPlacementModule(await readFile(path, "utf8"), name); + 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" }; });