From 86927f76c4f6a6ed3f56d9a09e7f1bfd1ff30142 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 14:43:47 -0700 Subject: [PATCH 001/130] fix(stress-lab): the scoreboard compares engines on one GPU only Rows #310-#312 ran on the laptop's Iris Xe and the dashboard scored them against Unreal rows from the desktop RTX 2080: "losing 46 to 56" compared two machines. The score, the ladder, the KPI anchor and the trend now read only the rows of the GPU where the latest Unreal row ran. --- scripts/__tests__/stress-loop.spec.ts | 25 +++++++++++++++++++++++++ scripts/stress-dashboard.mjs | 23 +++++++++++++++++++---- 2 files changed, 44 insertions(+), 4 deletions(-) diff --git a/scripts/__tests__/stress-loop.spec.ts b/scripts/__tests__/stress-loop.spec.ts index 69b641c5c9..666d373ab9 100644 --- a/scripts/__tests__/stress-loop.spec.ts +++ b/scripts/__tests__/stress-loop.spec.ts @@ -421,6 +421,31 @@ describe("stress-loop", () => { expect(strip).toContain('data-verdict="winning"'); }); + it("compares engines on one GPU only: a later laptop row does not stand in for the desktop baseline", () => { + const ctx = { loadVoid: 6, shadowOf: (row: { shadow: string }) => row.shadow }; + const row = (engine: string, adapter: string, fps: number, variant: string) => ({ + engine, + adapter, + shadow: "virtual", + variant, + pass: "uncapped", + load: { peak: 1 }, + decision: "measure", + metrics: { presentedP95Ms: 12, fps, frameP95Ms: 12, gpuP95Ms: 12, hitchesPerMin: 0 }, + }); + const desktop = row(WASM, "nvidia / turing", 110, "arm=develop&uncapped"); + const unreal = row("unreal", "NVIDIA GeForce RTX 2080", 70, "tnmatched&uncapped"); + const laptop = row(WASM, "intel / gen-12lp", 40, "arm=develop&uncapped"); + // Rows #310-#312 (Iris Xe) against #288 (RTX 2080) read "losing 46 to 56" on 2026-11-10: two machines, not two engines. + const verdict = verdictOf([desktop, unreal, laptop], ctx); + expect(verdict.status).toBe("winning"); + expect(verdict.rivals.find((rival: { result: string }) => rival.result === "win")?.mine).toBe( + Math.round(scoreOf(desktop.metrics) ?? 0), + ); + // With no rival on its GPU, the laptop row has nothing to beat. + expect(verdictOf([unreal, laptop], ctx).status).toBe("none"); + }); + it("says WAS WINNING in grey when the rows behind the verdict are old, so old data does not read as now", () => { const ctx = { loadVoid: 6, shadowOf: (row: { shadow: string }) => row.shadow }; const row = (engine: string, fps: number, at: string, commit: string) => ({ diff --git a/scripts/stress-dashboard.mjs b/scripts/stress-dashboard.mjs index 3ba94a13c6..17099df630 100644 --- a/scripts/stress-dashboard.mjs +++ b/scripts/stress-dashboard.mjs @@ -63,6 +63,21 @@ const isValid = (row, loadVoid) => Number.isFinite(row.metrics?.presentedP95Ms) && !isVoid(row, loadVoid); +/** The GPU vendor in a row's adapter. A browser row reads "nvidia / turing" and Unreal reads "NVIDIA GeForce RTX 2080": both carry the vendor. */ +export const gpuOf = (row) => + /nvidia|intel|amd|apple|qualcomm|arm/iu.exec(row.adapter ?? "")?.[0].toLowerCase() ?? ""; + +/** + * The rows of the scoreboard GPU: where the latest Unreal row ran, else where the latest row ran. A laptop Iris row + * against a desktop RTX row compares two machines, not two engines (rows #310-#312 against #288 read "losing 46 to + * 56", 2026-11-10). Rows from another GPU stay in the Arms and Ledger tabs. + */ +// ponytail: the key is the vendor, so two hosts with one vendor would mix; add a host field to the row when a second one records. +export function scoreboardRows(rows) { + const reference = rows.findLast((row) => row.engine === "unreal") ?? rows.at(-1); + return reference === undefined ? rows : rows.filter((row) => gpuOf(row) === gpuOf(reference)); +} + /** The latest kept row, else the latest row that was not rejected. */ const pick = (rows) => rows.filter((row) => row.decision !== "reject").findLast((row) => row.decision === "keep") ?? @@ -283,7 +298,7 @@ function rampTab(rows, ctx) { /** One Overview lane: where the develop baseline, the best kept arm and vanilla stand on the ladder. */ export function laneOf(rows, ctx, shadow, pass) { - const mine = rows.filter( + const mine = scoreboardRows(rows).filter( (row) => isValid(row, ctx.loadVoid) && passOf(row) === pass && loadOf(row.variant) === 1, ); const tn = mine.filter( @@ -352,7 +367,7 @@ const TREND_COLOR = ["--s1", "--s2", "--s3", "--s4"]; */ export function trendSeries(rows, { loadVoid, shadowOf }) { const groups = new Map(); - for (const row of rows) { + for (const row of scoreboardRows(rows)) { if (row.engine !== "threenative-wasm" || loadOf(row.variant) > 1 || isVoid(row, loadVoid)) continue; if (passOf(row) === null) continue; @@ -490,7 +505,7 @@ const bar = (fraction, cls) => /** The latest valid uncapped load 1 row of one Unreal setup. Each setup keeps its own bar: they are different pictures. */ const unrealRow = (rows, ctx, setup) => - rows.findLast( + scoreboardRows(rows).findLast( (row) => row.engine === "unreal" && flagsOf(row.variant).includes(setup) && @@ -637,7 +652,7 @@ function scorePanel(rows, ctx) { }) .join(""); const { stale, ageHours } = verdictOf(rows, ctx); - return `
Engine score (uncapped, load 1, same scene and GPU: 100 = the 120 fps goal)${stale ? ` ⚠️ STALE: these rows are ${ageHours} h old, the current engine has no score yet` : ""}
+ return `
Engine score (uncapped, load 1, same scene, ${esc(gpuOf(scoreboardRows(rows).at(-1) ?? {}) || "one")} GPU only: 100 = the 120 fps goal)${stale ? ` ⚠️ STALE: these rows are ${ageHours} h old, the current engine has no score yet` : ""}
${SCORE_PARTS.map(({ short, weight }) => ``).join("")}${body}
EngineScore/100${esc(short)} ${weight}%

Green wins its group, yellow ties at the top, red loses, grey has nothing to compare with yet. Each timing earns goal / value, so half the speed earns half its points, capped at the goal. The small bar under a value is its share of the goal. Compare only inside one group: the groups have different shadow work. Unreal stock also pays for Lumen, a richer picture.

`; } From 3dc61adc8a80b779e36087b66752dfbe0d1c56ef Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 14:57:20 -0700 Subject: [PATCH 002/130] feat(stress-lab): a keep needs a real-game holdout, the tail is measured, the gameplay preset has its own lanes The stress lab is a search tool. A lever that wins only there is benchmark tuning, so the loop now says so: - `record --decision keep` fails without `--holdout ": -> "`, and a row with no decision is a `measure`, not a `keep`. - Uncapped fair runs record presented p99 from the frame starts. The report's `frameP99Ms` and `frameWorstP95Ms` are kept: the median of the windows hid one bad window. - Rows of the `?stress&play` preset (textured materials, mesh variety, street camera, object churn) get their own lanes on the ladder and stay out of the `real` trend. --- scripts/__tests__/stress-fair.spec.ts | 7 ++++ scripts/__tests__/stress-loop.spec.ts | 49 ++++++++++++++++++++++++--- scripts/stress-dashboard.mjs | 33 ++++++++++++++---- scripts/stress-fair.mjs | 5 +-- scripts/stress-loop.mjs | 28 +++++++++++++-- 5 files changed, 106 insertions(+), 16 deletions(-) diff --git a/scripts/__tests__/stress-fair.spec.ts b/scripts/__tests__/stress-fair.spec.ts index d44fbb46bb..66281e875b 100644 --- a/scripts/__tests__/stress-fair.spec.ts +++ b/scripts/__tests__/stress-fair.spec.ts @@ -209,6 +209,13 @@ describe("stress-fair (PRD-770)", () => { expect(presentedFromStarts(seriesOf(gaps), 60)).toEqual({ presentedP50Ms: 15, presentedP95Ms: 20, + presentedP99Ms: 20, + }); + // One 80 ms stutter in 200 frames is invisible to p95 and is the p99. + const stutter = Array.from({ length: 200 }, (_, index) => (index % 67 === 0 ? 80 : 10)); + expect(presentedFromStarts(seriesOf(stutter), 60)).toMatchObject({ + presentedP95Ms: 10, + presentedP99Ms: 80, }); }); diff --git a/scripts/__tests__/stress-loop.spec.ts b/scripts/__tests__/stress-loop.spec.ts index 666d373ab9..f8dd026362 100644 --- a/scripts/__tests__/stress-loop.spec.ts +++ b/scripts/__tests__/stress-loop.spec.ts @@ -14,7 +14,7 @@ const { readLedger, writeLedger, } = await import(new URL("../stress-loop.mjs", import.meta.url).href); -const { rankOf, scoreOf, verdictOf, verdictStrip } = await import( +const { laneOf, rankOf, scoreOf, trendSeries, verdictOf, verdictStrip } = await import( new URL("../stress-dashboard.mjs", import.meta.url).href ); @@ -84,7 +84,7 @@ const THREEJS = "threenative-threejs"; const VANILLA = "three-vanilla"; describe("stress-loop", () => { - it("records the first row as the baseline and the second as keep", () => { + it("records the first row as the baseline and the second as a measurement, never a keep by default", () => { const file = ledgerPath(); const first = appendRow(readLedger(file), measureReport(report()), { label: "first run" }); expect(first.iterations[0].decision).toBe("baseline"); @@ -96,11 +96,23 @@ describe("stress-loop", () => { second.iterations.map((row: { id: number; decision: string }) => [row.id, row.decision]), ).toEqual([ [1, "baseline"], - [2, "keep"], + [2, "measure"], ]); expect(second.iterations[1].preset).toBe("max"); }); + it("refuses a keep without a holdout from a real game: a stress-lab win alone is not a keep", () => { + const ledger = appendRow(readLedger(ledgerPath()), measureReport(report()), { label: "first" }); + const keep = (holdout?: string) => + appendRow(ledger, measureReport(report()), { label: "lever", decision: "keep", holdout }); + expect(() => keep()).toThrow(/keep needs --holdout/u); + expect(() => keep(" ")).toThrow(/keep needs --holdout/u); + expect(keep("shooter kit: frame p50 10.2 -> 9.6 ms").iterations[1]).toMatchObject({ + decision: "keep", + holdout: "shooter kit: frame p50 10.2 -> 9.6 ms", + }); + }); + it("fails closed on a missing metric, never a zero", () => { const { presentedP95Ms: _, ...rest } = report().metrics; expect(() => measureReport(report({ metrics: rest }))).toThrow(/presentedP95Ms/u); @@ -314,7 +326,7 @@ describe("stress-loop", () => { const row = (variant: string, decision: string, gpuP95Ms: number) => [ measureReport(report({ metrics: { ...report().metrics, gpuP95Ms } })), - { label: variant, variant, decision }, + { label: variant, variant, decision, holdout: "shooter kit: frame p50 10.2 -> 9.6 ms" }, ] as const; const ledger = [ row("stress", "measure", 34), @@ -331,7 +343,13 @@ describe("stress-loop", () => { it("puts the apples-to-apples plain sun map table before the virtual shadow table", () => { const ledger = ["plainShadow&arm=stack", "arm=stack"].reduce( - (acc, variant) => recordRun(acc, run(), { label: variant, variant, decision: "keep" }), + (acc, variant) => + recordRun(acc, run(), { + label: variant, + variant, + decision: "keep", + holdout: "shooter kit: frame p50 10.2 -> 9.6 ms", + }), readLedger(ledgerPath()), ); const html = renderHtml(ledger, new Date(0)); @@ -446,6 +464,27 @@ describe("stress-loop", () => { expect(verdictOf([unreal, laptop], ctx).status).toBe("none"); }); + it("keeps the gameplay preset out of the real lanes: a play row is another workload", () => { + const ctx = { loadVoid: 6, shadowOf: (row: { shadow: string }) => row.shadow }; + const row = (variant: string, presentedP95Ms: number) => ({ + engine: WASM, + shadow: "virtual", + variant, + pass: "uncapped", + load: { peak: 1 }, + decision: "measure", + metrics: { presentedP95Ms, fps: 60, frameP95Ms: 12, gpuP95Ms: 12, hitchesPerMin: 0 }, + }); + const real = row("arm=develop&uncapped", 12); + const play = row("play&arm=develop&uncapped", 30); + const rows = [real, play]; + expect(laneOf(rows, ctx, "virtual", "uncapped").baseline).toBe(real); + expect(laneOf(rows, ctx, "virtual", "uncapped", "play").baseline).toBe(play); + expect(trendSeries(rows, ctx).flatMap((series: { rows: unknown[] }) => series.rows)).toEqual([ + real, + ]); + }); + it("says WAS WINNING in grey when the rows behind the verdict are old, so old data does not read as now", () => { const ctx = { loadVoid: 6, shadowOf: (row: { shadow: string }) => row.shadow }; const row = (engine: string, fps: number, at: string, commit: string) => ({ diff --git a/scripts/stress-dashboard.mjs b/scripts/stress-dashboard.mjs index 17099df630..6727d3c579 100644 --- a/scripts/stress-dashboard.mjs +++ b/scripts/stress-dashboard.mjs @@ -51,6 +51,12 @@ const num = (value, digits = 2) => : "—"; const passOf = (row) => row.pass ?? (flagsOf(row.variant).includes("uncapped") ? "uncapped" : null); +/** + * `play` in a variant is the gameplay preset (`?stress&play`): textured materials, several meshes per layer, a street + * camera and object churn. It is another workload, so it never shares a lane or a trend line with `real`. + */ +export const sceneKindOf = (row) => (flagsOf(row.variant).includes("play") ? "play" : "real"); + /** A run is void above the host-load limit, or when its scene tag names a contaminated run. */ export const isVoid = (row, loadVoid) => (row.load?.peak ?? 0) > loadVoid || @@ -297,9 +303,13 @@ function rampTab(rows, ctx) { } /** One Overview lane: where the develop baseline, the best kept arm and vanilla stand on the ladder. */ -export function laneOf(rows, ctx, shadow, pass) { +export function laneOf(rows, ctx, shadow, pass, scene = "real") { const mine = scoreboardRows(rows).filter( - (row) => isValid(row, ctx.loadVoid) && passOf(row) === pass && loadOf(row.variant) === 1, + (row) => + isValid(row, ctx.loadVoid) && + passOf(row) === pass && + loadOf(row.variant) === 1 && + sceneKindOf(row) === scene, ); const tn = mine.filter( (row) => row.engine === "threenative-wasm" && ctx.shadowOf(row) === shadow, @@ -314,6 +324,7 @@ export function laneOf(rows, ctx, shadow, pass) { undefined, ); return { + scene, shadow, pass, baseline: tn.findLast((row) => BASE_ARM.test(armOf(row) ?? "")), @@ -323,8 +334,10 @@ export function laneOf(rows, ctx, shadow, pass) { }; } +const laneName = (lane) => `${lane.scene === "play" ? "play · " : ""}${SHADOW[lane.shadow]}`; + function ladder(lanes) { - const [W, top, laneH, labelW, max] = [560, 24, 44, 138, 36]; + const [W, top, laneH, labelW, max] = [560, 24, 44, 176, 36]; const x = (ms) => labelW + ((W - labelW - 10) * Math.min(ms, max)) / max; const H = top + lanes.length * laneH + 6; const rungs = [...RUNGS, { ms: LINE_30, fps: 30 }] @@ -347,7 +360,7 @@ function ladder(lanes) { const body = lanes .map((lane, i) => { const cy = top + i * laneH + laneH / 2; - return `${SHADOW[lane.shadow]} · ${lane.pass}${mark(lane.vanilla, "vanilla", cy)}${mark(lane.baseline, "baseline", cy - 7)}${mark(lane.kept, "best kept", cy + 7)}`; + return `${laneName(lane)} · ${lane.pass}${mark(lane.vanilla, "vanilla", cy)}${mark(lane.baseline, "baseline", cy - 7)}${mark(lane.kept, "best kept", cy + 7)}`; }) .join(""); return `${rungs}${body}`; @@ -370,7 +383,7 @@ export function trendSeries(rows, { loadVoid, shadowOf }) { for (const row of scoreboardRows(rows)) { if (row.engine !== "threenative-wasm" || loadOf(row.variant) > 1 || isVoid(row, loadVoid)) continue; - if (passOf(row) === null) continue; + if (passOf(row) === null || sceneKindOf(row) !== "real") continue; const key = `${shadowOf(row)}|${passOf(row)}`; groups.set(key, [...(groups.get(key) ?? []), row]); } @@ -661,6 +674,12 @@ function overviewTab(rows, ctx, extra, goals) { const lanes = []; for (const shadow of ["virtual", "plain"]) for (const pass of PASSES) lanes.push(laneOf(rows, ctx, shadow, pass)); + // The gameplay preset gets a lane once it has a row; an unmeasured pass adds no empty line. + for (const shadow of ["virtual", "plain"]) + for (const pass of PASSES) { + const lane = laneOf(rows, ctx, shadow, pass, "play"); + if (lane.baseline !== undefined || lane.kept !== undefined) lanes.push(lane); + } const cell = (row) => { if (row === undefined) return `no valid run`; const v = row.metrics.presentedP95Ms; @@ -679,12 +698,12 @@ function overviewTab(rows, ctx, extra, goals) { const table = `
${lanes .map( (lane) => - `${cell(lane.baseline)}${cell(lane.kept)}${lane.shadow === "plain" ? cell(lane.vanilla) : ``}${verdict(lane)}`, + `${cell(lane.baseline)}${cell(lane.kept)}${lane.shadow === "plain" ? cell(lane.vanilla) : ``}${verdict(lane)}`, ) .join("")}
ScenePassDevelop baseline p95Best kept arm p95Vanilla three.js p95Rung met
${SHADOW[lane.shadow]}${lane.pass}no equivalent
${laneName(lane)}${lane.pass}no equivalent
`; return `${scorePanel(rows, ctx)}
120 fps goal ladder · presented p95 (ms, load 1)
${ladder(lanes)}

● latest develop baseline · ◆ best kept arm · ■ vanilla three.js (plain sun only) · rungs 8.33 / 11.1 / 16.67 ms

-

Where it stands

${table}

VSM is ?stress; plain sun is ?stress&plainShadow. A vsync pass floors at 16.7 ms on a 60 Hz display, so only the uncapped pass can show 90 or 120 fps. Baseline is the latest develop, head or stack* arm.

+

Where it stands

${table}

VSM is ?stress; plain sun is ?stress&plainShadow; play adds &play (textured materials, mesh variety, a street camera, object churn). A vsync pass floors at 16.7 ms on a 60 Hz display, so only the uncapped pass can show 90 or 120 fps. Baseline is the latest develop, head or stack* arm.

${extra} ${trendSection(rows, ctx, goals)}`; } diff --git a/scripts/stress-fair.mjs b/scripts/stress-fair.mjs index aa7853d8ba..aff9c50307 100644 --- a/scripts/stress-fair.mjs +++ b/scripts/stress-fair.mjs @@ -313,7 +313,7 @@ export function recordFrameStarts() { } /** - * Presented p50 and p95 of an uncapped run, from the frame starts in the report's own last `seconds`. + * Presented p50, p95 and p99 of an uncapped run, from the frame starts in the report's own last `seconds`. * With vsync off the rAF timestamp misreads a saturated main thread: stamps read 33.6, 18.5, 1.2 ms where the frames * start 18.4, 18.5, 16.0 ms apart (`tail-probe`, 2026-11-02). Unreal already reports its real frame interval. */ @@ -323,7 +323,8 @@ export function presentedFromStarts(starts, seconds) { if (gaps.length < 2) throw new Error(`stress-fair: ${gaps.length} frame intervals in the window`); const sorted = gaps.sort((a, b) => a - b); const at = (share) => sorted[Math.floor((sorted.length - 1) * share)]; - return { presentedP50Ms: at(0.5), presentedP95Ms: at(0.95) }; + // p99 is the tail a player feels as a stutter; the p95 of a 60 fps run ignores three frames in every second. + return { presentedP50Ms: at(0.5), presentedP95Ms: at(0.95), presentedP99Ms: at(0.99) }; } /** One timing run: a fresh page at 1280x720, nothing attached but the page's own report. */ diff --git a/scripts/stress-loop.mjs b/scripts/stress-loop.mjs index a980222117..b722f03cf8 100644 --- a/scripts/stress-loop.mjs +++ b/scripts/stress-loop.mjs @@ -2,6 +2,7 @@ // The stress lab loop's measure step. // // pnpm stress:loop record --label "" [--decision baseline|keep|reject|measure] [--engine ] [--variant ] [--notes ""] +// a keep also needs --holdout ": -> ": the lab finds a lever, a real game decides it // pnpm stress:loop fair ... [--tag ] [--passes vsync,uncapped] # PRD-770: one fair window, then record // pnpm stress:loop show // pnpm dashboard # the Stress lab tab renders docs/stress-loop/ledger.json on each load @@ -162,6 +163,19 @@ export function measureReport(report, { counted = true } = {}) { "rafStampP95Ms", true, ), + // The tail: p99 of the frame intervals (uncapped fair runs), and the worst window's CPU p95 (the report pools + // windows by median, which hides one bad window). Null on a row from before 2026-11-10 or an arm without them. + presentedP99Ms: metric( + { presentedP99Ms: metrics.presentedP99Ms ?? null }, + "presentedP99Ms", + true, + ), + frameP99Ms: metric({ frameP99Ms: metrics.frameP99Ms ?? null }, "frameP99Ms", true), + frameWorstP95Ms: metric( + { frameWorstP95Ms: metrics.frameWorstP95Ms ?? null }, + "frameWorstP95Ms", + true, + ), fps: metric(metrics, "fps"), gpuP50Ms: metric(metrics, "gpuP50Ms", true), gpuP95Ms: metric(metrics, "gpuP95Ms", true), @@ -202,12 +216,18 @@ export function appendRow( variant = "stress", at = new Date(), extra = {}, + holdout, } = {}, ) { if (typeof label !== "string" || label === "") throw new Error("record needs --label"); - const chosen = decision ?? (ledger.iterations.length === 0 ? "baseline" : "keep"); + const chosen = decision ?? (ledger.iterations.length === 0 ? "baseline" : "measure"); if (!DECISIONS.includes(chosen)) throw new Error(`decision must be one of ${DECISIONS.join(", ")}`); + // The lab finds a lever; a game decides it (owner, 2026-09-27: "we're not benchmaxxing; our fixes must reflect reality"). + if (chosen === "keep" && (typeof holdout !== "string" || holdout.trim() === "")) + throw new Error( + 'a keep needs --holdout ": -> " measured outside the stress lab; record --decision measure until a real game shows the gain, or states "no effect"', + ); if (!ENGINES.some((item) => item.key === engine)) throw new Error(`engine must be one of ${ENGINES.map((item) => item.key).join(", ")}`); if (typeof variant !== "string" || variant === "") @@ -230,6 +250,7 @@ export function appendRow( windows: row.windows, metrics: row.metrics, counts: row.counts, + ...(chosen === "keep" ? { holdout: holdout.trim() } : {}), ...extra, }; return { ...ledger, iterations: [...ledger.iterations, next] }; @@ -299,6 +320,8 @@ export function checkedRun(raw, engine) { /** Engine-boundary costs, tracked per row without a goal until a measured floor sets one. */ export const TRACKED = [ + { key: "presentedP99Ms", short: "Presented p99 (stutter)", unit: "ms" }, + { key: "frameWorstP95Ms", short: "CPU p95, worst window", unit: "ms" }, { key: "bridgeCrossingsP50", short: "JS↔Wasm crossings", unit: "/frame" }, { key: "hostCallsP50", short: "Host (ABI) calls", unit: "/frame" }, { key: "gpuUploadKbP50", short: "GPU upload", unit: "KB/frame" }, @@ -579,6 +602,7 @@ async function main(argv) { label: get("--label"), notes: get("--notes") ?? "", decision: get("--decision"), + holdout: get("--holdout"), engine: get("--engine"), variant: get("--variant"), }); @@ -608,7 +632,7 @@ async function main(argv) { return; } console.error( - "usage: stress-loop.mjs record --label [--decision baseline|keep|reject|measure] [--engine threenative-wasm|threenative-threejs|three-vanilla|unreal] [--variant ] [--notes ] | fair ... | show", + "usage: stress-loop.mjs record --label [--decision baseline|keep|reject|measure] [--holdout ': -> ' (a keep needs it)] [--engine threenative-wasm|threenative-threejs|three-vanilla|unreal] [--variant ] [--notes ] | fair ... | show", ); process.exit(2); } From f7db10c023597d2c344f131e4871c8d53f0a81d3 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 15:02:28 -0700 Subject: [PATCH 003/130] feat(stress-lab): a real game runs as a holdout arm in the same fair window An arm whose URL carries holdout= is a ThreeNative game with no stress report. The window reads its own TN_FRAME_BUDGET lines, skips three warm-up windows and pools the rest as the lab pools its own, so a keep's holdout comes from the same ABBA window, browser and load gate as the lever's rows. A holdout row is no lane. --- scripts/__tests__/stress-fair.spec.ts | 37 +++++++++++ scripts/stress-dashboard.mjs | 16 ++++- scripts/stress-fair.mjs | 96 ++++++++++++++++++++++++++- 3 files changed, 146 insertions(+), 3 deletions(-) diff --git a/scripts/__tests__/stress-fair.spec.ts b/scripts/__tests__/stress-fair.spec.ts index 66281e875b..48f56bb41d 100644 --- a/scripts/__tests__/stress-fair.spec.ts +++ b/scripts/__tests__/stress-fair.spec.ts @@ -9,6 +9,8 @@ const { fileName, parseArm, poolRuns, + holdoutOf, + holdoutReport, presentedFromStarts, saveRun, waitGpuIdle, @@ -197,6 +199,41 @@ describe("stress-fair (PRD-770)", () => { expect(readdirSync(outDir)).toHaveLength(4); }); + describe("holdoutReport", () => { + const window = (frameP50: number) => ({ + fps: 60, + frames: 300, + hitches: 0, + presented: { mean: 16.7, p50: 16.7, p95: 17 }, + frame: { p50: frameP50, p95: frameP50 + 2, p99: frameP50 + 4 }, + phases: { render: { p50: 3 }, update: { p50: 1 } }, + passes: { main: { draws: { p50: 40 }, triangles: { p50: 500_000 } } }, + }); + + it("pools a real game's frame-budget windows after warm-up, so a kit can prove a keep", () => { + // Three warm-up windows (shader compiles) would read 90 ms; the steady ones read 5, 6 and 7. + const windows = [90, 90, 90, 5, 7, 6].map(window); + const report = holdoutReport(windows, { name: "shooter", viewport: "1280x720" }); + expect(report).toMatchObject({ preset: "holdout:shooter", windows: 3 }); + expect(report.metrics).toMatchObject({ + frameP50Ms: 6, + frameP95Ms: 8, + frameWorstP95Ms: 9, + mainDraws: 40, + mainTrianglesM: 0.5, + gpuP95Ms: null, + }); + }); + + it("refuses a game that measured nothing: a holdout with no windows is no holdout", () => { + expect(() => + holdoutReport([90, 90, 90, 5].map(window), { name: "shooter", viewport: "1280x720" }), + ).toThrow(/need 3 or more/u); + expect(holdoutOf("holdout=shooter&arm=base")).toBe("shooter"); + expect(holdoutOf("play&arm=base")).toBeUndefined(); + }); + }); + describe("presentedFromStarts", () => { const seriesOf = (gaps: number[]) => { const starts = [0]; diff --git a/scripts/stress-dashboard.mjs b/scripts/stress-dashboard.mjs index 6727d3c579..0b230ded9c 100644 --- a/scripts/stress-dashboard.mjs +++ b/scripts/stress-dashboard.mjs @@ -55,7 +55,12 @@ const passOf = (row) => row.pass ?? (flagsOf(row.variant).includes("uncapped") ? * `play` in a variant is the gameplay preset (`?stress&play`): textured materials, several meshes per layer, a street * camera and object churn. It is another workload, so it never shares a lane or a trend line with `real`. */ -export const sceneKindOf = (row) => (flagsOf(row.variant).includes("play") ? "play" : "real"); +export const sceneKindOf = (row) => { + const flags = flagsOf(row.variant); + // `holdout=` is a real game measured in the same window: it is a keep's proof, never a lane. + const holdout = flags.find((flag) => flag.startsWith("holdout=")); + return holdout ?? (flags.includes("play") ? "play" : "real"); +}; /** A run is void above the host-load limit, or when its scene tag names a contaminated run. */ export const isVoid = (row, loadVoid) => @@ -724,7 +729,14 @@ export function pairsOf(rows, { loadVoid, shadowOf }) { for (const row of rows) { const tag = /^FAIR window (\S+) \(/u.exec(row.label ?? "")?.[1]; if (tag === undefined || !isValid(row, loadVoid)) continue; - const key = [tag, passOf(row), row.engine, shadowOf(row), loadOf(row.variant)].join("|"); + const key = [ + tag, + passOf(row), + row.engine, + shadowOf(row), + loadOf(row.variant), + sceneKindOf(row), + ].join("|"); groups.set(key, [...(groups.get(key) ?? []), row]); } return [...groups.values()] diff --git a/scripts/stress-fair.mjs b/scripts/stress-fair.mjs index aff9c50307..7091a0a8bf 100644 --- a/scripts/stress-fair.mjs +++ b/scripts/stress-fair.mjs @@ -312,6 +312,92 @@ export function recordFrameStarts() { }); } +/** + * Page script: every `TN_FRAME_BUDGET` window the engine logs, kept in `globalThis.__TN_BUDGET__`. A real game has no + * stress report, and this is the same meter the stress report pools, so a kit game can run as a holdout arm. + */ +export function recordBudgetWindows() { + const windows = []; + globalThis.__TN_BUDGET__ = windows; + const debug = console.debug.bind(console); + console.debug = (...args) => { + if (typeof args[0] === "string" && args[0].startsWith("TN_FRAME_BUDGET:")) + windows.push(JSON.parse(args[0].slice("TN_FRAME_BUDGET:".length))); + debug(...args); + }; +} + +/** Windows a holdout report skips after load: shader compiles, first uploads, the scene's entry. */ +const HOLDOUT_WARMUP_WINDOWS = 3; + +/** + * A stress-lab report from a game's own frame-budget windows, pooled as the lab pools them: the median window per + * metric, hitches summed. Throws on too few windows or a missing meter: a holdout that measured nothing is no holdout. + */ +export function holdoutReport(windows, { name, viewport }) { + const steady = windows.slice(HOLDOUT_WARMUP_WINDOWS); + if (steady.length < 3) + throw new Error( + `holdout ${name}: ${steady.length} frame-budget windows after warm-up, need 3 or more (did the game start?)`, + ); + const median = (read, field) => { + const values = steady.map(read).filter((value) => Number.isFinite(value)); + if (values.length === 0) { + if (field === undefined) return null; + throw new Error(`holdout ${name}: no window measured ${field}`); + } + return Math.round(values.sort((a, b) => a - b)[Math.floor(values.length / 2)] * 100) / 100; + }; + const seconds = steady.reduce((sum, w) => sum + (w.frames * w.presented.mean) / 1000, 0); + return { + schema: "stress-lab-report/1", + commit: "holdout", + preset: `holdout:${name}`, + layers: [], + adapter: "", + viewport, + seconds: Math.round(seconds * 100) / 100, + windows: steady.length, + counts: {}, + metrics: { + presentedP50Ms: median((w) => w.presented.p50, "presented.p50"), + presentedP95Ms: median((w) => w.presented.p95, "presented.p95"), + fps: median((w) => w.fps, "fps"), + gpuP50Ms: median((w) => w.gpu?.p50), + gpuP95Ms: median((w) => w.gpu?.p95), + renderP50Ms: median((w) => w.phases.render.p50, "phases.render.p50"), + updateP50Ms: median((w) => w.phases.update.p50, "phases.update.p50"), + frameP50Ms: median((w) => w.frame.p50, "frame.p50"), + frameP95Ms: median((w) => w.frame.p95, "frame.p95"), + frameP99Ms: median((w) => w.frame.p99, "frame.p99"), + frameWorstP95Ms: Math.max(...steady.map((w) => w.frame.p95)), + mainDraws: median((w) => w.passes?.main?.draws.p50, "passes.main.draws.p50"), + shadowDraws: median((w) => w.passes?.shadow?.draws.p50), + mainTrianglesM: median( + (w) => w.passes?.main?.triangles.p50 / 1e6, + "passes.main.triangles.p50", + ), + hitchesPerMin: + seconds > 0 + ? Math.round((steady.reduce((sum, w) => sum + w.hitches, 0) / seconds) * 6000) / 100 + : 0, + longTasks: null, + loadMs: null, + bridgeCrossingsP50: median((w) => w.counters?.bridgeCrossings?.p50), + hostCallsP50: median((w) => w.counters?.hostCalls?.p50), + gpuUploadKbP50: median((w) => w.counters?.gpuBytes?.p50 / 1024), + jsAllocKbP50: median((w) => w.counters?.jsAllocBytes?.p50 / 1024), + }, + }; +} + +/** The holdout name of an arm: `holdout=` in its URL makes it a real game, measured from its frame-budget windows. */ +export const holdoutOf = (variant) => + variant + .split("&") + .find((flag) => flag.startsWith("holdout=")) + ?.slice("holdout=".length); + /** * Presented p50, p95 and p99 of an uncapped run, from the frame starts in the report's own last `seconds`. * With vsync off the rAF timestamp misreads a saturated main thread: stamps read 33.6, 18.5, 1.2 ms where the frames @@ -332,6 +418,8 @@ async function measureArm(browser, arm, { pass, settle, commit }) { const context = browser.contexts()[0] ?? (await browser.newContext()); const page = await context.newPage(); if (pass === "uncapped") await page.addInitScript(recordFrameStarts); + const holdout = holdoutOf(arm.variant); + if (holdout !== undefined) await page.addInitScript(recordBudgetWindows); const scripts = []; let wasm; page.on("response", (response) => { @@ -350,7 +438,13 @@ async function measureArm(browser, arm, { pass, settle, commit }) { await page.goto(arm.url, { waitUntil: "load", timeout: 120_000 }); await page.waitForTimeout(settle * 1000); }); - const report = await page.evaluate(() => globalThis.__TN_STRESS__ ?? null); + const report = + holdout === undefined + ? await page.evaluate(() => globalThis.__TN_STRESS__ ?? null) + : holdoutReport(await page.evaluate(() => globalThis.__TN_BUDGET__ ?? []), { + name: holdout, + viewport: "1280x720", + }); if (!report?.metrics) throw new Error(`no __TN_STRESS__ report on ${arm.url}`); if (pass === "uncapped") { const starts = await page.evaluate(() => globalThis.__TN_FRAME_STARTS__); From 744a9321c7071b7a4efe3ea80ca1938261e2483c Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 15:25:59 -0700 Subject: [PATCH 004/130] fix(core): auto batch scans what arrived, not the whole scene, after each add or remove A spawn or a despawn at the scene's top two levels restarted the settle pass, and each pass walked the whole scene twice (the snapshot and the settle). A game that throws ten objects a second paid that walk for ever: on the stress lab's new `?play` preset it was 4.1 ms of a 9.8 ms frame (CPU profile), about 2,000 bridge crossings a frame, and the bridge census leaves that walk out by design, so no meter named it. A scan now walks the subtrees that arrived since the last finished scan, plus what an earlier scan could not merge yet: a hidden subtree, a mesh that moved while it was watched, a mesh with no partner. What merges is the same; the tests pin each of those cases, and the merged census on `?play` is equal on both builds (1,260 meshes in 32 draws, 960 in 31 after the bodies wake). Probe on the desktop, one run each, shared GPU (diagnosis, not a judged A/B): crossings 2,085 -> 389 a frame, `children` reads 3,030 -> 12, frame p95 30.3 -> 9.8 ms, p99 34.0 -> 11.4 ms. --- packages/core/__tests__/auto-batch.spec.ts | 87 +++++++++++++++++ packages/core/src/auto-batch.ts | 108 +++++++++++++++------ 2 files changed, 166 insertions(+), 29 deletions(-) diff --git a/packages/core/__tests__/auto-batch.spec.ts b/packages/core/__tests__/auto-batch.spec.ts index cc13c5cef7..19301bf527 100644 --- a/packages/core/__tests__/auto-batch.spec.ts +++ b/packages/core/__tests__/auto-batch.spec.ts @@ -601,3 +601,90 @@ describe("AutoBatch leaves what the game reads as the game wrote it", () => { expect(drawn(plate)).toBe(true); }); }); + +describe("AutoBatch scans what arrived, not the scene", () => { + /** A settled town of 40 under one group, with a count of the reads of that group's `children`. */ + const settledTown = () => { + const scene = new Scene(); + const group = new Group(); + scene.add(group); + town(40, group); + const batch = new AutoBatch(scene, { settleFrames: 3 }); + run(batch, 8); + expect(batch.census.meshes).toBe(40); + const reads = { count: 0 }; + const list = group.children; + Object.defineProperty(group, "children", { + configurable: true, + get: () => { + reads.count += 1; + return list; + }, + }); + return { batch, group, reads, scene }; + }; + const bullet = () => new Mesh(new BoxGeometry(0.2, 0.2, 0.2), new MeshStandardMaterial()); + + it("a spawn and a despawn every few frames never walk the settled town again", () => { + // Stress lab `?play`, 2026-11-10: ten thrown objects a second re-walked 5,000 nodes after each add + // and each remove, 4.1 ms of a 9.8 ms frame, and the bridge census hides that walk by design. + const { batch, reads, scene } = settledTown(); + const live: Mesh[] = []; + for (let frame = 0; frame < 120; frame += 1) { + if (frame % 6 === 0) { + const mesh = bullet(); + scene.add(mesh); + live.push(mesh); + } + if (frame % 6 === 3 && live.length > 3) scene.remove(live.shift() as Mesh); + for (const mesh of live) { + mesh.position.x += 0.1; + mesh.updateMatrixWorld(true); + } + batch.update(); + } + expect(reads.count).toBe(0); + expect(batch.census.meshes).toBe(40); + }); + + it("merges a mesh that moved while it was watched, once it rests and something else arrives", () => { + const { batch, group } = settledTown(); + const material = new MeshStandardMaterial(); + const [a, b] = [new Mesh(new BoxGeometry(), material), new Mesh(new BoxGeometry(), material)]; + group.add(a, b); + for (let frame = 0; frame < 8; frame += 1) { + a.position.x += 1; + a.updateMatrixWorld(true); + batch.update(); + } + expect(batch.census.meshes).toBe(40); + group.add(new Group()); + run(batch, 8); + expect(batch.census.meshes).toBe(42); + }); + + it("merges walls a builder places one at a time: a mesh with no partner waits for one", () => { + const { batch, scene } = settledTown(); + const stone = new MeshStandardMaterial({ name: "stone" }); + scene.add(new Mesh(new BoxGeometry(), stone)); + run(batch, 8); + expect(batch.census.meshes).toBe(40); + scene.add(new Mesh(new BoxGeometry(), stone)); + run(batch, 8); + expect(batch.census.meshes).toBe(42); + }); + + it("merges a hidden subtree after the game shows it and something else arrives", () => { + const { batch, scene } = settledTown(); + const wing = new Group(); + wing.visible = false; + town(6, wing); + scene.add(wing); + run(batch, 8); + expect(batch.census.meshes).toBe(40); + wing.visible = true; + scene.add(new Group()); + run(batch, 8); + expect(batch.census.meshes).toBe(46); + }); +}); diff --git a/packages/core/src/auto-batch.ts b/packages/core/src/auto-batch.ts index 6c3f688b94..9e06baa399 100644 --- a/packages/core/src/auto-batch.ts +++ b/packages/core/src/auto-batch.ts @@ -437,12 +437,24 @@ export class AutoBatch { readonly #levelOne = new Set(); /** Children added to or removed from the scene since the last frame, to start or stop counting. */ #arrived: Object3D[] = []; - readonly #onTopChild = (event: { child: Object3D }): void => { + /** + * The subtrees a scan walks: what was added at the top two levels since the last finished scan, plus what an + * earlier scan could not merge yet (a hidden subtree, a mesh that moved while watched, a mesh with no partner). + * A scan costs what arrived and what still waits, never the scene: ten thrown objects a second re-walked the + * stress lab's 5,000 nodes after every add and every remove, 4.1 ms of a 9.8 ms frame. + * ponytail: a mesh with no partner is walked by every scan; index those by material when a scene holds thousands. + */ + #pending = new Set(); + /** What the scan in progress met and left for the next one: hidden subtrees. */ + #later = new Set(); + readonly #onTopChild = (event: { type: string; child: Object3D }): void => { this.#shapeChanges += 1; this.#arrived.push(event.child); + if (event.type === "childadded") this.#pending.add(event.child); }; - readonly #onChild = (): void => { + readonly #onChild = (event: { type: string; child: Object3D }): void => { this.#shapeChanges += 1; + if (event.type === "childadded") this.#pending.add(event.child); }; constructor(scene: Object3D, options: IAutoBatchOptions = {}) { @@ -457,6 +469,7 @@ export class AutoBatch { scene.addEventListener("childadded", this.#onTopChild); scene.addEventListener("childremoved", this.#onTopChild); this.#arrived = [...scene.children]; + this.#pending = new Set(this.#arrived); } get census(): IAutoBatchCensus { @@ -506,6 +519,7 @@ export class AutoBatch { this.#frames = 0; if (this.#watched.size < 2) { // Nothing here can share a draw: the pass is finished until the scene's shape changes. + this.#finish(this.#watched); this.#known = shape; this.#state = "idle"; } @@ -523,6 +537,7 @@ export class AutoBatch { if (!this.#enabled) return; const meshes = this.#snapshot(); if (meshes.size >= 2) this.#merge([...meshes.keys()]); + this.#finish(meshes); this.#watchEngine(); this.#known = this.#shape(); this.#state = "idle"; @@ -537,6 +552,8 @@ export class AutoBatch { this.#watched.clear(); this.#state = "idle"; this.#known = Number.NaN; + // The next scene's content arrives through the events; what stays under the scene is scanned once more. + this.#pending = new Set(this.#scene.children); } /** @@ -562,51 +579,83 @@ export class AutoBatch { } /** - * Eligible meshes with the world matrix they were drawn at. Runs only while the scene settles, so - * its reads are no game's steady cost and the bridge census leaves them out. + * Eligible meshes under the pending subtrees, with the world matrix they were drawn at. Runs only while + * the scene settles, so its reads are no game's steady cost and the bridge census leaves them out. */ #snapshot(): Map { return unbilled(() => { - this.#scene.updateMatrixWorld(true); const found = new Map(); this.#optedOut = 0; this.#held = 0; + this.#later = new Set(); const held = heldObjects(this.#entities); - this.#walk(this.#scene, false, held, (mesh) => { + const visit = (mesh: Mesh): void => { found.set(mesh, Float64Array.from(mesh.matrixWorld.elements)); - }); + }; + for (const root of [...this.#pending]) { + const off = this.#hangs(root, held); + if (off === "gone") this.#pending.delete(root); + else if (off === "wait") this.#later.add(root); + else { + root.updateWorldMatrix(true, true); + this.#visit(root, off, held, visit); + } + } return found; }); } - #walk( - object: Object3D, + /** + * How a pending subtree hangs under the scene. `gone`: it left the scene, or a pending ancestor walks it. + * `wait`: an ancestor is hidden, held or an LOD, which the game can change. Else whether an ancestor opted it out. + */ + #hangs(root: Object3D, held: ReadonlySet): boolean | "gone" | "wait" { + let off = false; + let wait = false; + for (let node = root.parent; node !== this.#scene; node = node.parent) { + if (node === null || this.#excluded.has(node) || this.#pending.has(node)) return "gone"; + wait ||= !node.visible || (node as { isLOD?: boolean }).isLOD === true || held.has(node); + off ||= node.userData.batch === "off"; + } + return wait ? "wait" : off; + } + + /** + * Ends a scan: the next one walks what this one left (`#later`) and each mesh it found that no merge took. + * A mesh that moved can come to rest, and a mesh with no partner can get one. + */ + #finish(found: ReadonlyMap): void { + this.#pending = this.#later; + this.#later = new Set(); + for (const mesh of found.keys()) if (!sourceGroups.has(mesh)) this.#pending.add(mesh); + } + + #visit( + child: Object3D, off: boolean, held: ReadonlySet, visit: (mesh: Mesh) => void, ): void { - for (const child of object.children) { - if ( - !child.visible || - this.#excluded.has(child) || - (child as { isLOD?: boolean }).isLOD === true - ) - continue; - if (held.has(child)) { - const parts = countMeshes(child); - if (parts <= HELD_SUBTREE_MAX_MESHES) { - this.#held += parts; - continue; - } - } - const skip = off || child.userData.batch === "off"; - // A source already drawn by a merge is not walked into a second one: its `visible` stays true. - if (isMesh(child) && !sourceGroups.has(child) && mergeable(child)) { - if (skip) this.#optedOut += 1; - else visit(child); + if (!child.visible) { + // Hidden now; the game can show it, so the next scan asks again. + this.#later.add(child); + return; + } + if (this.#excluded.has(child) || (child as { isLOD?: boolean }).isLOD === true) return; + if (held.has(child)) { + const parts = countMeshes(child); + if (parts <= HELD_SUBTREE_MAX_MESHES) { + this.#held += parts; + return; } - this.#walk(child, skip, held, visit); } + const skip = off || child.userData.batch === "off"; + // A source already drawn by a merge is not walked into a second one: its `visible` stays true. + if (isMesh(child) && !sourceGroups.has(child) && mergeable(child)) { + if (skip) this.#optedOut += 1; + else visit(child); + } + for (const part of child.children) this.#visit(part, skip, held, visit); } #settle(): void { @@ -623,6 +672,7 @@ export class AutoBatch { this.#unsettled = moved; this.#watched = new Map(); this.#merge([...stable.keys()]); + this.#finish(now); this.#watchEngine(); this.#known = this.#shape(); this.#state = "idle"; From c3f8083a59d08d85654b7910811f297cf041434e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 15:54:54 -0700 Subject: [PATCH 005/130] fix(stress-lab): a build's identity covers the engine JS, and fps counts drawn frames - The fair window hashed the Wasm only, so both arms of a core-only change carried one hash and a paired window could not prove it ran two builds. The hash now covers the Wasm and every script the page loads. - A frame the Wasm engine declines to draw costs near zero and shows the previous image. A holdout report's fps now leaves those frames out, and every row records renderSkipPct (the lab report does the same). --- scripts/__tests__/stress-fair.spec.ts | 7 +++++++ scripts/stress-fair.mjs | 16 +++++++++++++--- scripts/stress-loop.mjs | 8 ++++++++ 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/scripts/__tests__/stress-fair.spec.ts b/scripts/__tests__/stress-fair.spec.ts index 48f56bb41d..359f5499b9 100644 --- a/scripts/__tests__/stress-fair.spec.ts +++ b/scripts/__tests__/stress-fair.spec.ts @@ -215,6 +215,13 @@ describe("stress-fair (PRD-770)", () => { const windows = [90, 90, 90, 5, 7, 6].map(window); const report = holdoutReport(windows, { name: "shooter", viewport: "1280x720" }); expect(report).toMatchObject({ preset: "holdout:shooter", windows: 3 }); + expect(report.metrics).toMatchObject({ fps: 60, renderSkipPct: 0 }); + // A GPU-bound host: the engine declined half the loop frames, so the player saw 30 a second, not 60. + const declined = holdoutReport( + windows.map((entry) => ({ ...entry, renderSkips: 150 })), + { name: "shooter", viewport: "1280x720" }, + ); + expect(declined.metrics).toMatchObject({ fps: 30, renderSkipPct: 50 }); expect(report.metrics).toMatchObject({ frameP50Ms: 6, frameP95Ms: 8, diff --git a/scripts/stress-fair.mjs b/scripts/stress-fair.mjs index 7091a0a8bf..21592f29c7 100644 --- a/scripts/stress-fair.mjs +++ b/scripts/stress-fair.mjs @@ -362,7 +362,15 @@ export function holdoutReport(windows, { name, viewport }) { metrics: { presentedP50Ms: median((w) => w.presented.p50, "presented.p50"), presentedP95Ms: median((w) => w.presented.p95, "presented.p95"), - fps: median((w) => w.fps, "fps"), + fps: median((w) => (w.fps * (w.frames - (w.renderSkips ?? 0))) / w.frames, "fps"), + renderSkipPct: + Math.round( + (10000 * steady.reduce((sum, w) => sum + (w.renderSkips ?? 0), 0)) / + Math.max( + 1, + steady.reduce((sum, w) => sum + w.frames, 0), + ), + ) / 100, gpuP50Ms: median((w) => w.gpu?.p50), gpuP95Ms: median((w) => w.gpu?.p95), renderP50Ms: median((w) => w.phases.render.p50, "phases.render.p50"), @@ -424,7 +432,7 @@ async function measureArm(browser, arm, { pass, settle, commit }) { let wasm; page.on("response", (response) => { const url = response.url(); - if (url.endsWith(".wasm")) wasm = response.body().then((body) => sha12(body)); + if (url.endsWith(".wasm")) wasm = response.body(); else if ( response.request().resourceType() === "script" && url.startsWith(new URL(arm.url).origin) @@ -465,7 +473,9 @@ async function measureArm(browser, arm, { pass, settle, commit }) { const build = wasm === undefined ? { kind: "js", sha256: sha12(...bundle.map(([, body]) => body)), commit } - : { kind: "wasm", sha256: await wasm, commit }; + : // The engine is the Wasm and the JS around it (core, the facade): a core-only change left the Wasm hash + // equal, so a paired window could not prove its two arms were two builds (rows #317-#322). + { kind: "wasm", sha256: sha12(await wasm, ...bundle.map(([, body]) => body)), commit }; return { ...arm, at: new Date().toISOString(), diff --git a/scripts/stress-loop.mjs b/scripts/stress-loop.mjs index b722f03cf8..3fd9ce15aa 100644 --- a/scripts/stress-loop.mjs +++ b/scripts/stress-loop.mjs @@ -176,6 +176,13 @@ export function measureReport(report, { counted = true } = {}) { "frameWorstP95Ms", true, ), + // Loop frames the engine declined to draw, in percent. The lab's `fps` leaves them out since 2026-11-10; a + // GPU-bound host (Iris Xe) declined enough of them to hide every Wasm stage behind a zero median. + renderSkipPct: metric( + { renderSkipPct: metrics.renderSkipPct ?? null }, + "renderSkipPct", + true, + ), fps: metric(metrics, "fps"), gpuP50Ms: metric(metrics, "gpuP50Ms", true), gpuP95Ms: metric(metrics, "gpuP95Ms", true), @@ -322,6 +329,7 @@ export function checkedRun(raw, engine) { export const TRACKED = [ { key: "presentedP99Ms", short: "Presented p99 (stutter)", unit: "ms" }, { key: "frameWorstP95Ms", short: "CPU p95, worst window", unit: "ms" }, + { key: "renderSkipPct", short: "Frames not drawn", unit: "%" }, { key: "bridgeCrossingsP50", short: "JS↔Wasm crossings", unit: "/frame" }, { key: "hostCallsP50", short: "Host (ABI) calls", unit: "/frame" }, { key: "gpuUploadKbP50", short: "GPU upload", unit: "KB/frame" }, From 7191c0c6d644dcf84e61419b4356200321522385 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 16:19:22 -0700 Subject: [PATCH 006/130] chore(stress-lab): ledger rows #313-#322, the laptop baseline, the paired auto batch window and the desktop runs --- docs/stress-loop/ledger.json | 659 +++++++++++++++++++++++++++++++++++ scripts/stress-fair.mjs | 2 +- 2 files changed, 660 insertions(+), 1 deletion(-) diff --git a/docs/stress-loop/ledger.json b/docs/stress-loop/ledger.json index 03f57b9628..8018f4bddd 100644 --- a/docs/stress-loop/ledger.json +++ b/docs/stress-loop/ledger.json @@ -18132,6 +18132,665 @@ "dpr": 1, "pass": "uncapped", "shadow": "virtual" + }, + { + "id": 313, + "at": "2026-11-10T22:08:54.810Z", + "commit": "core@0.3.13", + "label": "FAIR window audit-base-laptop (arm=develop&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=develop&uncapped", + "notes": "fair window audit-base-laptop: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 2 run(s) (0 void), load peak 2.42, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-audit-base-laptop-uncapped-threenative-wasm-127-0-0-1-5741-main-html-stress-arm-develop.json", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.02, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.285, + "presentedP95Ms": 21.412, + "rafStampP95Ms": 17.78, + "presentedP99Ms": 30.178, + "frameP99Ms": 30.135, + "frameWorstP95Ms": 22.93, + "fps": 58.075, + "gpuP50Ms": 268.585, + "gpuP95Ms": 274.095, + "renderP50Ms": 2.395, + "updateP50Ms": 2.25, + "frameP50Ms": 5.185, + "frameP95Ms": 20.85, + "mainDraws": 57, + "shadowDraws": 94, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0.5, + "loadMs": 800.5, + "bridgeCrossingsP50": 26, + "hostCallsP50": 26, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "3e86b378a540", + "commit": "75dc8ce37" + }, + "load": { + "peak": 2.42, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 314, + "at": "2026-11-10T22:08:54.810Z", + "commit": "core@0.3.13", + "label": "FAIR window audit-base-laptop (plainShadow&arm=develop&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "plainShadow&arm=develop&uncapped", + "notes": "fair window audit-base-laptop: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 2 run(s) (0 void), load peak 3.3, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-audit-base-laptop-uncapped-threenative-wasm-127-0-0-1-5741-main-html-stress-plainShadow-arm-develop.json", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.02, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.25, + "presentedP95Ms": 17.787, + "rafStampP95Ms": 17.75, + "presentedP99Ms": 19.748, + "frameP99Ms": 19.565, + "frameWorstP95Ms": 15.355, + "fps": 59.19, + "gpuP50Ms": 279.155, + "gpuP95Ms": 288.22, + "renderP50Ms": 2.76, + "updateP50Ms": 2.67, + "frameP50Ms": 6.12, + "frameP95Ms": 13.965, + "mainDraws": 70, + "shadowDraws": 18, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0, + "loadMs": 621.5, + "bridgeCrossingsP50": 26, + "hostCallsP50": 26, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "3e86b378a540", + "commit": "75dc8ce37" + }, + "load": { + "peak": 3.3, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "plain" + }, + { + "id": 315, + "at": "2026-11-10T22:08:54.810Z", + "commit": "core@0.3.13", + "label": "FAIR window audit-base-laptop (play&arm=develop&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "play&arm=develop&uncapped", + "notes": "fair window audit-base-laptop: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 2 run(s) (0 void), load peak 3.43, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-audit-base-laptop-uncapped-threenative-wasm-127-0-0-1-5741-main-html-stress-play-arm-develop.json", + "preset": "real-play", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 64.22, + "windows": 12, + "metrics": { + "presentedP50Ms": 17.34, + "presentedP95Ms": 54.558, + "rafStampP95Ms": 51.7, + "presentedP99Ms": 79.243, + "frameP99Ms": 77.715, + "frameWorstP95Ms": 66.25, + "fps": 41.475, + "gpuP50Ms": 252.295, + "gpuP95Ms": 257.99, + "renderP50Ms": 2.855, + "updateP50Ms": 3.64, + "frameP50Ms": 10.195, + "frameP95Ms": 50.01, + "mainDraws": 104.5, + "shadowDraws": 1245, + "mainTrianglesM": 3.33, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 148, + "loadMs": 1290.5, + "bridgeCrossingsP50": 499.5, + "hostCallsP50": 499.5, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16, + "materials": 40, + "textures": 72 + }, + "build": { + "kind": "wasm", + "sha256": "3e86b378a540", + "commit": "75dc8ce37" + }, + "load": { + "peak": 3.43, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 316, + "at": "2026-11-10T22:08:54.810Z", + "commit": "core@0.3.13", + "label": "FAIR window audit-base-laptop (play&plainShadow&arm=develop&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "play&plainShadow&arm=develop&uncapped", + "notes": "fair window audit-base-laptop: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 2 run(s) (0 void), load peak 3.55, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-audit-base-laptop-uncapped-threenative-wasm-127-0-0-1-5741-main-html-stress-play-plainShadow-arm-develop.json", + "preset": "real-play", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.44, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.245, + "presentedP95Ms": 44.86, + "rafStampP95Ms": 34.925, + "presentedP99Ms": 55.465, + "frameP99Ms": 54.08, + "frameWorstP95Ms": 46.705, + "fps": 45.665, + "gpuP50Ms": 259.56, + "gpuP95Ms": 294.89, + "renderP50Ms": 3.285, + "updateP50Ms": 3.69, + "frameP50Ms": 11.29, + "frameP95Ms": 44.48, + "mainDraws": 108, + "shadowDraws": 53, + "mainTrianglesM": 3.33, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 68, + "loadMs": 1285.5, + "bridgeCrossingsP50": 485, + "hostCallsP50": 485, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16, + "materials": 40, + "textures": 72 + }, + "build": { + "kind": "wasm", + "sha256": "3e86b378a540", + "commit": "75dc8ce37" + }, + "load": { + "peak": 3.55, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "plain" + }, + { + "id": 317, + "at": "2026-11-10T23:19:12.624Z", + "commit": "core@0.3.13", + "label": "FAIR window ab1-laptop (play&arm=p5741&uncapped): base 75dc8ce37", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "play&arm=p5741&uncapped", + "notes": "paired ABBA window, Iris Xe, uncapped, mean of 2 runs; base arm of the auto batch fix; fps counts drawn frames", + "preset": "real-play", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 63.14, + "windows": 12, + "metrics": { + "presentedP50Ms": 17.338, + "presentedP95Ms": 53.82, + "rafStampP95Ms": 52.05, + "presentedP99Ms": 80.21, + "frameP99Ms": 78.21, + "frameWorstP95Ms": 62.005, + "renderSkipPct": 91.515, + "fps": 3.585, + "gpuP50Ms": 261.535, + "gpuP95Ms": 302.98, + "renderP50Ms": 3.175, + "updateP50Ms": 3.845, + "frameP50Ms": 11.14, + "frameP95Ms": 55.24, + "mainDraws": 107, + "shadowDraws": 1431.5, + "mainTrianglesM": 3.33, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 175.5, + "loadMs": 1349, + "bridgeCrossingsP50": 500.5, + "hostCallsP50": 500.5, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16, + "materials": 40, + "textures": 72 + }, + "build": { + "kind": "wasm", + "sha256": "1a9e572f0902", + "commit": "744a9321c" + }, + "load": { + "peak": 2.81, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 318, + "at": "2026-11-10T23:19:12.626Z", + "commit": "core@0.3.13", + "label": "FAIR window ab1-laptop (play&arm=p5744&uncapped): auto batch scans what arrived 744a9321c", + "decision": "keep", + "engine": "threenative-wasm", + "variant": "play&arm=p5744&uncapped", + "notes": "paired ABBA window, Iris Xe, uncapped, mean of 2 runs; CPU frame p50 11.14 -> 7.71 ms, p95 55.24 -> 36.34 ms; drawn fps flat (GPU-bound)", + "preset": "real-play", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 61.63, + "windows": 12, + "metrics": { + "presentedP50Ms": 17.273, + "presentedP95Ms": 36.01, + "rafStampP95Ms": 34.415, + "presentedP99Ms": 61.97, + "frameP99Ms": 62.215, + "frameWorstP95Ms": 43.85, + "renderSkipPct": 93.29, + "fps": 3.52, + "gpuP50Ms": 279.005, + "gpuP95Ms": 287.47, + "renderP50Ms": 2.575, + "updateP50Ms": 3.165, + "frameP50Ms": 7.705, + "frameP95Ms": 36.34, + "mainDraws": 105, + "shadowDraws": 1372, + "mainTrianglesM": 3.33, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 68.5, + "loadMs": 1511.5, + "bridgeCrossingsP50": 501, + "hostCallsP50": 501, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16, + "materials": 40, + "textures": 72 + }, + "holdout": "tower-defense kit + midway (auto batch on): frame p50 no effect (td 3.06-3.65 -> 3.43-5.84 ms on a shared GPU, midway 7.03 -> 7.05 ms); same merged meshes and draws; cockpit stick and needles present", + "build": { + "kind": "wasm", + "sha256": "8fa4d19e19df", + "commit": "744a9321c" + }, + "load": { + "peak": 3.43, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 319, + "at": "2026-11-10T23:19:12.626Z", + "commit": "core@0.3.13", + "label": "FAIR window ab1-desktop (play&arm=base&uncapped): base 75dc8ce37", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "play&arm=base&uncapped", + "notes": "desktop window ab1-desktop, uncapped, ONE run (the GPU idle guard refused the rest of the window); build hash covers the Wasm only, the arms differ in core JS", + "preset": "real-play", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 65.28, + "windows": 13, + "metrics": { + "presentedP50Ms": 25.979999999981374, + "presentedP95Ms": 39.94000000000233, + "rafStampP95Ms": 46.07, + "presentedP99Ms": 47.36499999999069, + "frameP99Ms": 48.95, + "frameWorstP95Ms": 45.85, + "renderSkipPct": null, + "fps": 39.71, + "gpuP50Ms": 8.03, + "gpuP95Ms": 9.66, + "renderP50Ms": 13.26, + "updateP50Ms": 2.83, + "frameP50Ms": 25.52, + "frameP95Ms": 38.9, + "mainDraws": 104, + "shadowDraws": 135, + "mainTrianglesM": 3.33, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 18, + "loadMs": 1363, + "bridgeCrossingsP50": 537, + "hostCallsP50": 537, + "gpuUploadKbP50": 1277.13, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16, + "materials": 40, + "textures": 72 + }, + "build": { + "kind": "wasm", + "sha256": "3e86b378a540", + "commit": "744a9321c" + }, + "load": { + "peak": 5.26, + "samples": 10 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 320, + "at": "2026-11-10T23:19:12.627Z", + "commit": "core@0.3.13", + "label": "FAIR window ab1-desktop (play&arm=ab1&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "play&arm=ab1&uncapped", + "notes": "desktop window ab1-desktop, uncapped, ONE run (the GPU idle guard refused the rest of the window); build hash covers the Wasm only, the arms differ in core JS", + "preset": "real-play", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 64.25, + "windows": 12, + "metrics": { + "presentedP50Ms": 16.10999999998603, + "presentedP95Ms": 36.39500000001863, + "rafStampP95Ms": 38.56, + "presentedP99Ms": 52.300000000046566, + "frameP99Ms": 41.35, + "frameWorstP95Ms": 69.09, + "renderSkipPct": null, + "fps": 49.36, + "gpuP50Ms": 6.88, + "gpuP95Ms": 8.39, + "renderP50Ms": 12.92, + "updateP50Ms": 2.54, + "frameP50Ms": 17.01, + "frameP95Ms": 37.13, + "mainDraws": 104, + "shadowDraws": 135, + "mainTrianglesM": 3.33, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 31, + "loadMs": 1349, + "bridgeCrossingsP50": 535, + "hostCallsP50": 535, + "gpuUploadKbP50": 1277.13, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16, + "materials": 40, + "textures": 72 + }, + "build": { + "kind": "wasm", + "sha256": "3e86b378a540", + "commit": "744a9321c" + }, + "load": { + "peak": 9.93, + "samples": 10 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 321, + "at": "2026-11-10T23:19:12.627Z", + "commit": "core@0.3.13", + "label": "FAIR window ab1-desktop (arm=base&uncapped): base 75dc8ce37", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=base&uncapped", + "notes": "desktop window ab1-desktop, uncapped, ONE run (the GPU idle guard refused the rest of the window); build hash covers the Wasm only, the arms differ in core JS", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 65, + "windows": 13, + "metrics": { + "presentedP50Ms": 11.32999999995809, + "presentedP95Ms": 14.684999999997672, + "rafStampP95Ms": 14.13, + "presentedP99Ms": 17.739999999990687, + "frameP99Ms": 15.16, + "frameWorstP95Ms": 16.87, + "renderSkipPct": null, + "fps": 86.81, + "gpuP50Ms": 5.66, + "gpuP95Ms": 6.01, + "renderP50Ms": 9.34, + "updateP50Ms": 1.25, + "frameP50Ms": 11.02, + "frameP95Ms": 13.12, + "mainDraws": 57, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0, + "loadMs": 498, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 1195.82, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "3e86b378a540", + "commit": "744a9321c" + }, + "load": { + "peak": 7.4, + "samples": 10 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 322, + "at": "2026-11-10T23:19:12.627Z", + "commit": "core@0.3.13", + "label": "FAIR window ab1-desktop (arm=ab1&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=ab1&uncapped", + "notes": "desktop window ab1-desktop, uncapped, ONE run (the GPU idle guard refused the rest of the window); build hash covers the Wasm only, the arms differ in core JS", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 66.5, + "windows": 13, + "metrics": { + "presentedP50Ms": 10.684999999997672, + "presentedP95Ms": 14.160000000032596, + "rafStampP95Ms": 13.63, + "presentedP99Ms": 17.119999999995343, + "frameP99Ms": 13.98, + "frameWorstP95Ms": 17.47, + "renderSkipPct": null, + "fps": 92.68, + "gpuP50Ms": 5.69, + "gpuP95Ms": 6.05, + "renderP50Ms": 8.81, + "updateP50Ms": 1.18, + "frameP50Ms": 10.38, + "frameP95Ms": 12.65, + "mainDraws": 57, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 6, + "loadMs": 556, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 1194.44, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "3e86b378a540", + "commit": "744a9321c" + }, + "load": { + "peak": 7.43, + "samples": 10 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" } ] } diff --git a/scripts/stress-fair.mjs b/scripts/stress-fair.mjs index 21592f29c7..bdeeca9652 100644 --- a/scripts/stress-fair.mjs +++ b/scripts/stress-fair.mjs @@ -474,7 +474,7 @@ async function measureArm(browser, arm, { pass, settle, commit }) { wasm === undefined ? { kind: "js", sha256: sha12(...bundle.map(([, body]) => body)), commit } : // The engine is the Wasm and the JS around it (core, the facade): a core-only change left the Wasm hash - // equal, so a paired window could not prove its two arms were two builds (rows #317-#322). + // equal, so a paired window could not prove its two arms were two builds (rows #319-#322). { kind: "wasm", sha256: sha12(await wasm, ...bundle.map(([, body]) => body)), commit }; return { ...arm, From dba89d896373ce0711a4f4bf3a1ad8a81d9022f8 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 16:20:57 -0700 Subject: [PATCH 007/130] docs(perf): the 2026-11-10 stress lab audit and the auto batch lever --- PERFORMANCE-LEARNINGS.md | 53 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 53 insertions(+) diff --git a/PERFORMANCE-LEARNINGS.md b/PERFORMANCE-LEARNINGS.md index 7ebbb977fe..4c4e12c323 100644 --- a/PERFORMANCE-LEARNINGS.md +++ b/PERFORMANCE-LEARNINGS.md @@ -77,6 +77,59 @@ PRD's "Tried and rejected" or "Decisions" section, for example published state re-renders at the publish rate, not the rate of what it shows. `useUiState(selector)` compares the result by identity, so a selector that builds a new object or array renders as often as no selector. +## 2026-11-10 Stress lab audit (method, the `play` preset, auto batch) + +An audit of the stress loop for benchmark tuning. Rows #313-#322 in `docs/stress-loop/ledger.json`. + +Method traps found, each one fixed in the tool unless it says open: + +- **One GPU per comparison.** The scoreboard put laptop Iris Xe rows of one engine beside desktop RTX 2080 rows of + Unreal. It now compares engines on the adapter of the latest row only (`86927f76c`). +- **A keep needs a real game.** `record` defaulted to `keep`. It now defaults to `measure`, and a keep needs + `--holdout ": -> "`. "No effect" is a valid holdout result when the game does not + use the path (`3dc61adc8`). +- **The lab scene was benchmark-shaped.** `real` has flat colours, one clip, a fixed orbit and no object that is + added or removed. `?stress&play` adds textures, a walk with camera cuts, clip changes and ten spawns a second, in + both labs. The first engine bug below shows only there. +- **fps counted frames the engine did not draw.** The Wasm engine declines a loop frame while the GPU queue is + full. On Iris Xe the `play` lab read 41.5 fps while it drew 3.6 frames a second (92% declined, 260 ms of GPU per + drawn frame). `fps` now counts drawn frames and every row carries `renderSkipPct` (`c3f8083a5`). +- **The tail was hidden.** A median of window p95 values hides one bad window. Rows carry frame p99 and the worst + window p95. +- **A build hash of the Wasm alone cannot tell two arms apart** when the change is in core or the facade (rows + #319-#322 share one hash). The hash now covers the Wasm and every script the page loads. +- **The bridge census does not bill the auto batch scan** (`unbilled()`), by design. A 4 ms per frame cost had no + meter. Read `__tnEngineCounters().childrenReads` per frame when the census and the frame time disagree. +- **Lab on the published kit, production bundle.** The lab was pinned to 0.3.12 on the dev server. It now installs + the develop tarballs and serves `dist`. +- **Desktop host.** A headed GPU browser launch with the RTX 2080 near full memory came just before a hard restart + of the desktop (cause not proven). Read `nvidia-smi` memory first, and send the run to the laptop when the other + browsers hold the GPU: the idle guard refuses the run after 10 minutes anyway. +- Open: the goals are proxies (main draws, JS render p50) and fps counts twice in the score. Unreal has no `play` + arm. The vanilla arm has no physics engine and one sun map. + +### 2026-11-10 — Auto batch scans what arrived, not the scene, after each add or remove — KEEP + +- Where: `packages/core/src/auto-batch.ts` (`#pending`, `#hangs`, `#visit`, `#finish`); `744a9321c`. +- Cause: every `childadded` or `childremoved` at the top two scene levels re-walked the whole scene twice (quiet, + then watching). The facade drops every `children` cache on any hierarchy move, so each walk crossed the bridge + once per node. Ten spawns a second kept the scan running in every frame. +- Probe (desktop, one run each, diagnosis): crossings 2,085 to 389 per frame, `children` reads 3,030 to 12. +- Laptop paired window, Iris Xe, uncapped, 2 ABBA pairs (#317, #318): CPU frame p50 11.14 to 7.71 ms, p95 55.24 to + 36.34 ms, p99 78.21 to 62.22 ms. Drawn fps is flat at 3.5: that host is GPU-bound. +- Desktop RTX 2080, uncapped, single runs because the GPU guard refused the rest (#319-#322): `play` 39.71 to 49.36 + fps, CPU frame p50 25.52 to 17.01 ms; `real` 86.81 to 92.68 fps (second pair 89.13 to 93.98). +- Holdout: no effect on the tower defense kit (its performance scenario) or on Midway with auto batch on. Neither + adds objects at the top two levels during play (`hierarchyMoves` 0.01 per frame). The merged meshes and draws + are equal in both arms on the lab, the kit and Midway. +- Visual judge (fresh, read-only, Midway cockpit, 2 runs per arm): NEUTRAL on all 4 pairs. The control stick and + every gauge needle are present (the parts that `4a8abe0a3` fixed). +- Tests: `auto-batch.spec.ts` "scans what arrived, not the scene" (the settled town's `children` are not read + again, 78 reads before), a mover merges after it rests, lone meshes wait for a partner, a hidden subtree merges + after it is shown. +- Ceiling: a mesh with no partner is walked by every scan, and `heldObjects()` and `mergeByMaterial` are still + O(scene) per merge. Index by material when a scene holds thousands of lone meshes. + ## 2026-10-29 Stress lab (web Wasm engine, Chrome DevTools MCP, RTX 2080) The sandbox scene `sandbox/stress-lab` (`?stress`, real preset) holds houses, a forest, a skinned crowd, physics From 20362a6f92f557f72c509c0c5e0acfc7ad7fa76e Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 16:47:27 -0700 Subject: [PATCH 008/130] feat(stress-lab): the ledger keeps the GPU frame by pass; rows #323-#331, the laptop shadow split and the adaptive lane --- docs/stress-loop/ledger.json | 620 +++++++++++++++++++++++++++++++++++ scripts/stress-loop.mjs | 7 + 2 files changed, 627 insertions(+) diff --git a/docs/stress-loop/ledger.json b/docs/stress-loop/ledger.json index 8018f4bddd..a4f9299e17 100644 --- a/docs/stress-loop/ledger.json +++ b/docs/stress-loop/ledger.json @@ -18791,6 +18791,626 @@ "dpr": 1, "pass": "uncapped", "shadow": "virtual" + }, + { + "id": 323, + "at": "2026-11-10T23:47:17.889Z", + "commit": "core@0.3.13", + "label": "FAIR window split-laptop (arm=p5744&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=p5744&uncapped", + "notes": "laptop window split-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs, tier pinned high; shadows are not the owner of the GPU frame (plain shadow map draws no faster)", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.02, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.29, + "presentedP95Ms": 21.068, + "rafStampP95Ms": 17.76, + "presentedP99Ms": 29.237, + "frameP99Ms": 29.125, + "frameWorstP95Ms": 23.625, + "renderSkipPct": 93.6, + "fps": 3.7, + "gpuP50Ms": 268.025, + "gpuP95Ms": 277.69, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 2.335, + "updateP50Ms": 2.25, + "frameP50Ms": 5.165, + "frameP95Ms": 20.93, + "mainDraws": 57, + "shadowDraws": 112, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0, + "loadMs": 814.5, + "bridgeCrossingsP50": 26, + "hostCallsP50": 26, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "8fa4d19e19df", + "commit": "744a9321c" + }, + "load": { + "peak": 2.43, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 324, + "at": "2026-11-10T23:47:17.891Z", + "commit": "core@0.3.13", + "label": "FAIR window split-laptop (plainShadow&arm=p5744&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "plainShadow&arm=p5744&uncapped", + "notes": "laptop window split-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs, tier pinned high; shadows are not the owner of the GPU frame (plain shadow map draws no faster)", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.252, + "presentedP95Ms": 17.772, + "rafStampP95Ms": 17.745, + "presentedP99Ms": 18.632, + "frameP99Ms": 18.03, + "frameWorstP95Ms": 15.125, + "renderSkipPct": 93.87, + "fps": 3.595, + "gpuP50Ms": 276.82, + "gpuP95Ms": 290.26, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 2.67, + "updateP50Ms": 2.555, + "frameP50Ms": 5.8, + "frameP95Ms": 13.39, + "mainDraws": 70, + "shadowDraws": 18, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0, + "loadMs": 655, + "bridgeCrossingsP50": 26, + "hostCallsP50": 26, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "8fa4d19e19df", + "commit": "744a9321c" + }, + "load": { + "peak": 2.9, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "plain" + }, + { + "id": 325, + "at": "2026-11-10T23:47:17.891Z", + "commit": "core@0.3.13", + "label": "FAIR window split-laptop (play&arm=p5744&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "play&arm=p5744&uncapped", + "notes": "laptop window split-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs, tier pinned high; shadows are not the owner of the GPU frame (plain shadow map draws no faster)", + "preset": "real-play", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 66.28, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.263, + "presentedP95Ms": 38.625, + "rafStampP95Ms": 34.425, + "presentedP99Ms": 60.977, + "frameP99Ms": 59.785, + "frameWorstP95Ms": 47.21, + "renderSkipPct": 93.285, + "fps": 3.475, + "gpuP50Ms": 254.65, + "gpuP95Ms": 302.96, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 2.645, + "updateP50Ms": 3.1, + "frameP50Ms": 7.635, + "frameP95Ms": 39.32, + "mainDraws": 104.5, + "shadowDraws": 1278, + "mainTrianglesM": 3.33, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 77.5, + "loadMs": 1395, + "bridgeCrossingsP50": 501.5, + "hostCallsP50": 501.5, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16, + "materials": 40, + "textures": 72 + }, + "build": { + "kind": "wasm", + "sha256": "8fa4d19e19df", + "commit": "744a9321c" + }, + "load": { + "peak": 2.87, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 326, + "at": "2026-11-10T23:47:17.891Z", + "commit": "core@0.3.13", + "label": "FAIR window split-laptop (play&plainShadow&arm=p5744&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "play&plainShadow&arm=p5744&uncapped", + "notes": "laptop window split-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs, tier pinned high; shadows are not the owner of the GPU frame (plain shadow map draws no faster)", + "preset": "real-play", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.01, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.26, + "presentedP95Ms": 20.62, + "rafStampP95Ms": 17.765, + "presentedP99Ms": 34.418, + "frameP99Ms": 30.625, + "frameWorstP95Ms": 21.585, + "renderSkipPct": 93.97, + "fps": 3.495, + "gpuP50Ms": 275.86, + "gpuP95Ms": 310.795, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 3.055, + "updateP50Ms": 3.61, + "frameP50Ms": 8.8, + "frameP95Ms": 18.54, + "mainDraws": 111, + "shadowDraws": 53, + "mainTrianglesM": 3.33, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0, + "loadMs": 1352, + "bridgeCrossingsP50": 499, + "hostCallsP50": 499, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16, + "materials": 40, + "textures": 72 + }, + "build": { + "kind": "wasm", + "sha256": "8fa4d19e19df", + "commit": "744a9321c" + }, + "load": { + "peak": 2.79, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "plain" + }, + { + "id": 327, + "at": "2026-11-10T23:47:17.892Z", + "commit": "core@0.3.13", + "label": "FAIR window adapt-laptop (arm=p5745&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=p5745&uncapped", + "notes": "laptop window adapt-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; `adaptive` lets the kit quality policy react (it lands on low), the other arm pins high", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.01, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.263, + "presentedP95Ms": 22.035, + "rafStampP95Ms": 17.78, + "presentedP99Ms": 33.133, + "frameP99Ms": 31.235, + "frameWorstP95Ms": 23.505, + "renderSkipPct": 93.775, + "fps": 3.5, + "gpuP50Ms": 283.71, + "gpuP95Ms": 297.995, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 2.435, + "updateP50Ms": 2.29, + "frameP50Ms": 5.23, + "frameP95Ms": 21.48, + "mainDraws": 57, + "shadowDraws": 104, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0.5, + "loadMs": 772, + "bridgeCrossingsP50": 26, + "hostCallsP50": 26, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "0f8dd9163f2e", + "commit": "744a9321c" + }, + "load": { + "peak": 2.49, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 328, + "at": "2026-11-10T23:47:17.892Z", + "commit": "core@0.3.13", + "label": "FAIR window adapt-laptop (adaptive&arm=p5745&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "adaptive&arm=p5745&uncapped", + "notes": "laptop window adapt-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; `adaptive` lets the kit quality policy react (it lands on low), the other arm pins high", + "preset": "real-adaptive-low", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.01, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.257, + "presentedP95Ms": 17.835, + "rafStampP95Ms": 17.765, + "presentedP99Ms": 20.545, + "frameP99Ms": 19.435, + "frameWorstP95Ms": 17.34, + "renderSkipPct": 89.14, + "fps": 6.495, + "gpuP50Ms": 155.11, + "gpuP95Ms": 157.435, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 1.745, + "updateP50Ms": 1.855, + "frameP50Ms": 4.15, + "frameP95Ms": 15.75, + "mainDraws": 41, + "shadowDraws": 110, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 1, + "loadMs": 696.5, + "bridgeCrossingsP50": 26, + "hostCallsP50": 26, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "0f8dd9163f2e", + "commit": "744a9321c" + }, + "load": { + "peak": 2.54, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 329, + "at": "2026-11-10T23:47:17.892Z", + "commit": "core@0.3.13", + "label": "FAIR window adapt-laptop (play&arm=p5745&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "play&arm=p5745&uncapped", + "notes": "laptop window adapt-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; `adaptive` lets the kit quality policy react (it lands on low), the other arm pins high", + "preset": "real-play", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 66.46, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.272, + "presentedP95Ms": 35.178, + "rafStampP95Ms": 34.4, + "presentedP99Ms": 60.803, + "frameP99Ms": 60, + "frameWorstP95Ms": 42.4, + "renderSkipPct": 93.525, + "fps": 3.45, + "gpuP50Ms": 266.01, + "gpuP95Ms": 311.015, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 2.465, + "updateP50Ms": 3.04, + "frameP50Ms": 7.425, + "frameP95Ms": 34.76, + "mainDraws": 105.5, + "shadowDraws": 1030, + "mainTrianglesM": 3.33, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 70.5, + "loadMs": 1421.5, + "bridgeCrossingsP50": 500.5, + "hostCallsP50": 500.5, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16, + "materials": 40, + "textures": 72 + }, + "build": { + "kind": "wasm", + "sha256": "0f8dd9163f2e", + "commit": "744a9321c" + }, + "load": { + "peak": 2.68, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 330, + "at": "2026-11-10T23:47:17.892Z", + "commit": "core@0.3.13", + "label": "FAIR window adapt-laptop (play&adaptive&arm=p5745&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "play&adaptive&arm=p5745&uncapped", + "notes": "laptop window adapt-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; `adaptive` lets the kit quality policy react (it lands on low), the other arm pins high", + "preset": "real-play-adaptive-low", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.11, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.25, + "presentedP95Ms": 33.345, + "rafStampP95Ms": 26.11, + "presentedP99Ms": 43.748, + "frameP99Ms": 42.83, + "frameWorstP95Ms": 36.575, + "renderSkipPct": 90.05, + "fps": 5.9, + "gpuP50Ms": 165.11, + "gpuP95Ms": 189.295, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 1.96, + "updateP50Ms": 2.495, + "frameP50Ms": 6.3, + "frameP95Ms": 32.975, + "mainDraws": 88.5, + "shadowDraws": 1028, + "mainTrianglesM": 3.33, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 13, + "loadMs": 1445, + "bridgeCrossingsP50": 498.5, + "hostCallsP50": 498.5, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16, + "materials": 40, + "textures": 72 + }, + "build": { + "kind": "wasm", + "sha256": "0f8dd9163f2e", + "commit": "744a9321c" + }, + "load": { + "peak": 2.7, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 331, + "at": "2026-11-10T23:47:17.892Z", + "commit": "core@0.3.13", + "label": "FAIR window ab1-desktop (arm=head&uncapped): current engine 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=head&uncapped", + "notes": "the SAME run file as row #322, recorded with arm=head so the scoreboard reads it (it ignores other arm names); desktop RTX 2080, uncapped, ONE run", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 66.5, + "windows": 13, + "metrics": { + "presentedP50Ms": 10.684999999997672, + "presentedP95Ms": 14.160000000032596, + "rafStampP95Ms": 13.63, + "presentedP99Ms": 17.119999999995343, + "frameP99Ms": 13.98, + "frameWorstP95Ms": 17.47, + "renderSkipPct": null, + "fps": 92.68, + "gpuP50Ms": 5.69, + "gpuP95Ms": 6.05, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 8.81, + "updateP50Ms": 1.18, + "frameP50Ms": 10.38, + "frameP95Ms": 12.65, + "mainDraws": 57, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 6, + "loadMs": 556, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 1194.44, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "3e86b378a540", + "commit": "744a9321c" + }, + "load": { + "peak": 7.43, + "samples": 10 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" } ] } diff --git a/scripts/stress-loop.mjs b/scripts/stress-loop.mjs index 3fd9ce15aa..bd1b20b92b 100644 --- a/scripts/stress-loop.mjs +++ b/scripts/stress-loop.mjs @@ -186,6 +186,10 @@ export function measureReport(report, { counted = true } = {}) { fps: metric(metrics, "fps"), gpuP50Ms: metric(metrics, "gpuP50Ms", true), gpuP95Ms: metric(metrics, "gpuP95Ms", true), + // The GPU frame by pass (main scene, every shadow pass, the rest): which pass owns a GPU-bound frame. + gpuMainMs: metric({ gpuMainMs: metrics.gpuMainMs ?? null }, "gpuMainMs", true), + gpuShadowMs: metric({ gpuShadowMs: metrics.gpuShadowMs ?? null }, "gpuShadowMs", true), + gpuOtherMs: metric({ gpuOtherMs: metrics.gpuOtherMs ?? null }, "gpuOtherMs", true), renderP50Ms: metric(metrics, "renderP50Ms"), updateP50Ms: metric(metrics, "updateP50Ms"), frameP50Ms: metric(metrics, "frameP50Ms"), @@ -339,6 +343,9 @@ export const TRACKED = [ /** The report's other metrics: shown in the history and the engine comparison, no goal. */ const MORE = [ { key: "gpuP50Ms", short: "GPU p50", unit: "ms" }, + { key: "gpuMainMs", short: "GPU main pass", unit: "ms" }, + { key: "gpuShadowMs", short: "GPU shadow passes", unit: "ms" }, + { key: "gpuOtherMs", short: "GPU post and other", unit: "ms" }, { key: "presentedP50Ms", short: "Presented p50", unit: "ms" }, { key: "frameP50Ms", short: "CPU frame p50", unit: "ms" }, { key: "updateP50Ms", short: "Update p50", unit: "ms" }, From 4ad2607195d7dbc96b6c451c3d060a19f68cad33 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 16:56:16 -0700 Subject: [PATCH 009/130] chore(stress-lab): ledger rows #332-#333, the laptop GPU frame by pass --- docs/stress-loop/ledger.json | 136 +++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/docs/stress-loop/ledger.json b/docs/stress-loop/ledger.json index a4f9299e17..754a6b325a 100644 --- a/docs/stress-loop/ledger.json +++ b/docs/stress-loop/ledger.json @@ -19411,6 +19411,142 @@ "dpr": 1, "pass": "uncapped", "shadow": "virtual" + }, + { + "id": 332, + "at": "2026-11-10T23:56:15.935Z", + "commit": "core@0.3.13", + "label": "FAIR window seg-laptop (arm=p5746&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=p5746&uncapped", + "notes": "laptop window seg-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; first rows with the GPU frame by pass: the scene pass owns it (232 of 266 ms pinned high, 128 of 148 ms on the low tier the adaptive policy lands on)", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.03, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.273, + "presentedP95Ms": 21.905, + "rafStampP95Ms": 17.765, + "presentedP99Ms": 31.74, + "frameP99Ms": 31.115, + "frameWorstP95Ms": 23.67, + "renderSkipPct": 93.69, + "fps": 3.6, + "gpuP50Ms": 266.115, + "gpuP95Ms": 284.19, + "gpuMainMs": 232.12, + "gpuShadowMs": 12.525, + "gpuOtherMs": 18.755, + "renderP50Ms": 2.42, + "updateP50Ms": 2.34, + "frameP50Ms": 5.335, + "frameP95Ms": 21.5, + "mainDraws": 57, + "shadowDraws": 94, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0, + "loadMs": 782.5, + "bridgeCrossingsP50": 26, + "hostCallsP50": 26, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "90335fdf1cef", + "commit": "744a9321c" + }, + "load": { + "peak": 2.67, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 333, + "at": "2026-11-10T23:56:15.939Z", + "commit": "core@0.3.13", + "label": "FAIR window seg-laptop (adaptive&arm=p5746&uncapped): auto batch fix 744a9321c", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "adaptive&arm=p5746&uncapped", + "notes": "laptop window seg-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; first rows with the GPU frame by pass: the scene pass owns it (232 of 266 ms pinned high, 128 of 148 ms on the low tier the adaptive policy lands on)", + "preset": "real-adaptive-low", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.02, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.268, + "presentedP95Ms": 17.837, + "rafStampP95Ms": 17.77, + "presentedP99Ms": 20.442, + "frameP99Ms": 19.585, + "frameWorstP95Ms": 17.52, + "renderSkipPct": 89.015, + "fps": 6.485, + "gpuP50Ms": 147.53, + "gpuP95Ms": 154.98, + "gpuMainMs": 127.75, + "gpuShadowMs": 11.58, + "gpuOtherMs": 6.42, + "renderP50Ms": 1.74, + "updateP50Ms": 1.885, + "frameP50Ms": 4.135, + "frameP95Ms": 15.59, + "mainDraws": 41, + "shadowDraws": 108.5, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 1, + "loadMs": 668.5, + "bridgeCrossingsP50": 26, + "hostCallsP50": 26, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "90335fdf1cef", + "commit": "744a9321c" + }, + "load": { + "peak": 3.16, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" } ] } From 078aae53bddc1fa68d8163a85e6bf43ec47426d1 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 17:18:12 -0700 Subject: [PATCH 010/130] perf(shader): a lamp shades only the fragments its range reaches The standard and physical fragment programs evaluated every point and spot light's GGX lobe on every fragment. An unshadowed lamp's lobe now sits in one branch on its distance attenuation, which is exactly 0 past the cutoff, so the branch changes no pixel. A lamp that reads a shadow map keeps the straight path. Stress lab, laptop Iris Xe, uncapped, 16 torches of 14 m range (paired probe, two runs per arm): scene pass 206-224 ms to 8.6 ms, 3.6 to 25.3 fps at the high tier, 6.3 to 34.8 fps at the adaptive low tier, 3.35 to 19.9 fps in play. UE 5.8.3: Engine/Shaders/Private/DeferredLightingCommon.ush:334-344. --- .../cmake/NativeEngineCore.cmake | 1 + .../src/engine/shader/standard.cpp | 70 ++++++++++++++----- .../tests/native-engine/material_test.cpp | 27 ++++++- 3 files changed, 79 insertions(+), 19 deletions(-) diff --git a/packages/runtime-native/cmake/NativeEngineCore.cmake b/packages/runtime-native/cmake/NativeEngineCore.cmake index fe16a89915..14e22b9b9b 100644 --- a/packages/runtime-native/cmake/NativeEngineCore.cmake +++ b/packages/runtime-native/cmake/NativeEngineCore.cmake @@ -495,6 +495,7 @@ tn_native_engine_test(tn-native-engine-material-test tests/native-engine/materia native_engine_material_depth_node_writes_frag_depth=depth_node_writes_frag_depth native_engine_material_unsupported=unsupported native_engine_material_standard_builds=builds + native_engine_material_lamp_range_branch=lamp_range_branch native_engine_material_emissive_output=emissive_output native_engine_fog_math=fog native_engine_fog_node_mixes_color=fog_node_mixes_color diff --git a/packages/runtime-native/src/engine/shader/standard.cpp b/packages/runtime-native/src/engine/shader/standard.cpp index 57c8a6d71c..e269651357 100644 --- a/packages/runtime-native/src/engine/shader/standard.cpp +++ b/packages/runtime-native/src/engine/shader/standard.cpp @@ -808,6 +808,7 @@ struct Incoming { ExprId direction; ExprId color; ExprId sourceRadius = kInvalid; // a lamp's Source Radius, metres: read only when lampSize asks (a sun has none) + ExprId mask = kInvalid; // a lamp's distance attenuation: exactly 0 past its cutoff (a sun has none) }; // interleavedGradientNoise(screenCoordinate.xy) * 2pi, PCF's per-pixel rotation, with Unreal's frame term: the @@ -1368,10 +1369,10 @@ static Incoming incoming(Program& f, Tsl& t, char kind, std::size_t index, ExprI const ExprId cut = f.mul(falloff, t.pow2(t.saturate(t.oneMinus(t.pow2(t.pow2(f.div(distance, cutoff))))))); const ExprId attenuation = f.select(f.less(t.f(0), cutoff), cut, falloff); const ExprId sourceRadius = lampSize ? f.uniform(at + "SourceRadius", Type::f32()) : kInvalid; - if (kind == 'p') return {direction, f.mul(color, attenuation), sourceRadius}; + if (kind == 'p') return {direction, f.mul(color, attenuation), sourceRadius, attenuation}; const ExprId angleCos = t.dot(direction, f.call("normalize", {f.uniform(at + "Axis", Type::vec(3))})); const ExprId spot = f.call("smoothstep", {f.uniform(at + "ConeCos", Type::f32()), f.uniform(at + "PenumbraCos", Type::f32()), angleCos}); - return {direction, f.mul(f.mul(color, spot), attenuation), sourceRadius}; + return {direction, f.mul(f.mul(color, spot), attenuation), sourceRadius, attenuation}; } // NodeMaterial.depthNode: the fragment's own depth (frag_depth), on every material program that takes node slots. @@ -1566,42 +1567,75 @@ static StandardPrograms buildStandardProgram(const StandardMaterial& material, b // colour with its shadow, with no N.L and no 1/pi. UE 5.8.3: Engine/Shaders/Private/Substrate/ // SubstrateEvaluation.ush:881-899 (the SSS_TYPE_TWO_SIDED_WRAP branch); Opacity 1 keeps the light's own shadow. const ExprId subsurface = variant.twoSidedFoliage ? f.uniform("subsurfaceColor", Type::vec(3)) : kInvalid; + // A lamp shades only the fragments its range reaches: past the cutoff its attenuation is exactly 0, so the branch + // changes no pixel. UE 5.8.3: Engine/Shaders/Private/DeferredLightingCommon.ush:334-344 (`BRANCH if( LightMask > 0 )`). + // Unbranched, 16 torches cost every fragment 16 GGX lobes: 195 of 203 ms of the scene pass on an Iris Xe (stress lab, + // ledger #332). A lamp with a shadow keeps the straight path: its shadow taps need uniform control flow. + const auto ranged = [&](std::size_t i) { return lights.kinds[i] == 'p' || lights.kinds[i] == 's'; }; + bool branches = false; + for (std::size_t i = 0; i < lights.kinds.size(); ++i) branches = branches || ranged(i); + // What every light reads is evaluated once, before the first branch: a value first used inside a branch would be + // computed there, and a texture sample or a derivative must not run in a branch. + const auto held = [&](ExprId value) { + return branches && value != kInvalid ? f.load(f.var(f.expr(value).type, value)) : value; + }; + const ExprId lightNormal = held(n), lightView = held(positionViewDirection), lightLambert = held(brdfLambert); + const ExprId lightAlbedo = held(diffuseContribution), lightRoughness = held(roughness); + const ExprId lightF0 = held(f0Dielectric), lightF90 = held(specularF90), lightWeight = held(multiScatteringCompensation); + const Surface lightSurface{lightNormal, lightView, lightRoughness, held(surface.f0), lightF90, dfg}; + const ExprId lightCoatNormal = held(clearcoatNormalView); + const Surface lightCoat = coated ? Surface{lightCoatNormal, lightView, held(coat.roughness), coat.f0, coat.f90, coat.dfg} : coat; + const VarId diffuseSum = branches ? f.var(Type::vec(3), directDiffuse) : VarId{}; + const VarId specularSum = branches ? f.var(Type::vec(3), directSpecular) : VarId{}; + const VarId coatSum = branches && coated ? f.var(Type::vec(3), clearcoatSpecularDirect) : VarId{}; + const auto accumulate = [&](ExprId& sum, VarId var, ExprId term) { + if (branches) f.assign(var, f.add(f.load(var), term)); + else sum = f.add(sum, term); + }; for (std::size_t i = 0; i < lights.kinds.size(); ++i) { if (lights.kinds[i] == LightLayout::kExcluded) continue; // not in the material's lightsNode const Incoming light = incoming(f, t, lights.kinds[i], i, fragmentView, positionWorld, normalWorld, variant.leafReceiver, variant.roughDiffuse); - const ExprId irradiance = f.mul(t.saturate(t.dot(n, light.direction)), light.color); + const auto shade = [&] { + const ExprId irradiance = f.mul(t.saturate(t.dot(lightNormal, light.direction)), light.color); // PhysicalLightingModel.direct :675-678: F_Schlick on the specular interface at dotVH, which the // diffuse layer loses. - const ExprId halfDir = f.call("normalize", {f.add(light.direction, positionViewDirection)}); - const ExprId dotVH = t.saturate(t.dot(positionViewDirection, halfDir)); + const ExprId halfDir = f.call("normalize", {f.add(light.direction, lightView)}); + const ExprId dotVH = t.saturate(t.dot(lightView, halfDir)); // A fresh Unreal project's Substrate slab (GBuffer format 0) compiles with its energy conservation off: the // diffuse keeps all the light and the specular lobe takes no multi-scattering weight. UE 5.8.3: // Engine/Shaders/Private/ShadingEnergyConservation.ush:7-31, ShadingEnergyConservationTemplate.ush:73-78 and // :134-135, used at Substrate/SubstrateEvaluation.ush:585-589 and :836. const ExprId oneMinusF = - variant.roughDiffuse ? t.f(1) : t.oneMinus(fSchlick(t, f0Dielectric, specularF90, dotVH)); - const ExprId specularWeight = variant.roughDiffuse ? t.f(1) : multiScatteringCompensation; - ExprId diffuseBrdf = brdfLambert; + variant.roughDiffuse ? t.f(1) : t.oneMinus(fSchlick(t, lightF0, lightF90, dotVH)); + const ExprId specularWeight = variant.roughDiffuse ? t.f(1) : lightWeight; + ExprId diffuseBrdf = lightLambert; if (variant.roughDiffuse) { // EON's roughness is 0.4 x the material's, fading to Lambert as the lamp's Source Radius reaches 20 cm. - ExprId r = f.mul(t.f(kEonRoughnessScale), roughness); + ExprId r = f.mul(t.f(kEonRoughnessScale), lightRoughness); if (light.sourceRadius != kInvalid) r = f.mul(r, t.saturate(t.oneMinus(f.mul(light.sourceRadius, t.f(1 / kEonSizeLimit))))); - diffuseBrdf = f.mul(brdfLambert, roughDiffuseFactor(f, t, diffuseContribution, r, n, positionViewDirection, light.direction)); + diffuseBrdf = f.mul(lightLambert, roughDiffuseFactor(f, t, lightAlbedo, r, lightNormal, lightView, light.direction)); } - directDiffuse = f.add(directDiffuse, f.mul(f.mul(irradiance, diffuseBrdf), oneMinusF)); - directSpecular = - f.add(directSpecular, f.mul(f.mul(irradiance, brdfGgx(t, surface, light.direction)), specularWeight)); + accumulate(directDiffuse, diffuseSum, f.mul(f.mul(irradiance, diffuseBrdf), oneMinusF)); + accumulate(directSpecular, specularSum, f.mul(f.mul(irradiance, brdfGgx(t, lightSurface, light.direction)), specularWeight)); if (subsurface != kInvalid) { - const ExprId wrap = t.saturate(f.div(f.add(f.neg(t.dot(n, light.direction)), t.f(0.5f)), t.f(2.25f))); - const ExprId scatter = dGgx(t, t.f(0.6f), t.saturate(f.neg(t.dot(positionViewDirection, light.direction)))); - directDiffuse = f.add(directDiffuse, f.mul(f.mul(light.color, f.mul(wrap, scatter)), subsurface)); + const ExprId wrap = t.saturate(f.div(f.add(f.neg(t.dot(lightNormal, light.direction)), t.f(0.5f)), t.f(2.25f))); + const ExprId scatter = dGgx(t, t.f(0.6f), t.saturate(f.neg(t.dot(lightView, light.direction)))); + accumulate(directDiffuse, diffuseSum, f.mul(f.mul(light.color, f.mul(wrap, scatter)), subsurface)); } if (coated) { // PhysicalLightingModel.direct: BRDF_GGX on the clearcoat normal, with its own irradiance. - const ExprId ccIrradiance = f.mul(t.saturate(t.dot(clearcoatNormalView, light.direction)), light.color); - clearcoatSpecularDirect = f.add(clearcoatSpecularDirect, f.mul(ccIrradiance, brdfGgx(t, coat, light.direction))); + const ExprId ccIrradiance = f.mul(t.saturate(t.dot(lightCoatNormal, light.direction)), light.color); + accumulate(clearcoatSpecularDirect, coatSum, f.mul(ccIrradiance, brdfGgx(t, lightCoat, light.direction))); } + }; + if (ranged(i)) f.If(f.less(t.f(0), light.mask), shade); + else shade(); + } + if (branches) { + directDiffuse = f.load(diffuseSum); + directSpecular = f.load(specularSum); + if (coated) clearcoatSpecularDirect = f.load(coatSum); } // Hemisphere and ambient irradiance, then PhysicalLightingModel.indirectDiffuse (r187 :786-798): energy diff --git a/packages/runtime-native/tests/native-engine/material_test.cpp b/packages/runtime-native/tests/native-engine/material_test.cpp index 2a4caa3df7..f76d4ba648 100644 --- a/packages/runtime-native/tests/native-engine/material_test.cpp +++ b/packages/runtime-native/tests/native-engine/material_test.cpp @@ -347,6 +347,31 @@ void roughDiffuse() { CHECK(without.code.find("0.189468") == std::string::npos); } +// A lamp with a range shades only the fragments it reaches: each unshadowed point or spot light's BRDF sits in one +// branch on its distance attenuation. A sun and a lamp that reads a shadow map keep the straight path. +void lampRangeBranch() { + const auto branches = [](const char* kinds, bool physical, const VertexVariant& variant = {}) { + const auto programs = physical ? buildPhysical(StandardMaterial{}, variant, LightLayout{kinds}) + : buildStandard(StandardMaterial{}, variant, LightLayout{kinds}); + CHECK(programs.diagnostics.empty()); + const auto fragment = WgslEmitter::emit(programs.fragment, 1); + CHECK(fragment.ok()); + size_t count = 0; + for (size_t at = fragment.code.find("if ("); at != std::string::npos; at = fragment.code.find("if (", at + 1)) ++count; + return count; + }; + VertexVariant rough; rough.roughDiffuse = true; + VertexVariant mapped; mapped.environment = true; + for (bool physical : {false, true}) { + const size_t sun = branches("d", physical); + CHECK(branches("dps", physical) == sun + 2); + CHECK(branches("dp-p", physical) == sun + 2); + CHECK(branches("dP", physical) == sun); // its shadow taps need uniform control flow + CHECK(branches("dps", physical, rough) == branches("d", physical, rough) + 2); + CHECK(branches("dpp", physical, mapped) == branches("d", physical, mapped) + 2); + } +} + // NodeMaterial.irradianceNode replaces the environment's diffuse irradiance: its uniform enters the fragment of a // material that is lit by an environment, or is the whole indirect diffuse without one, and the slot is part of // the program key. @@ -1031,4 +1056,4 @@ void viewMipBiasSamples() { CHECK(colored.code.find("u.f_viewMipBias") != std::string::npos); } -TN_TEST_MAIN({"alpha_hash", [] { alphaHash(); alphaTestCompiled(); }}, {"view_mip_bias_samples", viewMipBiasSamples}, {"ssr_passes", ssrPasses}, {"denoise_reads_raw_normals", denoiseReadsRawNormals}, {"ssr_rejects_inputs", ssrRejectsInputs}, {"depth_texture_lowering", depthTextureLowering}, {"depth_node_writes_frag_depth", depthNodeWritesFragDepth}, {"unsupported", unsupported}, {"emissive_output", emissiveOutput}, {"builds", builds}, {"fog", fog}, {"rough_diffuse", roughDiffuse}, {"fog_node_mixes_color", fogNodeMixesColor}, {"node_key", nodeKey}, {"bloom_passes", bloomPasses}, {"slots_share_one_lowering", slotsShareOneLowering}, {"mrt_slot", mrtSlot}, {"env_node", envNode}, {"basic_normal_view", basicNormalView}, {"normal_map_zero_det", normalMapZeroDet}); +TN_TEST_MAIN({"alpha_hash", [] { alphaHash(); alphaTestCompiled(); }}, {"view_mip_bias_samples", viewMipBiasSamples}, {"ssr_passes", ssrPasses}, {"denoise_reads_raw_normals", denoiseReadsRawNormals}, {"ssr_rejects_inputs", ssrRejectsInputs}, {"depth_texture_lowering", depthTextureLowering}, {"depth_node_writes_frag_depth", depthNodeWritesFragDepth}, {"unsupported", unsupported}, {"emissive_output", emissiveOutput}, {"builds", builds}, {"fog", fog}, {"rough_diffuse", roughDiffuse}, {"lamp_range_branch", lampRangeBranch}, {"fog_node_mixes_color", fogNodeMixesColor}, {"node_key", nodeKey}, {"bloom_passes", bloomPasses}, {"slots_share_one_lowering", slotsShareOneLowering}, {"mrt_slot", mrtSlot}, {"env_node", envNode}, {"basic_normal_view", basicNormalView}, {"normal_map_zero_det", normalMapZeroDet}); From 0601663ed32467001605cdfe7d97e3d9d8021501 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 17:32:04 -0700 Subject: [PATCH 011/130] docs(prd): PRD-871, the next stress lab levers; ledger rows #334-#337 PRD-871 plans the work after the 2026-11-10 audit: close the lamp range lever, bring the laptop adaptive lane to vanilla speed, and cut the virtual shadow planning cost on the desktop. The visual judge of the lamp range change read NEUTRAL. Rows #334-#337 are the desktop window range-score (base, head, one sun map, vanilla three.js). --- ...ab-laptop-parity-and-desktop-shadow-cpu.md | 173 +++++++++++ docs/stress-loop/ledger.json | 272 ++++++++++++++++++ 2 files changed, 445 insertions(+) create mode 100644 docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md diff --git a/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md b/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md new file mode 100644 index 0000000000..47e0aa1e4a --- /dev/null +++ b/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md @@ -0,0 +1,173 @@ +# PRD-871 — Stress lab: an integrated GPU plays at vanilla speed, and the desktop frame loses its shadow planning cost + +**Status:** IN PROGRESS +**Priority:** P1 — on a laptop GPU the engine draws the stress scene at 25 fps where vanilla three.js draws 53 (Phase 2), and the gameplay preset runs at half the speed of the orbit preset on the desktop (Phase 3). +**Complexity:** 6 (MEDIUM) +**Owner:** João · 2026-11-10 +**Depends on:** None. PR #659 (`perf/stress-lab-audit`) carries this PRD and the work before it. + +## Context + +The 2026-11-10 audit of the stress lab (PR #659) changed the method and found where the frame goes. Read +`PERFORMANCE-LEARNINGS.md` (section "2026-11-10") before the first candidate. + +Rules the audit set, which each candidate in this PRD must follow: + +- A keep needs a real-game holdout in the same fair window (`scripts/stress-loop.mjs fair`, commit `f7db10c02`). +- FPS counts drawn frames. The scoreboard reads only arms named `head`, `develop` or `stack`, and one GPU. +- Measure the `adaptive` lane on an integrated GPU. A pinned `high` tier is not what that player gets. +- Attribute before you choose: the ledger keeps the GPU frame by pass (`gpuMainMs`, `gpuShadowMs`, `gpuOtherMs`). + +### Measured state (2026-11-10, 1280x720, uncapped) + +Laptop, Intel Iris Xe (`intel gen-12lp`), stress lab `main.html?stress`, paired probe, two runs per arm: + +| Lane | Before lamp range | After (commit `078aae53b`) | GPU frame after: scene / shadow / post | +| --- | --- | --- | --- | +| `real`, tier pinned `high` | 3.6 fps | 25.3 fps | 38 ms: 8.6 / 11.7 / 18.0 | +| `real&adaptive` (lands on `low`) | 6.3 fps | 34.8 fps | 27.4 ms: 8.5 / 11.8 / 6.8 | +| `real&play` | 3.35 fps | 19.9 fps | 49 ms: 12.3 / 12.4 / 23.6 | +| Vanilla three.js, one sun map (ledger #309) | 53 fps | not measured again | | + +Desktop, RTX 2080, fair window `range-score`, ledger rows #334-#337 (one valid run per ThreeNative arm, two for vanilla; the second half was void because CI runners raised the load to 27): + +| Arm | FPS | Frame p50 | GPU p95 | +| --- | --- | --- | --- | +| ThreeNative, virtual shadows, before lamp range | 95.0 | 10.16 ms | 5.99 ms | +| ThreeNative, virtual shadows, after | 96.7 | 9.84 ms | 5.05 ms | +| ThreeNative, one sun map (`plainShadow`), after | 137.1 | 6.61 ms | 3.89 ms | +| Vanilla three.js | 75.0 and 75.6 | 12.7 ms | | +| ThreeNative `play` preset (earlier single run, ledger #322 window) | 49.4 | 17.0 ms | | + +What the numbers say: + +1. **Laptop, adaptive lane:** the shadow pass is now the largest GPU cost, 11.8 of 27.4 ms. The `low` tier promises no + shadow (`packages/core/src/look/quality.ts:268-286`, "`low` draws no shadow"), but only the kart kit applies + `castShadows` from the tier. The lab and every other kit still draw the shadow pass at `low`. +2. **Desktop, `real`:** the frame is CPU-bound (frame 9.84 ms against GPU 5 ms). Virtual shadows cost 3.2 ms of CPU frame + against one sun map (9.84 against 6.61 ms). The stage split names `planVirtualPages` at 1.64 ms and `planShadowLists` + at 0.33 ms. Each frame also uploads 1,194 KB to the GPU with virtual shadows and about 820 KB with one sun map. +3. **Desktop, `play`:** the gameplay preset draws 114 main draws and 1,242 shadow draws per frame, against 57 and 144 for + `real`. It has no stage split yet. + +## Solution + +Three phases, ordered by gain per hour. Each engine change lands in `packages/`, with a red-green test, a paired window and +a visual judge when it can change a pixel. + +**Phase 1 closes the lamp range lever.** The standard and physical fragment programs now branch on a lamp's distance +attenuation (`packages/runtime-native/src/engine/shader/standard.cpp`, `ranged`, `held`, `shade`). It follows +`UE 5.8.3: Engine/Shaders/Private/DeferredLightingCommon.ush:334-344`. Two gaps stay: the Lambert and Phong loop in +`buildLit` still shades each lamp on each fragment, and the change has no native run. + +**Phase 2 brings the laptop adaptive lane to vanilla speed.** Make the `low` tier keep its promise without a line of game +code. First name the layer: the quality policy (`packages/core/src/look/adaptiveQuality.ts`, `qualityPolicy.ts`) knows the +tier, and "the engine decides what it can measure" is the repository rule. The tier must still be a named override on the +same object, and the report must say that shadows are off. With the shadow pass gone the GPU frame is about 15.6 ms, so +the lane can pass 53 fps. Then re-measure at `medium`: the policy may hold a higher tier now that the lamps are cheap. + +**Phase 3 cuts CPU frame time on the desktop.** Hypotheses, in the order to test them: + +- `planVirtualPages` rebuilds per-caster state each frame: a `std::string` signature through `std::to_string`, a + `std::set` insert, a `motion_` map lookup, `protectedIds` inserts per page, and `writeTable` each frame + (`renderer_frame.cpp:124-212`, `shadows/virtual/atlas.cpp`, `updateCasters`, `update`, `writeTable`). A static caster + whose inputs did not change must cost nothing. The ceiling is the `plainShadow` arm: 3.2 ms. +- The 374 KB of extra upload per frame with virtual shadows, and the 820 KB base. Read the counters + (`bufferUploadBytes`, `attributeUploadBytes`, `uniformUploadBytes`, `textureUploadBytes`, URL flag `tnDebug=1`) and + find each buffer that uploads unchanged content. +- `play`: take a Chrome DevTools Performance trace (chrome-devtools MCP) and the stage split first. The 1,242 shadow + draws point at moving casters that draw one by one. + +Risks: + +- **Disappearing meshes.** The auto batch fix of 2026-11 (aircraft yoke pieces vanished) must not regress. Run the auto + batch specs after each change to draw lists. +- **Shadow cache staleness.** A caster that the planner skips must still dirty its pages when it moves. The existing + `tn-native-engine-vsm-atlas-test` and `-vsm-test` suites are the guard. +- **Benchmark-only wins.** A change that helps only `?stress` fails the holdout and is rejected. + +## Validation scene + +- **Consumer and scene:** the stress lab, `main.html?stress`, preset `real` (4000 trees, 6000 rocks, 120 houses, 600 props, + 120 walkers, 300 bodies, 12 fires of 1500 particles, 16 torches, one sun with virtual shadows), plus `&play` and `&adaptive`. +- **Window:** `node scripts/stress-loop.mjs fair`, ABBA, two runs per arm, `--passes uncapped`, 1280x720, base and candidate + lab builds served side by side. Desktop: RTX 2080. Laptop: Intel Iris Xe through `laptop-offload`. +- **Benefit workloads:** `?stress&adaptive` on the laptop (Phase 2); `?stress` and `?stress&play` on the desktop (Phase 3). +- **Controls that charge the change's own cost:** `?stress&plainShadow` (no virtual shadow planning, must not slow down), + `?stress&still` (no camera motion), and `?stress&noLights` (the ceiling of any lamp work). +- **Holdout:** one scaffolded kit with lamps (`adventure` or `shooter`) in the same window. A keep needs it. +- **Pass:** the acceptance numbers below, frame p95 and hitches not worse than base by more than the A/A band, and a visual + judge NEUTRAL or IMPROVEMENT on each change that can alter a pixel. + +## Acceptance criteria + +- [ ] AC-1 [shared]: on the Iris Xe laptop, `main.html?stress&adaptive` draws at 53 fps or more, uncapped, two runs per arm, with no stress lab source change that removes content. proof: ledger rows from a laptop fair window (`~/.agents/skills/laptop-offload/offload.sh start --gpu`) — Evidence: pending (34.8 fps today). +- [ ] AC-2 [local]: on the RTX 2080, `main.html?stress&arm=head` holds a frame p50 of 8.4 ms or less (about 120 fps, the scoreboard goal) in a valid fair window. proof: ledger rows from `node scripts/stress-loop.mjs fair` — Evidence: pending (9.84 ms today). +- [ ] AC-3 [local]: on the RTX 2080, `main.html?stress&play&arm=head` draws at 60 fps or more in a valid fair window. proof: ledger rows from `node scripts/stress-loop.mjs fair` — Evidence: pending (49.4 fps today). + +## Blocked on + +- A valid desktop fair window needs host load under 18 for the whole window. The `tn-local` CI runner pool on the desktop + raised it to 27 on 2026-11-10 — unblocked by a quiet period, or by João pausing the pool for 20 minutes. +- The Unreal 5.8.3 scoreboard rows (#287, #288) date from 2026-11-03, so the dashboard reads STALE until someone runs the + Unreal arms again — unblocked by João's decision: rerun Unreal, or let the stale flag follow only the ThreeNative rows. + +## Integration Ledger + +| Capability | Reachable consumer/trigger | Replaces / disposition | Evidence | +| --- | --- | --- | --- | +| A lamp shades only fragments in range | Any `MeshStandardMaterial` or `MeshPhysicalMaterial` lit by an unshadowed `PointLight` or `SpotLight` → `buildStandardProgram`, `standard.cpp` | The unbranched loop, deleted | Phase 1 | +| The `low` tier draws no shadow | Adaptive quality policy tier change → shadow pass | Per-kit `castShadows` wiring (kart `src/render/lighting.ts:22-31`) stays valid as the named override | AC-1 | +| Virtual shadow planning skips unchanged casters | `Renderer` frame → `planVirtualPages` → `atlas.updateCasters` | The per-frame rebuild, deleted | AC-2 | + +## Decisions + +- 2026-11-10 (Claude, audit session): clustered or tiled light culling is not in scope. With the range branch, 16 lamps cost + 0.6 ms of the Iris Xe scene pass (8.55 ms against 7.96 ms with no lamp), so a light grid has nothing left to remove at + this lamp count. Reopen it when a game ships more than about 64 lamps. +- 2026-11-10 (Claude, audit session): a depth prepass is not in scope. The scene pass is 8.6 ms on the Iris Xe after the lamp + change, and PRD-766 phase 2 already holds the masked prepass as deferred. +- 2026-11-10 (João): no Midway work in this lane. Midway still ships `autoBatch: false`; that is a game-side note, not a + box here. + +## Execution Phases + +#### Phase 1: the lamp range lever is closed + +**Status:** IN PROGRESS +**Files:** `packages/runtime-native/src/engine/shader/standard.cpp` (`buildLit`), `packages/runtime-native/tests/native-engine/material_test.cpp`, `docs/stress-loop/ledger.json`, `PERFORMANCE-LEARNINGS.md`. +**Implementation:** reuse `ranged`, `held` and the branch from `buildStandardProgram` for the Lambert and Phong loop. Record the laptop paired probe and one valid desktop window as ledger rows with the candidate arm named `head`. Add the learnings entry (method traps of the session included: scoreboard arm names, the desktop needs a live display for a headed browser, the owned engine reports its GPU split through `globalThis.__tnGpuSegments`, the glue constant `ASM_CONSTS` moves with each Wasm build). +**Verification:** `cmake --build build/wasm --target tn-native-engine-material-test && ctest --test-dir build/wasm -R native_engine_material` (run from `packages/runtime-native`, after `source ~/emsdk/emsdk_env.sh`). + +- [x] Standard and physical programs branch on lamp range. proof: `ctest --test-dir build/wasm -R native_engine_material` — 21 passed, 2026-11-10; `native_engine_material_lamp_range_branch` failed on the old shader and passes on `078aae53b`. +- [x] Visual judge NEUTRAL on `?stress&still`, `?stress&freezeAt=12` and `?stress&play&freezeAt=8`, two runs per arm on the Iris Xe. proof: fresh Opus judge on `artifacts/stress-lab-audit/judge-range/` — NEUTRAL overall, 2026-11-10: torch glow on the house wall reads RGB 214.4/168.7/131.8 before and after (`freezeAt=12`), and 215.0-215.5 before against 215.2-215.9 after (`play`); no cutoff edge. The `still` pose shows no torch light, so it proves nothing about the lamps. +- [ ] Lambert and Phong programs branch on lamp range too, and a kit with lamps (`adventure` or `shooter`) holds or gains FPS as the holdout. proof: `native_engine_material_lamp_range_branch` extended to `buildLambert` and `buildPhong`; holdout arm in one fair window; ledger rows. + +#### Phase 2: the laptop adaptive lane reaches vanilla speed + +**Status:** NOT STARTED +**Files:** `packages/core/src/look/adaptiveQuality.ts`, `packages/core/src/look/qualityPolicy.ts`, their specs under `packages/core/__tests__/`, `packages/create-threenative/conventions.json` when the rule becomes a convention. +**Implementation:** run `engine_search_capabilities` for the shadow tier first. Decide the layer in writing (engine policy or kit source) and quote the repository rule that decides it. Apply the tier's `castShadows` where the tier changes, keep the named override, and report the overridden state. Feed the owned engine's GPU pass split into `TN_FRAME_BUDGET`, so a game reads it without the lab's hook. +**Verification:** a laptop fair window with base and candidate lab builds, arms `?stress&adaptive&arm=base` and `?stress&adaptive&arm=head`, plus a visual judge at each tier the change touches. + +- [ ] At the `low` tier a game with no shadow wiring of its own draws no shadow pass, and a game that sets its own shadow option keeps it. proof: a core spec red-green, and `gpuShadowMs` under 1 ms on `?stress&adaptive` in the laptop window. +- [ ] `TN_FRAME_BUDGET` carries the GPU frame by pass (shadow, scene, other) on the owned engine. proof: a core spec, and one native desktop run read with `node packages/playtest/dist/runner/cli.js perf --file `. + +#### Phase 3: the desktop frame loses its shadow planning cost + +**Status:** NOT STARTED +**Files:** `packages/runtime-native/src/engine/renderer/renderer_frame.cpp`, `packages/runtime-native/src/engine/renderer/shadows/virtual/atlas.cpp`, `atlas.h`, `packages/runtime-native/tests/native-engine/vsm/vsm_atlas_test.cpp`. +**Implementation:** one hypothesis per candidate, each with its expected gain written first. Start with the per-caster work of `planVirtualPages` for casters whose transform and geometry did not change. Then attribute the per-frame upload by counter. Then profile `play` and cut its largest removable term. Use Opus 5.5 high for the C++ changes and DeepSeek V4.1 Flash max for searches and row bookkeeping (owner routing, 2026-11-10). +**Verification:** `ctest --test-dir build/wasm -L native-engine -R "vsm|render_database|renderer"`, then a paired desktop fair window (`--passes uncapped`, arms `base` and `head`, plus `plainShadow` as the ceiling), a holdout kit, and a visual judge on `?stress&closeup` and `?stress&freezeAt=12` for each change to shadow planning. + +- [ ] A frame whose static casters did not change spends 0.5 ms or less in `planVirtualPages` on `?stress` (1.64 ms today). proof: a `vsm_atlas_test` case that counts per-caster work on an unchanged frame, red-green; stage split in the ledger row. +- [ ] No buffer with unchanged content uploads on `?stress&still`. proof: `tnDebug=1` upload counters before and after, in the ledger row notes. +- [ ] The `play` frame has a stage split that covers 95% of the frame, and its largest removable term is cut. proof: the split and the paired window in ledger rows; the DevTools trace named in the learnings entry. + +## Handover notes + +- Work in `.claude/worktrees/stress-lab-audit`, branch `perf/stress-lab-audit`, PR #659. Commit by path. +- Lab source: `~/projects/threenative/sandbox/stress-lab` (commits go to `main` with a push). Lab flags: `stress`, `play`, `adaptive`, `plainShadow`, `still`, `closeup`, `freezeAt=`, `noLights`, `noTrees`, `noRocks`, `noCrowd`, `noBodies`, `noFires`, `arm=`. +- Probe tools from the audit, kept outside git: `artifacts/stress-lab-audit/tools/` in the worktree, and `~/offload/labs/stress-audit/` on the laptop (`dist-cand3` is the base lab build, `dist-cand4` the lamp range build). `seg-probe.mjs` prints the GPU pass split per URL variant. `cap-probe.mjs` takes the judge captures. +- A candidate lab build is a copy of the base `dist` with the two engine `.wasm` files replaced and the `ASM_CONSTS` address patched in the glue chunks. Do not rebuild a folder that a live server serves. +- Before a headed GPU browser on the desktop: read `nvidia-smi --query-gpu=memory.used --format=csv,noheader` and keep 3 GB free. Check `uptime`: a fair window is void above load 18. diff --git a/docs/stress-loop/ledger.json b/docs/stress-loop/ledger.json index 754a6b325a..ed16ded525 100644 --- a/docs/stress-loop/ledger.json +++ b/docs/stress-loop/ledger.json @@ -19547,6 +19547,278 @@ "dpr": 1, "pass": "uncapped", "shadow": "virtual" + }, + { + "id": 334, + "at": "2026-11-11T00:31:24.731Z", + "commit": "core@0.3.13", + "label": "FAIR window range-score (arm=base&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=base&uncapped", + "notes": "fair window range-score: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 1 run(s) (1 void), load peak 3.42, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-range-score-uncapped-threenative-wasm-127-0-0-1-5744-main-html-stress-arm-base.json", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 65, + "windows": 13, + "metrics": { + "presentedP50Ms": 10.515, + "presentedP95Ms": 13.35, + "rafStampP95Ms": 12.97, + "presentedP99Ms": 16.365, + "frameP99Ms": 13.87, + "frameWorstP95Ms": 16.27, + "renderSkipPct": 0.34, + "fps": 95, + "gpuP50Ms": 5.65, + "gpuP95Ms": 5.99, + "gpuMainMs": 1.89, + "gpuShadowMs": 1.16, + "gpuOtherMs": 2.55, + "renderP50Ms": 8.71, + "updateP50Ms": 1.19, + "frameP50Ms": 10.16, + "frameP95Ms": 12.57, + "mainDraws": 57, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0, + "loadMs": 817, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 1195.07, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "90335fdf1cef", + "commit": "078aae53b" + }, + "load": { + "peak": 3.42, + "samples": 10 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 335, + "at": "2026-11-11T00:31:24.731Z", + "commit": "core@0.3.13", + "label": "FAIR window range-score (arm=head&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=head&uncapped", + "notes": "fair window range-score: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 1 run(s) (1 void), load peak 3.37, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-range-score-uncapped-threenative-wasm-127-0-0-1-5750-main-html-stress-arm-head.json", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 64.99, + "windows": 13, + "metrics": { + "presentedP50Ms": 10.155, + "presentedP95Ms": 13.035, + "rafStampP95Ms": 12.98, + "presentedP99Ms": 16.895, + "frameP99Ms": 13.51, + "frameWorstP95Ms": 13.49, + "renderSkipPct": 0.55, + "fps": 96.65, + "gpuP50Ms": 4.74, + "gpuP95Ms": 5.05, + "gpuMainMs": 1.01, + "gpuShadowMs": 1.17, + "gpuOtherMs": 2.56, + "renderP50Ms": 8.37, + "updateP50Ms": 1.15, + "frameP50Ms": 9.84, + "frameP95Ms": 12.15, + "mainDraws": 57, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0, + "loadMs": 757, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 1194.57, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "27c44da3f718", + "commit": "078aae53b" + }, + "load": { + "peak": 3.37, + "samples": 10 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 336, + "at": "2026-11-11T00:31:24.731Z", + "commit": "core@0.3.13", + "label": "FAIR window range-score (plainShadow&arm=head&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "plainShadow&arm=head&uncapped", + "notes": "fair window range-score: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 1 run(s) (1 void), load peak 6.17, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-range-score-uncapped-threenative-wasm-127-0-0-1-5750-main-html-stress-plainShadow-arm-head.json", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 65.03, + "windows": 13, + "metrics": { + "presentedP50Ms": 7.035, + "presentedP95Ms": 10.405, + "rafStampP95Ms": 9.88, + "presentedP99Ms": 18.485, + "frameP99Ms": 10.43, + "frameWorstP95Ms": 28.23, + "renderSkipPct": 0.65, + "fps": 137.08, + "gpuP50Ms": 3.6, + "gpuP95Ms": 3.89, + "gpuMainMs": 0.76, + "gpuShadowMs": 0.26, + "gpuOtherMs": 2.59, + "renderP50Ms": 5.69, + "updateP50Ms": 0, + "frameP50Ms": 6.61, + "frameP95Ms": 9.21, + "mainDraws": 70, + "shadowDraws": 18, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 4, + "loadMs": 457, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 837.5, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "27c44da3f718", + "commit": "078aae53b" + }, + "load": { + "peak": 6.17, + "samples": 10 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "plain" + }, + { + "id": 337, + "at": "2026-11-11T00:31:24.731Z", + "commit": "three@0.187.0", + "label": "FAIR window range-score (arm=vanilla&uncapped)", + "decision": "measure", + "engine": "three-vanilla", + "variant": "arm=vanilla&uncapped", + "notes": "fair window range-score: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 2 run(s) (0 void), load peak 6.68, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-range-score-uncapped-three-vanilla-127-0-0-1-5736-main-html-stress-arm-vanilla.json", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 59.77, + "windows": 60, + "metrics": { + "presentedP50Ms": 12.675, + "presentedP95Ms": 17.975, + "rafStampP95Ms": 16.82, + "presentedP99Ms": 21.212, + "frameP99Ms": 18.845, + "frameWorstP95Ms": 24.16, + "renderSkipPct": null, + "fps": 75.32, + "gpuP50Ms": null, + "gpuP95Ms": null, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 11.625, + "updateP50Ms": 1.11, + "frameP50Ms": 12.7, + "frameP95Ms": 16.615, + "mainDraws": 1109, + "shadowDraws": null, + "mainTrianglesM": 2.08, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 0, + "loadMs": 250.417, + "bridgeCrossingsP50": null, + "hostCallsP50": null, + "gpuUploadKbP50": null, + "jsAllocKbP50": null + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "js", + "sha256": "5f36154a86a6", + "commit": "078aae53b" + }, + "load": { + "peak": 6.68, + "samples": 18 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "plain" } ] } From fa8f612a13d53c920af9987a61c232cf40573c51 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 17:35:22 -0700 Subject: [PATCH 012/130] perf(shader): Lambert and Phong lamps shade only the fragments in range The same branch as the standard and physical programs (078aae53b): an unshadowed point or spot light evaluates its diffuse and Blinn-Phong terms only where its distance attenuation is above 0. The normal, view direction and Lambert term are evaluated once before the first branch. Test: native_engine_material_lamp_range_branch now covers buildLambert and buildPhong; it failed on the old loop and passes (27 material and TSL tests). --- .../src/engine/shader/standard.cpp | 31 ++++++++++++++++--- .../tests/native-engine/material_test.cpp | 13 ++++++++ 2 files changed, 39 insertions(+), 5 deletions(-) diff --git a/packages/runtime-native/src/engine/shader/standard.cpp b/packages/runtime-native/src/engine/shader/standard.cpp index e269651357..0ddc8a8834 100644 --- a/packages/runtime-native/src/engine/shader/standard.cpp +++ b/packages/runtime-native/src/engine/shader/standard.cpp @@ -1861,14 +1861,35 @@ StandardPrograms buildLit(bool phong, const VertexVariant& variant, const LightL const ExprId fragmentView = f.varying("positionView", Type::vec(3)); const ExprId shininess = phong ? f.call("max", {f.uniform("shininess", Type::f32()), t.f(1e-4f)}) : kInvalid; ExprId directDiffuse = f.construct(Type::vec(3), {t.f(0)}), directSpecular = directDiffuse; + // As in buildStandardProgram: an unshadowed lamp shades only the fragments its range reaches, and what every light + // reads (a normal map, a colour texel) is evaluated once, before the first branch. + const auto ranged = [&](std::size_t i) { return lights.kinds[i] == 'p' || lights.kinds[i] == 's'; }; + bool branches = false; + for (std::size_t i = 0; i < lights.kinds.size(); ++i) branches = branches || ranged(i); + const auto held = [&](ExprId value) { return branches ? f.load(f.var(f.expr(value).type, value)) : value; }; + const ExprId lightNormal = held(n), lightView = held(positionViewDirection), lightLambert = held(brdfLambert); + const VarId diffuseSum = branches ? f.var(Type::vec(3), directDiffuse) : VarId{}; + const VarId specularSum = branches && phong ? f.var(Type::vec(3), directSpecular) : VarId{}; + const auto accumulate = [&](ExprId& sum, VarId var, ExprId term) { + if (branches) f.assign(var, f.add(f.load(var), term)); + else sum = f.add(sum, term); + }; for (std::size_t i = 0; i < lights.kinds.size(); ++i) { if (lights.kinds[i] == LightLayout::kExcluded) continue; // not in the material's lightsNode const Incoming light = incoming(f, t, lights.kinds[i], i, fragmentView, positionWorld, normalWorld, variant.leafReceiver); - const ExprId irradiance = f.mul(t.saturate(t.dot(n, light.direction)), light.color); - directDiffuse = f.add(directDiffuse, f.mul(irradiance, brdfLambert)); - if (phong) - directSpecular = f.add(directSpecular, f.mul(irradiance, brdfBlinnPhong(t, n, positionViewDirection, light.direction, - f.uniform("specular", Type::vec(3)), shininess))); + const auto shade = [&] { + const ExprId irradiance = f.mul(t.saturate(t.dot(lightNormal, light.direction)), light.color); + accumulate(directDiffuse, diffuseSum, f.mul(irradiance, lightLambert)); + if (phong) + accumulate(directSpecular, specularSum, f.mul(irradiance, brdfBlinnPhong(t, lightNormal, lightView, light.direction, + f.uniform("specular", Type::vec(3)), shininess))); + }; + if (ranged(i)) f.If(f.less(t.f(0), light.mask), shade); + else shade(); + } + if (branches) { + directDiffuse = f.load(diffuseSum); + if (phong) directSpecular = f.load(specularSum); } // Hemisphere and ambient irradiance, then PhongLightingModel.indirect diffuse. diff --git a/packages/runtime-native/tests/native-engine/material_test.cpp b/packages/runtime-native/tests/native-engine/material_test.cpp index f76d4ba648..1c4bdd6f97 100644 --- a/packages/runtime-native/tests/native-engine/material_test.cpp +++ b/packages/runtime-native/tests/native-engine/material_test.cpp @@ -360,6 +360,19 @@ void lampRangeBranch() { for (size_t at = fragment.code.find("if ("); at != std::string::npos; at = fragment.code.find("if (", at + 1)) ++count; return count; }; + for (bool phong : {false, true}) { + const auto lit = [&](const char* kinds) { + const auto programs = phong ? buildPhong(VertexVariant{}, LightLayout{kinds}) : buildLambert(VertexVariant{}, LightLayout{kinds}); + CHECK(programs.diagnostics.empty()); + const auto fragment = WgslEmitter::emit(programs.fragment, 1); + CHECK(fragment.ok()); + size_t count = 0; + for (size_t at = fragment.code.find("if ("); at != std::string::npos; at = fragment.code.find("if (", at + 1)) ++count; + return count; + }; + CHECK(lit("dps") == lit("d") + 2); + CHECK(lit("dP") == lit("d")); + } VertexVariant rough; rough.roughDiffuse = true; VertexVariant mapped; mapped.environment = true; for (bool physical : {false, true}) { From 6dd609ecac2a70d38d3ff74da28fe9882f38ee76 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 17:42:12 -0700 Subject: [PATCH 013/130] fix(starter): the low tier draws no shadow pass qualityTiers.shadow(tier).castShadows already said that the low tier casts no shadow, but only the kart kit applied it. The starter (and every game built on it, the stress lab included) drew the shadow pass at every tier. On an Iris Xe at the low tier that pass was the largest GPU cost: 11.8 of 27.4 ms. setupPost now sets the renderer shadow map from the tier at each tier change. A named `shadows` option keeps the game choice at every tier, and both TN_QUALITY_TIER and debug().shadows name the source (tier or override). PRD-871 Phase 2. Spec: starter-shadow-tier.spec.ts, red before, green after. --- .../__tests__/scaffold.spec.ts | 2 +- .../__tests__/starter-shadow-tier.spec.ts | 60 +++++++++++++++++++ .../starter/src/render/postprocessing.ts | 18 +++++- 3 files changed, 77 insertions(+), 3 deletions(-) create mode 100644 packages/create-threenative/__tests__/starter-shadow-tier.spec.ts diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index d896a8563c..3ac692d1fb 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -173,7 +173,7 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, starter: { shared: "45287923ee472856d7fee5781bf527f953789cb893b951151eaa3d64c1ac539b", - kit: "232c5278ee55f8581bf3b2b25c53d8a852a4a1704947ee2a6d22f3756c724a1e", + kit: "e9938d81b783b7b743c45465f87fce1b35b47a603fd6008ac4a08e155770439e", }, "tower-defense": { shared: "9d9bbb42f8a7f0845bf827030f6978aa4feae5ac455e3739ee612e1d18c5bf9b", diff --git a/packages/create-threenative/__tests__/starter-shadow-tier.spec.ts b/packages/create-threenative/__tests__/starter-shadow-tier.spec.ts new file mode 100644 index 0000000000..eac0f79763 --- /dev/null +++ b/packages/create-threenative/__tests__/starter-shadow-tier.spec.ts @@ -0,0 +1,60 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; + +vi.mock("three/addons/loaders/LUTCubeLoader.js", () => ({ + LUTCubeLoader: class { + setType(): this { + return this; + } + loadAsync(): Promise { + return new Promise(() => undefined); + } + }, +})); +vi.mock("@threenative/core", async (importOriginal) => ({ + ...(await importOriginal()), + WorldEnvironment: class { + apply() { + return { dispose: () => undefined, dropped: [], stages: [] }; + } + }, +})); + +const { setupPost } = await import("../templates/starter/src/render/postprocessing.js"); + +const shadowMapAt = (options: Parameters[3]) => { + const shadowMap = { enabled: true }; + const post = setupPost( + { kind: "webgpu", raw: { shadowMap } } as never, + {} as never, + {} as never, + options, + ); + return { shadowMap, post }; +}; + +describe("starter post chain applies the tier's shadows (PRD-871)", () => { + let dispose: (() => void) | undefined; + afterEach(() => dispose?.()); + + it("draws no shadow pass at the low tier, which casts none", () => { + const { shadowMap, post } = shadowMapAt({ tier: "low" }); + dispose = () => post.dispose(); + expect(shadowMap.enabled).toBe(false); + expect(post.debug()).toMatchObject({ shadows: { enabled: false, source: "tier" } }); + }); + + it("keeps shadows at the medium and high tiers", () => { + for (const tier of ["medium", "high"] as const) { + const { shadowMap, post } = shadowMapAt({ tier }); + expect(shadowMap.enabled).toBe(true); + post.dispose(); + } + }); + + it("keeps the game's own shadow choice and reports the override", () => { + const { shadowMap, post } = shadowMapAt({ tier: "low", shadows: true }); + dispose = () => post.dispose(); + expect(shadowMap.enabled).toBe(true); + expect(post.debug()).toMatchObject({ shadows: { enabled: true, source: "override" } }); + }); +}); diff --git a/packages/create-threenative/templates/starter/src/render/postprocessing.ts b/packages/create-threenative/templates/starter/src/render/postprocessing.ts index 0d80f1d165..1574551e28 100644 --- a/packages/create-threenative/templates/starter/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/starter/src/render/postprocessing.ts @@ -1,4 +1,4 @@ -import { type OutputRenderer, WorldEnvironment } from "@threenative/core"; +import { type OutputRenderer, WorldEnvironment, qualityTiers } from "@threenative/core"; // Generated for you: ordinary Three.js; ThreeNative does not read this file. // // This file wires two things together and decides nothing itself. `quality.ts` owns which stages @@ -64,6 +64,12 @@ export function setupPost( tier?: QualityTier; /** Scene-owned material assignments follow the same resolved tier. */ onTierChanged?: (tier: QualityTier) => void; + /** + * Whether the sun and the lamps cast shadows. Omit it and the tier decides: `low` casts none, as + * Unreal's `sg.ShadowQuality` 0. Set it and every tier keeps this game's choice; `TN_QUALITY_TIER` + * and `debug().shadows` name which one ran. + */ + shadows?: boolean; /** * The bounded participating medium from `volumetricFog.ts`, built once per graph: a tier change * replaces the graph, and a fog controller owns one graph, so it is released and rebuilt here @@ -85,6 +91,7 @@ export function setupPost( let temporal: TemporalAAProvider | undefined; // The chain's own temporal AA (`temporalAA` in `quality.ts`) reads the engine's live frame, history and jitter. let engineTemporal: (() => Record) | undefined; + const shadowSource = environment.shadows === undefined ? "tier" : "override"; let observation: Record = { tier: policy.tier, source: policy.pinned ? "pinned" : "auto", @@ -95,6 +102,12 @@ export function setupPost( medium?.dispose(); medium = environment.fog?.(); const composed = medium; + // The tier's shadow switch. An integrated GPU lands on `low`, where the shadow pass was the + // largest GPU cost of the frame while the tier table already said "no shadow". + const castShadows = environment.shadows ?? qualityTiers.shadow(policy.tier).castShadows; + const shadowMap = (renderer.raw as { shadowMap?: { enabled: boolean } }).shadowMap; + if (shadowMap !== undefined) shadowMap.enabled = castShadows; + observation = { ...observation, shadows: { enabled: castShadows, source: shadowSource } }; const settings = qualityPreset(policy.tier); const detail = temporalDetailRequested(renderer, settings) ? temporalDetailStages() : []; const world = new WorldEnvironment({ @@ -157,7 +170,7 @@ export function setupPost( console.info( `TN_QUALITY_TIER ${policy.tier} mobile=${environment.mobile === true} software=${ environment.software === true - } source=${source}`, + } source=${source} shadows=${(environment.shadows ?? qualityTiers.shadow(policy.tier).castShadows) ? "on" : "off"}(${shadowSource})`, ); const controller = { get tier(): QualityTier { @@ -174,6 +187,7 @@ export function setupPost( observation = { stages: observation.stages, dropped: observation.dropped, + shadows: observation.shadows, ...decision, ...(Number.isInteger(window.window) ? { window: window.window } : {}), }; From 4bd3989939136fb49df8d607a5063ef354565f90 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 17:42:12 -0700 Subject: [PATCH 014/130] feat(core): TN_FRAME_BUDGET splits the owned engine GPU frame by pass The pass recorder cannot instrument the owned engine, so gpuMain, gpuShadow and gpuOther stayed empty there. The facade now publishes each resolved sample as a split (scene pass; sun and lamp shadow maps; other passes, output and gather) under info.engine.gpuSplit, core gpuFrameSample carries it while it names the same frame, and the frame loop feeds the existing buckets from it. PRD-871 Phase 2. Spec: renderer.spec.ts "hands out the engine own pass split", red before, green after; frame-budget and GPU observation specs pass. --- packages/core/__tests__/renderer.spec.ts | 37 +++++++++++++++++++ packages/core/src/game-frame-loop.ts | 8 ++++ packages/core/src/renderer.ts | 34 +++++++++++++++-- packages/three-native/src/browser-renderer.ts | 15 ++++++++ 4 files changed, 91 insertions(+), 3 deletions(-) diff --git a/packages/core/__tests__/renderer.spec.ts b/packages/core/__tests__/renderer.spec.ts index 053d0690ad..83070f9f8b 100644 --- a/packages/core/__tests__/renderer.spec.ts +++ b/packages/core/__tests__/renderer.spec.ts @@ -183,6 +183,43 @@ describe("createRenderer", () => { } }); + it("hands out the engine's own pass split with the sample it belongs to (PRD-871)", async () => { + // The owned engine times its passes itself (shadow, scene, the rest), where the pass recorder + // cannot reach. The split rides on the sample only while it describes that same frame. + const canvas = testCanvas(); + const info = { + frame: 0, + render: { timestamp: 6 }, + engine: { gpuSplit: { frame: 16, main: 3, shadow: 2, other: 1 } }, + }; + const renderer = await createRenderer({ + canvas, + preferWebGPU: false, + webgl2Factory: () => ({ + domElement: canvas, + info, + backend: { getTimestampFrames: () => [16] }, + render: () => undefined, + setSize: () => undefined, + }), + }); + try { + for (let frame = 0; frame < 20; frame += 1) + renderer.render(new Scene(), new PerspectiveCamera()); + expect(renderer.gpuFrameSample?.()).toEqual({ + frame: 16, + ms: 6, + main: 3, + shadow: 2, + other: 1, + }); + info.engine.gpuSplit.frame = 15; + expect(renderer.gpuFrameSample?.()).toEqual({ frame: 16, ms: 6 }); + } finally { + renderer.dispose(); + } + }); + it("smooths the main-pass GPU time and reports it absent once the samples go stale", async () => { const canvas = testCanvas(); const renderer = await createRenderer({ diff --git a/packages/core/src/game-frame-loop.ts b/packages/core/src/game-frame-loop.ts index bd853b0ce6..280259fdc4 100644 --- a/packages/core/src/game-frame-loop.ts +++ b/packages/core/src/game-frame-loop.ts @@ -719,6 +719,14 @@ export function startFrameLoop, TPhysics> // fresh or not. renderer.noteGpuMainMs?.(gpuFrame.main, gpuFrame.frame); } else { + // A renderer the pass recorder cannot instrument (the owned engine) may time its own + // passes; its split rides on the sample, so `TN_FRAME_BUDGET` names the pass that costs. + if (gpuSample?.main !== undefined) + frameBudget?.addGpuBucketMs({ + main: gpuSample.main, + shadow: gpuSample.shadow, + other: gpuSample.other, + }); renderer.noteGpuMainMs?.(undefined); } if (!depthCoupledOutput && game.sceneEntered) game.scene?.render(ctx); diff --git a/packages/core/src/renderer.ts b/packages/core/src/renderer.ts index c82960a8ec..fcec9b0ede 100644 --- a/packages/core/src/renderer.ts +++ b/packages/core/src/renderer.ts @@ -156,6 +156,22 @@ export function prewarm(object: Object3D | readonly Object3D[]): void { } } +/** Where one resolved frame's GPU time went, when the renderer times its own passes (the owned engine). */ +export interface IGpuFrameSplit { + /** The scene (main colour) pass. */ + readonly main: number; + /** Every shadow map the frame drew: the sun's and the lamps'. */ + readonly shadow: number; + /** Everything else: post chain, output, global illumination gather. */ + readonly other: number; +} + +/** One resolved GPU frame: its duration, the Three.js frame id it belongs to, and its pass split when known. */ +export interface IGpuFrameSample extends Partial { + readonly frame: number; + readonly ms: number; +} + export interface IRendererLike { readonly domElement: HTMLCanvasElement; readonly kind: RendererKind; @@ -376,7 +392,7 @@ export interface IRendererLike { * resolve twice. Optional like `gpuFrameAge`, for stubs that implement only the drawing * contract. `createRenderer` always provides it. */ - gpuFrameSample?(): { readonly frame: number; readonly ms: number } | undefined; + gpuFrameSample?(): IGpuFrameSample | undefined; /** * Observes complete allocated render-query groups without changing ordinary sampling. * Frame IDs are Three query IDs, including any overlay draws; callers map world callbacks. @@ -736,7 +752,7 @@ function wrapRenderer( */ const currentFrame = (): number | undefined => timestampFrame >= 0 ? timestampFrame : raw.info?.frame; - const gpuFrameSample = (): { frame: number; ms: number } | undefined => { + const gpuFrameSample = (): IGpuFrameSample | undefined => { const frame = currentFrame(); const frames = raw.backend?.getTimestampFrames?.("render"); const sampled = frames?.[frames.length - 1]; @@ -753,7 +769,19 @@ function wrapRenderer( timestamp <= 0 ) return undefined; - return { frame: sampled, ms: timestamp }; + // The owned engine times its own passes (`info.engine.gpuSplit`); the split joins the sample + // only while it describes that same frame. + const split = ( + raw.info as { engine?: { gpuSplit?: IGpuFrameSplit & { frame: number } } } | undefined + )?.engine?.gpuSplit; + if (split?.frame !== sampled) return { frame: sampled, ms: timestamp }; + return { + frame: sampled, + ms: timestamp, + main: split.main, + shadow: split.shadow, + other: split.other, + }; }; /** diff --git a/packages/three-native/src/browser-renderer.ts b/packages/three-native/src/browser-renderer.ts index c3cd695732..837ae56c0d 100644 --- a/packages/three-native/src/browser-renderer.ts +++ b/packages/three-native/src/browser-renderer.ts @@ -278,6 +278,10 @@ export function defineWebRenderer( // The GPU milliseconds of the sample `render.timestamp` reports, by named stage (gpu-stages.ts). A stage that // drew no pass is absent; core's `stageGpuReading` hands it to the GI and shadow budgets. gpuStages: {} as Record, + // The same sample split by pass (frame_gpu_timer.h segments): the scene pass, every shadow map, and the rest + // (other passes, output, gather). `frame` is the sample's source frame, -1 until one resolves; core's + // `gpuFrameSample` hands the split to the frame budget's `gpuMain`, `gpuShadow` and `gpuOther`. + gpuSplit: { frame: -1, main: 0, shadow: 0, other: 0 }, // The last frame's shadow passes, the spot and point light maps apart from the sun's (PRD-650): // lamp maps redrawn and their draws, every shadow draw (the sun's are the rest), and the GPU // milliseconds of each part in the sample `render.timestamp` reports, -1 until one resolves. @@ -730,6 +734,17 @@ export function defineWebRenderer( this.info.engine.shadows.lampGpuMs = module._tnw_web_frame( WEB_FRAME_FIELD.gpuSegmentLampShadow, ); + const segment = (field: number): number => Math.max(0, module._tnw_web_frame(field)); + const split = this.info.engine.gpuSplit; + split.frame = this.#timestampFrames[0] ?? -1; + split.main = segment(WEB_FRAME_FIELD.gpuSegmentScene); + split.shadow = + segment(WEB_FRAME_FIELD.gpuSegmentShadow) + + segment(WEB_FRAME_FIELD.gpuSegmentLampShadow); + split.other = + segment(WEB_FRAME_FIELD.gpuSegmentOther) + + segment(WEB_FRAME_FIELD.gpuSegmentOutput) + + segment(WEB_FRAME_FIELD.gpuSegmentGather); // Opt-in, as `__tnPassLog`: a probe that sets `__tnGpuSegments` to an array gets each resolved sample's // split (frame_gpu_timer.h segments: sun shadow, scene, other passes, output, gather, lamp shadow). (globalThis as { __tnGpuSegments?: number[][] }).__tnGpuSegments?.push([ From b088ae932e33f9e06f13ac8a7f49c5ef738ed810 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:10:13 -0700 Subject: [PATCH 015/130] docs(prd-871): the lamp range lever holds on a kit; phase 1 done Laptop window lit-holdout-laptop (Iris Xe, uncapped, two runs per arm): the stress lab adaptive lane 7.1 to 36.0 fps, the tower-defense kit 109.3 to 114.2 fps. Desktop window lit-holdout: every lab head run voided by CI pool load; its kit pair holds. Ledger rows #338-#344. The learnings file gets the lever entry and the method traps. --- PERFORMANCE-LEARNINGS.md | 37 ++ ...ab-laptop-parity-and-desktop-shadow-cpu.md | 8 +- docs/stress-loop/ledger.json | 440 ++++++++++++++++++ 3 files changed, 481 insertions(+), 4 deletions(-) diff --git a/PERFORMANCE-LEARNINGS.md b/PERFORMANCE-LEARNINGS.md index 4c4e12c323..7802220698 100644 --- a/PERFORMANCE-LEARNINGS.md +++ b/PERFORMANCE-LEARNINGS.md @@ -108,6 +108,43 @@ Method traps found, each one fixed in the tool unless it says open: - Open: the goals are proxies (main draws, JS render p50) and fps counts twice in the score. Unreal has no `play` arm. The vanilla arm has no physics engine and one sun map. +### 2026-11-10 — A lamp shades only the fragments its range reaches — KEEP + +- Where: `packages/runtime-native/src/engine/shader/standard.cpp` (`ranged`, `held`, `shade` in + `buildStandardProgram` and `buildLit`); `078aae53b`, `fa8f612a1`. UE 5.8.3: + `Engine/Shaders/Private/DeferredLightingCommon.ush:334-344` (`BRANCH if( LightMask > 0 )`). +- Cause: every lit fragment evaluated the GGX lobe of all 16 torches of the lab, whatever the distance. Past its + cutoff a lamp's attenuation is exactly 0, so the work changed no pixel. +- Attribution (laptop Iris Xe, `seg-probe`, one run per variant): scene pass 203 ms of a 235 ms GPU frame; + `&noLights` 7.8 ms; `&noTrees` 192 ms; `&noFires` and `&noRocks` no change; 640x360 82 ms. Shadows were 11 ms. +- Result (laptop paired probe, two runs per arm, uncapped): scene pass 206-224 to 8.6 ms; `real` 3.6 to 25.3 fps, + adaptive `low` 6.3 to 34.8 fps, `play` 3.35 to 19.9 fps. Desktop RTX 2080 (#334-#335): 95.0 to 96.7 fps, GPU p95 + 5.99 to 5.05 ms; that frame is CPU-bound. +- Visual judge (fresh Opus, three poses, two runs per arm): NEUTRAL. The torch glow on a house wall reads + RGB 214.4/168.7/131.8 in both arms. +- Rules: a value that every light reads (normal map, colour texel, derivatives) goes in a `var` before the first + branch, because WGSL refuses a texture sample or a derivative in non-uniform control flow. A lamp that reads a + shadow map keeps the straight path for the same reason. +- Ceiling: 16 lamps now cost 0.6 ms of the Iris Xe scene pass. A light grid pays only past about 64 lamps. +- Holdout (laptop, `docs/stress-loop/ledger.json` #341-#344): the `tower-defense` kit holds, 109.3 to 114.2 fps. + +### 2026-11-10 — Method: the owned engine's GPU frame by pass, and staging an engine into a built game + +- The owned engine times its passes (`frame_gpu_timer.h`: sun shadow, scene, other, output, gather, lamp shadow), + but core's `gpuMain`, `gpuShadow` and `gpuOther` stayed empty because the pass recorder cannot reach it. A probe + read them through `globalThis.__tnGpuSegments`; since `4bd398993` they ride on `TN_FRAME_BUDGET`. +- Pinned `high` is not what an integrated GPU player gets. Measure `?stress&adaptive`: on the Iris Xe the policy + lands on `low`, and the lane is named `real-adaptive-`. +- A candidate build of a game can reuse a built `dist`: replace the two engine `.wasm` files and patch the + `ASM_CONSTS` address of `TN_GPU_PASSES` in the glue chunks, which moves with each Wasm link (the untracked + `artifacts/stress-lab-audit/tools/stage-engine.sh` of the audit worktree does both). Without the patch the page throws + `ASM_CONSTS[code] is not a function`. +- The scoreboard reads only arms named `head`, `develop` or `stack`. A fair window with other arm names records + rows that never reach it. +- The desktop needs a connected monitor for a headed browser: with none, Brave never writes its DevTools port. +- The `tn-local` CI pool on the desktop can raise the load to 27 in the middle of a window. The window voids those + runs; read the VOID lines before you trust a row count. + ### 2026-11-10 — Auto batch scans what arrived, not the scene, after each add or remove — KEEP - Where: `packages/core/src/auto-batch.ts` (`#pending`, `#hangs`, `#visit`, `#finish`); `744a9321c`. diff --git a/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md b/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md index 47e0aa1e4a..cb05b1e921 100644 --- a/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md +++ b/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md @@ -134,18 +134,18 @@ Risks: #### Phase 1: the lamp range lever is closed -**Status:** IN PROGRESS +**Status:** DONE **Files:** `packages/runtime-native/src/engine/shader/standard.cpp` (`buildLit`), `packages/runtime-native/tests/native-engine/material_test.cpp`, `docs/stress-loop/ledger.json`, `PERFORMANCE-LEARNINGS.md`. **Implementation:** reuse `ranged`, `held` and the branch from `buildStandardProgram` for the Lambert and Phong loop. Record the laptop paired probe and one valid desktop window as ledger rows with the candidate arm named `head`. Add the learnings entry (method traps of the session included: scoreboard arm names, the desktop needs a live display for a headed browser, the owned engine reports its GPU split through `globalThis.__tnGpuSegments`, the glue constant `ASM_CONSTS` moves with each Wasm build). **Verification:** `cmake --build build/wasm --target tn-native-engine-material-test && ctest --test-dir build/wasm -R native_engine_material` (run from `packages/runtime-native`, after `source ~/emsdk/emsdk_env.sh`). - [x] Standard and physical programs branch on lamp range. proof: `ctest --test-dir build/wasm -R native_engine_material` — 21 passed, 2026-11-10; `native_engine_material_lamp_range_branch` failed on the old shader and passes on `078aae53b`. - [x] Visual judge NEUTRAL on `?stress&still`, `?stress&freezeAt=12` and `?stress&play&freezeAt=8`, two runs per arm on the Iris Xe. proof: fresh Opus judge on `artifacts/stress-lab-audit/judge-range/` — NEUTRAL overall, 2026-11-10: torch glow on the house wall reads RGB 214.4/168.7/131.8 before and after (`freezeAt=12`), and 215.0-215.5 before against 215.2-215.9 after (`play`); no cutoff edge. The `still` pose shows no torch light, so it proves nothing about the lamps. -- [ ] Lambert and Phong programs branch on lamp range too, and a kit with lamps (`adventure` or `shooter`) holds or gains FPS as the holdout. proof: `native_engine_material_lamp_range_branch` extended to `buildLambert` and `buildPhong`; holdout arm in one fair window; ledger rows. +- [x] Lambert and Phong programs branch on lamp range too, and a kit with lamps (`adventure` or `shooter`) holds or gains FPS as the holdout. proof: `native_engine_material_lamp_range_branch` extended to `buildLambert` and `buildPhong`; holdout arm in one fair window; ledger rows. — `fa8f612a1`; `ctest -R native_engine_material` 21 passed, 2026-11-10 (`lit("dps") == lit("d") + 2`). Holdout: the `tower-defense` kit (point lamps, standard materials), laptop window `lit-holdout-laptop` rows #341-#344: kit 109.3 → 114.2 fps (holds); lab `?stress&adaptive` 7.1 → 36.0 fps. The desktop window `lit-holdout` (#338-#340) voided every lab head run (load 24-34 from the CI pool); its kit pair holds (320 → 333 fps). Native run: not done, the owned engine on desktop shares this shader source. #### Phase 2: the laptop adaptive lane reaches vanilla speed -**Status:** NOT STARTED +**Status:** IN PROGRESS **Files:** `packages/core/src/look/adaptiveQuality.ts`, `packages/core/src/look/qualityPolicy.ts`, their specs under `packages/core/__tests__/`, `packages/create-threenative/conventions.json` when the rule becomes a convention. **Implementation:** run `engine_search_capabilities` for the shadow tier first. Decide the layer in writing (engine policy or kit source) and quote the repository rule that decides it. Apply the tier's `castShadows` where the tier changes, keep the named override, and report the overridden state. Feed the owned engine's GPU pass split into `TN_FRAME_BUDGET`, so a game reads it without the lab's hook. **Verification:** a laptop fair window with base and candidate lab builds, arms `?stress&adaptive&arm=base` and `?stress&adaptive&arm=head`, plus a visual judge at each tier the change touches. @@ -155,7 +155,7 @@ Risks: #### Phase 3: the desktop frame loses its shadow planning cost -**Status:** NOT STARTED +**Status:** IN PROGRESS **Files:** `packages/runtime-native/src/engine/renderer/renderer_frame.cpp`, `packages/runtime-native/src/engine/renderer/shadows/virtual/atlas.cpp`, `atlas.h`, `packages/runtime-native/tests/native-engine/vsm/vsm_atlas_test.cpp`. **Implementation:** one hypothesis per candidate, each with its expected gain written first. Start with the per-caster work of `planVirtualPages` for casters whose transform and geometry did not change. Then attribute the per-frame upload by counter. Then profile `play` and cut its largest removable term. Use Opus 5.5 high for the C++ changes and DeepSeek V4.1 Flash max for searches and row bookkeeping (owner routing, 2026-11-10). **Verification:** `ctest --test-dir build/wasm -L native-engine -R "vsm|render_database|renderer"`, then a paired desktop fair window (`--passes uncapped`, arms `base` and `head`, plus `plainShadow` as the ceiling), a holdout kit, and a visual judge on `?stress&closeup` and `?stress&freezeAt=12` for each change to shadow planning. diff --git a/docs/stress-loop/ledger.json b/docs/stress-loop/ledger.json index ed16ded525..d4ef378cff 100644 --- a/docs/stress-loop/ledger.json +++ b/docs/stress-loop/ledger.json @@ -19819,6 +19819,446 @@ "dpr": 1, "pass": "uncapped", "shadow": "plain" + }, + { + "id": 338, + "at": "2026-11-11T00:59:22.252Z", + "commit": "core@0.3.13", + "label": "FAIR window lit-holdout (arm=base&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=base&uncapped", + "notes": "fair window lit-holdout: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 1 run(s) (1 void), load peak 11.87, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-lit-holdout-uncapped-threenative-wasm-127-0-0-1-5744-main-html-stress-arm-base.json", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 65.23, + "windows": 13, + "metrics": { + "presentedP50Ms": 12.675, + "presentedP95Ms": 22.23, + "rafStampP95Ms": 17.39, + "presentedP99Ms": 33.63, + "frameP99Ms": 22.17, + "frameWorstP95Ms": 40.33, + "renderSkipPct": 0.73, + "fps": 76.1, + "gpuP50Ms": 5.72, + "gpuP95Ms": 6.11, + "gpuMainMs": 1.95, + "gpuShadowMs": 1.19, + "gpuOtherMs": 2.58, + "renderP50Ms": 10.54, + "updateP50Ms": 1.83, + "frameP50Ms": 12.23, + "frameP95Ms": 16.78, + "mainDraws": 57, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 8, + "loadMs": 448, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 1198.38, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "90335fdf1cef", + "commit": "fa8f612a1" + }, + "load": { + "peak": 11.87, + "samples": 10 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 339, + "at": "2026-11-11T00:59:22.252Z", + "commit": "holdout", + "label": "FAIR window lit-holdout (holdout=td&arm=base&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "holdout=td&arm=base&uncapped", + "notes": "fair window lit-holdout: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 2 run(s) (0 void), load peak 17.6, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-lit-holdout-uncapped-threenative-wasm-127-0-0-1-5761-index-html-holdout-td-arm-base.json", + "preset": "holdout:td", + "layers": [], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 55.44, + "windows": 12, + "metrics": { + "presentedP50Ms": 0.3, + "presentedP95Ms": 3.028, + "rafStampP95Ms": 2.765, + "presentedP99Ms": 5.88, + "frameP99Ms": 2.95, + "frameWorstP95Ms": 14.41, + "renderSkipPct": 68.71, + "fps": 320.245, + "gpuP50Ms": 2.795, + "gpuP95Ms": 3.13, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 0.07, + "updateP50Ms": 0, + "frameP50Ms": 0.095, + "frameP95Ms": 2.37, + "mainDraws": 67, + "shadowDraws": null, + "mainTrianglesM": 0.01, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": null, + "loadMs": null, + "bridgeCrossingsP50": 11, + "hostCallsP50": 11, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": {}, + "build": { + "kind": "wasm", + "sha256": "be6399c69ee3", + "commit": "fa8f612a1" + }, + "load": { + "peak": 17.6, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 340, + "at": "2026-11-11T00:59:22.252Z", + "commit": "holdout", + "label": "FAIR window lit-holdout (holdout=td&arm=head&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "holdout=td&arm=head&uncapped", + "notes": "fair window lit-holdout: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 2 run(s) (0 void), load peak 11.6, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-lit-holdout-uncapped-threenative-wasm-127-0-0-1-5762-index-html-holdout-td-arm-head.json", + "preset": "holdout:td", + "layers": [], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 58.9, + "windows": 12, + "metrics": { + "presentedP50Ms": 0.292, + "presentedP95Ms": 2.768, + "rafStampP95Ms": 2.555, + "presentedP99Ms": 3.963, + "frameP99Ms": 2.93, + "frameWorstP95Ms": 3.72, + "renderSkipPct": 73.14, + "fps": 332.745, + "gpuP50Ms": 2.785, + "gpuP95Ms": 3.17, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 0.06, + "updateP50Ms": 0, + "frameP50Ms": 0.09, + "frameP95Ms": 2.25, + "mainDraws": 67, + "shadowDraws": null, + "mainTrianglesM": 0.01, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": null, + "loadMs": null, + "bridgeCrossingsP50": 11, + "hostCallsP50": 11, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": {}, + "build": { + "kind": "wasm", + "sha256": "4bf1bf948eb6", + "commit": "fa8f612a1" + }, + "load": { + "peak": 11.6, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 341, + "at": "2026-11-11T01:09:21.034Z", + "commit": "core@0.3.13", + "label": "FAIR window lit-holdout-laptop (adaptive&arm=base&uncapped): lamp range branch fa8f612a1", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "adaptive&arm=base&uncapped", + "notes": "laptop window lit-holdout-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; base = lab before the lamp range branch (dist-cand3), head = fa8f612a1 (PBR + Lambert/Phong range branch); td = the tower-defense kit as the holdout", + "preset": "real-adaptive-low", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.01, + "windows": 13, + "metrics": { + "presentedP50Ms": 17.18, + "presentedP95Ms": 17.943, + "rafStampP95Ms": 17.765, + "presentedP99Ms": 22.885, + "frameP99Ms": 21.775, + "frameWorstP95Ms": 18.325, + "renderSkipPct": 88.55, + "fps": 7.09, + "gpuP50Ms": 144.26, + "gpuP95Ms": 148.415, + "gpuMainMs": 121.915, + "gpuShadowMs": 11.045, + "gpuOtherMs": 6.005, + "renderP50Ms": 1.87, + "updateP50Ms": 1.835, + "frameP50Ms": 4.365, + "frameP95Ms": 17.375, + "mainDraws": 41, + "shadowDraws": 103, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 1, + "loadMs": 679, + "bridgeCrossingsP50": 26, + "hostCallsP50": 26, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "90335fdf1cef", + "commit": "fa8f612a1" + }, + "load": { + "peak": 3.45, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 342, + "at": "2026-11-11T01:09:21.037Z", + "commit": "core@0.3.13", + "label": "FAIR window lit-holdout-laptop (adaptive&arm=head&uncapped): lamp range branch fa8f612a1", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "adaptive&arm=head&uncapped", + "notes": "laptop window lit-holdout-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; base = lab before the lamp range branch (dist-cand3), head = fa8f612a1 (PBR + Lambert/Phong range branch); td = the tower-defense kit as the holdout", + "preset": "real-adaptive-low", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.04, + "windows": 13, + "metrics": { + "presentedP50Ms": 15.487, + "presentedP95Ms": 19.04, + "rafStampP95Ms": 17.765, + "presentedP99Ms": 23.487, + "frameP99Ms": 22.805, + "frameWorstP95Ms": 20.365, + "renderSkipPct": 47.995, + "fps": 35.985, + "gpuP50Ms": 25.865, + "gpuP95Ms": 27.11, + "gpuMainMs": 8.345, + "gpuShadowMs": 11.395, + "gpuOtherMs": 6.1, + "renderP50Ms": 10.935, + "updateP50Ms": 1.82, + "frameP50Ms": 11.865, + "frameP95Ms": 18.095, + "mainDraws": 41, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 1, + "loadMs": 739, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 712.32, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "5dc52088f4e9", + "commit": "fa8f612a1" + }, + "load": { + "peak": 4.37, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 343, + "at": "2026-11-11T01:09:21.038Z", + "commit": "holdout", + "label": "FAIR window lit-holdout-laptop (holdout=td&arm=base&uncapped): lamp range branch fa8f612a1", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "holdout=td&arm=base&uncapped", + "notes": "laptop window lit-holdout-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; base = lab before the lamp range branch (dist-cand3), head = fa8f612a1 (PBR + Lambert/Phong range branch); td = the tower-defense kit as the holdout", + "preset": "holdout:td", + "layers": [], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 60.81, + "windows": 12, + "metrics": { + "presentedP50Ms": 0.618, + "presentedP95Ms": 3.158, + "rafStampP95Ms": 3.39, + "presentedP99Ms": 4.943, + "frameP99Ms": 4.02, + "frameWorstP95Ms": 2.7, + "renderSkipPct": 89.83, + "fps": 109.305, + "gpuP50Ms": 7.98, + "gpuP95Ms": 8.985, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 0.14, + "updateP50Ms": 0, + "frameP50Ms": 0.185, + "frameP95Ms": 2.485, + "mainDraws": 54, + "shadowDraws": null, + "mainTrianglesM": 0.01, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": null, + "loadMs": null, + "bridgeCrossingsP50": 11, + "hostCallsP50": 11, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": {}, + "build": { + "kind": "wasm", + "sha256": "be6399c69ee3", + "commit": "fa8f612a1" + }, + "load": { + "peak": 4.49, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 344, + "at": "2026-11-11T01:09:21.038Z", + "commit": "holdout", + "label": "FAIR window lit-holdout-laptop (holdout=td&arm=head&uncapped): lamp range branch fa8f612a1", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "holdout=td&arm=head&uncapped", + "notes": "laptop window lit-holdout-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; base = lab before the lamp range branch (dist-cand3), head = fa8f612a1 (PBR + Lambert/Phong range branch); td = the tower-defense kit as the holdout", + "preset": "holdout:td", + "layers": [], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 60.22, + "windows": 12, + "metrics": { + "presentedP50Ms": 1.125, + "presentedP95Ms": 4.425, + "rafStampP95Ms": 4.555, + "presentedP99Ms": 6.07, + "frameP99Ms": 4.675, + "frameWorstP95Ms": 3.6, + "renderSkipPct": 80.99, + "fps": 114.16, + "gpuP50Ms": 7.825, + "gpuP95Ms": 8.885, + "gpuMainMs": null, + "gpuShadowMs": null, + "gpuOtherMs": null, + "renderP50Ms": 0.18, + "updateP50Ms": 0.005, + "frameP50Ms": 0.265, + "frameP95Ms": 3.285, + "mainDraws": 54, + "shadowDraws": null, + "mainTrianglesM": 0.01, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": null, + "loadMs": null, + "bridgeCrossingsP50": 11, + "hostCallsP50": 11, + "gpuUploadKbP50": 0, + "jsAllocKbP50": 0 + }, + "counts": {}, + "build": { + "kind": "wasm", + "sha256": "4bf1bf948eb6", + "commit": "fa8f612a1" + }, + "load": { + "peak": 4.36, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" } ] } From a38190bff4aec00105d7bec0be833598ccdeaaed Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:22:03 -0700 Subject: [PATCH 016/130] perf(shadows): a moving rigid caster keeps its vertex box The virtual shadow planner keyed each draw's shadow box on its world matrix too, so a rigid caster that moved re-read every vertex position to rebuild a box that only needed the new transform. The draw cache now keeps the vertex box in geometry space, keyed by the position store revisions alone. Stress lab, desktop, temporary sub-timers: 234 moving rigid draws a frame; vertex scans 234 -> 0, bounds rebuild 1.38 -> 1.12 ms. The rest of the rebuild is 240 rig draws (0.87 ms) and 11 instanced draws (0.20 ms). PRD-871 Phase 3. Red-green: virtual_shadow_bounds_cache (render_database_test) failed on the vertex scan count before, passes after; all 109 database cases and the shadow render cases pass on a native engine-only build. --- .../src/engine/renderer/renderer.cpp | 25 +++++++++++++------ .../src/engine/renderer/renderer.h | 5 ++++ .../native-engine/render_database_test.cpp | 5 ++++ 3 files changed, 28 insertions(+), 7 deletions(-) diff --git a/packages/runtime-native/src/engine/renderer/renderer.cpp b/packages/runtime-native/src/engine/renderer/renderer.cpp index 2b027d9d99..9b8388c92b 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.cpp +++ b/packages/runtime-native/src/engine/renderer/renderer.cpp @@ -38,14 +38,25 @@ const std::pair& DrawCache::virtualShadowBounds(const DrawItem& it // morphing each vertex on the CPU here, every frame, cost the MCP forest about 6 % of its CPU. bounds = item.morphGeometry->morphReachBox(); } else if (item.positions->scalar() == Scalar::F32) { - for (uint64_t idx = 0; idx + 2 < item.positions->count(); idx += 3) { - float p[3]; item.positions->read(idx * 4, p, sizeof(p)); - Vector3 vertex(p[0], p[1], p[2]); - if ((item.boneMatrices || item.morphGeometry) && item.shadowObject) { - const auto* mesh = dynamic_cast(item.shadowObject); - if (mesh) mesh->getVertexPosition(idx / 3, vertex); + const bool rigid = !item.boneMatrices && !item.morphGeometry; + const auto vertexKey = std::make_tuple(item.positions, item.positions->version(), item.positions->epoch(), + item.positions->writes()); + // The owner check rules out a released store whose address came back with the same revisions. + if (rigid && vertexBoxKey == vertexKey && !shadowPositionsOwner.expired()) { + bounds = vertexBox; + } else { + ++shadowVertexScans; + for (uint64_t idx = 0; idx + 2 < item.positions->count(); idx += 3) { + float p[3]; item.positions->read(idx * 4, p, sizeof(p)); + Vector3 vertex(p[0], p[1], p[2]); + if ((item.boneMatrices || item.morphGeometry) && item.shadowObject) { + const auto* mesh = dynamic_cast(item.shadowObject); + if (mesh) mesh->getVertexPosition(idx / 3, vertex); + } + bounds.expandByPoint(vertex); } - bounds.expandByPoint(vertex); + vertexBox = bounds; + vertexBoxKey = rigid ? std::optional(vertexKey) : std::nullopt; } } Matrix4 model; model.elements = item.matrixWorld; diff --git a/packages/runtime-native/src/engine/renderer/renderer.h b/packages/runtime-native/src/engine/renderer/renderer.h index 11f28e9eb1..8d490ab6f9 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.h +++ b/packages/runtime-native/src/engine/renderer/renderer.h @@ -135,6 +135,11 @@ struct DrawCache { std::optional shadowBoundsKey; std::weak_ptr shadowPositionsOwner, shadowInstancesOwner; uint64_t shadowBoundsBuilds = 0; + uint64_t shadowVertexScans = 0; // rebuilds that read every vertex position + // A rigid draw's vertex box in geometry space, keyed by the position store's revisions alone: a caster that only + // moved rebuilds its world box from this, without reading a vertex (PRD-871). + std::optional> vertexBoxKey; + Box3 vertexBox; const std::pair& virtualShadowBounds(const DrawItem& item); }; diff --git a/packages/runtime-native/tests/native-engine/render_database_test.cpp b/packages/runtime-native/tests/native-engine/render_database_test.cpp index a5c50d41aa..442ab427f0 100644 --- a/packages/runtime-native/tests/native-engine/render_database_test.cpp +++ b/packages/runtime-native/tests/native-engine/render_database_test.cpp @@ -8711,8 +8711,13 @@ void virtualShadowBoundsCache() { const auto built = cache.shadowBoundsBuilds; sample(); CHECK(cache.shadowBoundsBuilds == built); + // A rigid caster that moves keeps its vertex box: the new transform rebuilds the world box from it, and no + // vertex is read again. Re-reading them cost the stress lab 234 moving casters a frame (PRD-871). + const auto scans = cache.shadowVertexScans; model.makeTranslation(3, 0, 0); item.matrixWorld = model.elements; CHECK(sample().first.max.x == 4); + CHECK(cache.shadowBoundsBuilds == built + 1); + CHECK(cache.shadowVertexScans == scans); const float changed[3] = {20, 2, 3}; CHECK(item.positions->write(0, changed, sizeof(changed)) == BufferError::None); CHECK(sample().first.max.x == 23); From 7482c204ca4e92ea62140f2fd28a771531de8fc1 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:27:09 -0700 Subject: [PATCH 017/130] perf(skinning): a rig shadow box reads each bone stretch alone The virtual shadow box of a skinned draw multiplied each bone world matrix by its inverse bind matrix every frame, only to read the stretch of the product. The inverse bind stretch now rides on the cached bone reach, so a frame reads the stretch of the bone world matrix alone. The product of the two stretches bounds the stretch of the product, and equals it for a rotation with uniform scale, so the box still holds every skinned vertex. Stress lab, desktop, temporary sub-timers, 240 rig draws a frame: rig box 0.87 -> 0.72 ms. PRD-871 Phase 3. Tests: skeleton, skinned palette, VSM, glTF hierarchy and fixture cases in the Wasm build (23 passed); render database, skinned crowd, renderer and shadow render cases on a native engine-only build (115 passed). The ledger also takes rows #345-#346: the laptop Phase 2 window (adaptive, both arms at low: base 35.8 fps, head 63.2 fps, shadow pass 11.3 -> 0 ms). --- docs/stress-loop/ledger.json | 136 ++++++++++++++++++ .../engine/animation/skinning/skeleton.cpp | 46 +++--- 2 files changed, 161 insertions(+), 21 deletions(-) diff --git a/docs/stress-loop/ledger.json b/docs/stress-loop/ledger.json index d4ef378cff..604efbd06d 100644 --- a/docs/stress-loop/ledger.json +++ b/docs/stress-loop/ledger.json @@ -20259,6 +20259,142 @@ "dpr": 1, "pass": "uncapped", "shadow": "virtual" + }, + { + "id": 345, + "at": "2026-11-11T01:26:27.668Z", + "commit": "core@0.3.13", + "label": "FAIR window p2-shadow-tier-laptop (adaptive&arm=base&uncapped): low tier draws no shadow pass", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "adaptive&arm=base&uncapped", + "notes": "laptop window p2-shadow-tier-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; base = dist-lit (fa8f612a1 engine, old starter post), head = the starter low tier draws no shadow pass (6dd609eca) + TN_FRAME_BUDGET GPU split (4bd398993); both arms settle at low; head runs 76.4 and 49.9 fps with the same GPU frame (11.5 ms, shadow 0): the laptop throttles at 100 C between runs", + "preset": "real-adaptive-low", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.01, + "windows": 13, + "metrics": { + "presentedP50Ms": 15.453, + "presentedP95Ms": 18.875, + "rafStampP95Ms": 17.745, + "presentedP99Ms": 22.922, + "frameP99Ms": 22.09, + "frameWorstP95Ms": 19.675, + "renderSkipPct": 48.965, + "fps": 35.765, + "gpuP50Ms": 25.885, + "gpuP95Ms": 27.205, + "gpuMainMs": 8.235, + "gpuShadowMs": 11.355, + "gpuOtherMs": 6.06, + "renderP50Ms": 7.285, + "updateP50Ms": 1.71, + "frameP50Ms": 9.265, + "frameP95Ms": 18.44, + "mainDraws": 41, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 1, + "loadMs": 671.5, + "bridgeCrossingsP50": 33, + "hostCallsP50": 33, + "gpuUploadKbP50": 356.66, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "5dc52088f4e9", + "commit": "4bd398993" + }, + "load": { + "peak": 3.9, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 346, + "at": "2026-11-11T01:26:27.670Z", + "commit": "core@0.3.13", + "label": "FAIR window p2-shadow-tier-laptop (adaptive&arm=head&uncapped): low tier draws no shadow pass", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "adaptive&arm=head&uncapped", + "notes": "laptop window p2-shadow-tier-laptop, Iris Xe, uncapped, ABBA, mean of 2 runs; base = dist-lit (fa8f612a1 engine, old starter post), head = the starter low tier draws no shadow pass (6dd609eca) + TN_FRAME_BUDGET GPU split (4bd398993); both arms settle at low; head runs 76.4 and 49.9 fps with the same GPU frame (11.5 ms, shadow 0): the laptop throttles at 100 C between runs", + "preset": "real-adaptive-low", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "intel / gen-12lp", + "viewport": "1280x720", + "seconds": 65.02, + "windows": 13, + "metrics": { + "presentedP50Ms": 11.07, + "presentedP95Ms": 17.697, + "rafStampP95Ms": 17.57, + "presentedP99Ms": 18.805, + "frameP99Ms": 15.135, + "frameWorstP95Ms": 19.005, + "renderSkipPct": 26.15, + "fps": 63.16, + "gpuP50Ms": 11.5, + "gpuP95Ms": 12.205, + "gpuMainMs": 5.345, + "gpuShadowMs": 0, + "gpuOtherMs": 6.11, + "renderP50Ms": 6.945, + "updateP50Ms": 1.745, + "frameP50Ms": 8.615, + "frameP95Ms": 12.725, + "mainDraws": 41, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 1.5, + "loadMs": 736.5, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 1028.315, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "19d119098f56", + "commit": "4bd398993" + }, + "load": { + "peak": 4.75, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" } ] } diff --git a/packages/runtime-native/src/engine/animation/skinning/skeleton.cpp b/packages/runtime-native/src/engine/animation/skinning/skeleton.cpp index 8835010935..4ecfe6b7da 100644 --- a/packages/runtime-native/src/engine/animation/skinning/skeleton.cpp +++ b/packages/runtime-native/src/engine/animation/skinning/skeleton.cpp @@ -196,6 +196,24 @@ Vector3& SkinnedMesh::getVertexPosition(uint64_t index, Vector3& target) const { return applyBoneTransform(index, target); } +// The largest stretch of a matrix's linear part: the longest axis when the axes are orthogonal, +// else the Frobenius norm (a sheared bone chain can stretch past its longest axis). +static double linearStretch(const Matrix4& m) { + const auto& e = m.elements; + const double axes[3][3] = {{e[0], e[1], e[2]}, {e[4], e[5], e[6]}, {e[8], e[9], e[10]}}; + double lengths[3], frobenius = 0; + for (int a = 0; a < 3; ++a) { + lengths[a] = std::sqrt(axes[a][0] * axes[a][0] + axes[a][1] * axes[a][1] + axes[a][2] * axes[a][2]); + frobenius += lengths[a] * lengths[a]; + } + for (int a = 0; a < 3; ++a) + for (int b = a + 1; b < 3; ++b) { + const double dot = axes[a][0] * axes[b][0] + axes[a][1] * axes[b][1] + axes[a][2] * axes[b][2]; + if (std::abs(dot) > 1e-6 * lengths[a] * lengths[b]) return std::sqrt(frobenius); + } + return std::max({lengths[0], lengths[1], lengths[2]}); +} + bool SkinnedMesh::boneReachBounds(Box3& target) const { if (!skeleton || !geometry || !geometry->attributes.count("position") || !geometry->attributes.count("skinIndex") || !geometry->attributes.count("skinWeight") || skeleton->boneInverses.size() < skeleton->bones.size()) @@ -235,39 +253,25 @@ bool SkinnedMesh::boneReachBounds(Box3& target) const { } if (std::abs(sum - 1) > 1e-3) boneReachHolds_ = false; } + // Each reach carries its inverse bind matrix's stretch, so a frame reads only the bone's own: the + // product bounds the stretch of bone x inverse, exactly for a rotation with uniform scale, and spares + // a 4x4 multiply per bone per frame (PRD-871: 0.87 ms a frame for the stress lab's 120-rig crowd). + for (std::size_t j = 0; j < bones; ++j) + if (boneReach_[j] >= 0) boneReach_[j] *= linearStretch(skeleton->boneInverses[j]); } if (!boneReachHolds_) return false; // A skinned vertex is a weighted mean of its bones' rigid images, and each image lies within the // bone's reach (times the bone's scale) of the bone's posed position: the spheres' box holds it. - // The largest stretch of a matrix's linear part: the longest axis when the axes are orthogonal, - // else the Frobenius norm (a sheared bone chain can stretch past its longest axis). - const auto stretch = [](const Matrix4& m) { - const auto& e = m.elements; - const double axes[3][3] = {{e[0], e[1], e[2]}, {e[4], e[5], e[6]}, {e[8], e[9], e[10]}}; - double lengths[3], frobenius = 0; - for (int a = 0; a < 3; ++a) { - lengths[a] = std::sqrt(axes[a][0] * axes[a][0] + axes[a][1] * axes[a][1] + axes[a][2] * axes[a][2]); - frobenius += lengths[a] * lengths[a]; - } - for (int a = 0; a < 3; ++a) - for (int b = a + 1; b < 3; ++b) { - const double dot = axes[a][0] * axes[b][0] + axes[a][1] * axes[b][1] + axes[a][2] * axes[b][2]; - if (std::abs(dot) > 1e-6 * lengths[a] * lengths[b]) return std::sqrt(frobenius); - } - return std::max({lengths[0], lengths[1], lengths[2]}); - }; target.makeEmpty(); - const double meshScale = stretch(bindMatrixInverse); - Matrix4 matrix; + const double meshScale = linearStretch(bindMatrixInverse); Vector3 center; for (std::size_t j = 0; j < bones; ++j) { if (boneReach_[j] < 0) continue; Bone* bone = skeleton->bone(j); if (!bone) return false; bone->materializeWorldTransformIfPending(); - matrix.multiplyMatrices(bone->matrixWorld, skeleton->boneInverses[j]); center.setFromMatrixPosition(bone->matrixWorld).applyMatrix4(bindMatrixInverse); - const double radius = boneReach_[j] * stretch(matrix) * meshScale; + const double radius = boneReach_[j] * linearStretch(bone->matrixWorld) * meshScale; target.min.x = std::min(target.min.x, center.x - radius); target.min.y = std::min(target.min.y, center.y - radius); target.min.z = std::min(target.min.z, center.z - radius); From 97c33a8312f78ecd708bd6716f8e29fec8091a91 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:32:28 -0700 Subject: [PATCH 018/130] fix(starter): a tier raise that fails waits longer before the next try With the low tier now free of its shadow pass, the stress lab on an Iris Xe measures low at 12.7-13.7 ms: inside the 20% headroom line, so after five healthy windows the policy raised to medium (shadows on, 27 ms), fell back two windows later, and tried again after each 5 s cooldown. The shadows switched on and off every few seconds. Each raise that the next change undoes now doubles the wait before the next raise, up to 32 cooldowns; a raise that holds resets it. Steps down keep the plain cooldown. PRD-871 Phase 2. Spec: template-quality "should wait twice as long before retrying a raise the next window undid", red before (medium after one cooldown), green after; 55 quality and shadow-tier specs pass. --- .../__tests__/scaffold.spec.ts | 2 +- .../__tests__/template-quality.spec.ts | 28 +++++++++++++++++++ .../starter/src/render/adaptiveQuality.ts | 11 +++++++- 3 files changed, 39 insertions(+), 2 deletions(-) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index 3ac692d1fb..c84153db69 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -173,7 +173,7 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, starter: { shared: "45287923ee472856d7fee5781bf527f953789cb893b951151eaa3d64c1ac539b", - kit: "e9938d81b783b7b743c45465f87fce1b35b47a603fd6008ac4a08e155770439e", + kit: "66bbbe13a8ba71930f412b2f240bf342af2e7fdb6c8aaa51735898bc1b087bd0", }, "tower-defense": { shared: "9d9bbb42f8a7f0845bf827030f6978aa4feae5ac455e3739ee612e1d18c5bf9b", diff --git a/packages/create-threenative/__tests__/template-quality.spec.ts b/packages/create-threenative/__tests__/template-quality.spec.ts index eefa7d0b01..38dd510499 100644 --- a/packages/create-threenative/__tests__/template-quality.spec.ts +++ b/packages/create-threenative/__tests__/template-quality.spec.ts @@ -630,6 +630,34 @@ describe("starter adaptive quality", () => { expect(deadband.tier).toBe("medium"); }); + it("should wait twice as long before retrying a raise the next window undid (PRD-871)", async () => { + const { createAdaptiveQuality } = await loadAdaptive("starter"); + const clock = clockAt(0); + const policy = createAdaptiveQuality( + { mobile: true }, + { cooldownMs: 5000, now: clock.now, startupWindows: 0 }, + ); + // low fits with headroom (its shadow pass is off), medium does not: the raise fails. + clock.advance(6000); + expect(feed(policy, 1, 5, { gpuMs: 12 }).tier).toBe("medium"); + clock.advance(6000); + expect(feed(policy, 6, 2, { gpuMs: 27 }).tier).toBe("low"); + // One cooldown later low still has headroom, but the raise that just failed waits two. + clock.advance(6000); + const held = feed(policy, 8, 5, { gpuMs: 12 }); + expect(held.tier, "a failed raise is not retried after one cooldown").toBe("low"); + expect(held.reason).toBe("cooldown"); + clock.advance(4001); + expect(feed(policy, 13, 5, { gpuMs: 12 }).tier).toBe("medium"); + // A second failure doubles the wait again, and a stepped-down tier is never held back. + clock.advance(6000); + expect(feed(policy, 18, 2, { gpuMs: 27 }).tier).toBe("low"); + clock.advance(15000); + expect(feed(policy, 20, 5, { gpuMs: 12 }).tier).toBe("low"); + clock.advance(5001); + expect(feed(policy, 25, 5, { gpuMs: 12 }).tier).toBe("medium"); + }); + it("should keep an explicit tier pinned and still observe it", async () => { const { createAdaptiveQuality } = await loadAdaptive("starter"); const policy = createAdaptiveQuality({ mobile: false, tier: "high" }, { startupWindows: 0 }); diff --git a/packages/create-threenative/templates/starter/src/render/adaptiveQuality.ts b/packages/create-threenative/templates/starter/src/render/adaptiveQuality.ts index 79e7d5be84..cb3ab763e5 100644 --- a/packages/create-threenative/templates/starter/src/render/adaptiveQuality.ts +++ b/packages/create-threenative/templates/starter/src/render/adaptiveQuality.ts @@ -66,6 +66,11 @@ export function createAdaptiveQuality( let lastChange = Number.NEGATIVE_INFINITY; let overloaded = 0; let healthy = 0; + // Raises in a row that the next change undid. Each doubles the wait before the next raise (up to 32 cooldowns), + // so a tier that cannot hold is not retried every cooldown: medium's shadow pass on a tier that fits only without + // it switched the shadows on and off every few seconds (PRD-871). + let failedRaises = 0; + let lastDirection = 0; const finite = (value: number | undefined): value is number => value !== undefined && Number.isFinite(value); return { @@ -158,11 +163,15 @@ export function createAdaptiveQuality( if (next === undefined) return report(direction < 0 ? "at-floor" : "at-ceiling"); const time = now(); if (!Number.isFinite(time)) throw new Error("quality clock must return finite milliseconds."); - if (time - lastChange < policy.cooldownMs) { + const wait = policy.cooldownMs * (direction > 0 ? 2 ** Math.min(failedRaises, 5) : 1); + if (time - lastChange < wait) { overloaded = 0; healthy = 0; return report("cooldown"); } + if (direction < 0 && lastDirection > 0) failedRaises += 1; + else if (direction > 0 && lastDirection > 0) failedRaises = 0; + lastDirection = direction; tier = next; lastChange = time; overloaded = 0; From 52876410fc369792148f8866143613eeb91d78ce Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:37:50 -0700 Subject: [PATCH 019/130] feat(runtime-native): the V8 player publishes the GPU frame by pass The Wasm facade publishes info.engine.gpuSplit since 4bd398993; the V8 player now does the same from its timer segments (scene; sun and lamp shadows; other, output and gather), so TN_FRAME_BUDGET gpuMain, gpuShadow and gpuOther fill on desktop too. player-gpu-stages.mjs asserts the split names the sampled frame and matches the stage times. Its ball now moves: the scene was static, so the cached sun shadow map drew no pass in steady frames and the existing Shadow > 0 check failed with and without this branch (checked on a player built without the PRD-871 Phase 3 sources). PRD-871 Phase 2. Proof: node tests/native-engine/player-gpu-stages.mjs build/engine-only/tn-native-engine-player-v8, RTX 2080, PASS. --- .../runtime-native/src/engine/player/core-webgpu.mjs | 7 +++++++ .../tests/native-engine/player-gpu-stages.mjs | 9 +++++++++ 2 files changed, 16 insertions(+) diff --git a/packages/runtime-native/src/engine/player/core-webgpu.mjs b/packages/runtime-native/src/engine/player/core-webgpu.mjs index d4f8e4cd49..dacf5f4673 100644 --- a/packages/runtime-native/src/engine/player/core-webgpu.mjs +++ b/packages/runtime-native/src/engine/player/core-webgpu.mjs @@ -119,6 +119,13 @@ export class WebGPURenderer { if (segments[0] > 0) stages[SHADOW_STAGE] = segments[0]; return stages; }, + // The same sample split by pass, as the Wasm engine publishes it (PRD-871): the scene pass, every shadow map (sun + // and lamps), and the rest (other passes, output, gather). Core's `gpuFrameSample` hands it to TN_FRAME_BUDGET. + get gpuSplit() { + const { samples, source, segments } = globalThis.tn.renderInfo().gpuTiming; + if (!(samples > 0)) return undefined; + return { frame: source, main: segments[1], shadow: segments[0] + segments[5], other: segments[2] + segments[3] + segments[4] }; + }, }, frame: 0, reset() {}, diff --git a/packages/runtime-native/tests/native-engine/player-gpu-stages.mjs b/packages/runtime-native/tests/native-engine/player-gpu-stages.mjs index 1d2afc59f5..293bb701b6 100644 --- a/packages/runtime-native/tests/native-engine/player-gpu-stages.mjs +++ b/packages/runtime-native/tests/native-engine/player-gpu-stages.mjs @@ -50,6 +50,9 @@ let pipeline; const step = () => { frame += 1; renderer.info.frame = frame; + // The ball moves, so the sun's shadow map redraws each frame: a static scene keeps its cached map and draws no + // shadow pass, and \`Shadow\` reads absent. + ball.position.x = Math.sin(frame * 0.05); if (frame === 3) { const gather = rtt(vec4(0.2, 0.4, 0.6, 1), 256, 144, { type: 1016, gpuStage: "SSGI" }); pipeline = new RenderPipeline(renderer, gather.sample(screenUV)); @@ -60,6 +63,7 @@ const step = () => { seen.frames = renderer.backend.getTimestampFrames("render"); seen.timestamp = renderer.info.render.timestamp; seen.stages = renderer.info.engine.gpuStages; + seen.split = renderer.info.engine.gpuSplit; seen.reading = stageGpuReading(renderer) ?? null; seen.enabled = globalThis.tn.renderInfo().gpuTiming.enabled; globalThis.tn.log("GPU_STAGES " + JSON.stringify(seen)); @@ -106,6 +110,11 @@ assert(seen.timestamp > 0, verdict); assert(seen.stages.SSGI > 0, verdict); assert(seen.stages.SSGI < seen.timestamp, verdict); assert(seen.stages.Shadow > 0 && seen.stages.Shadow < seen.timestamp, verdict); +// PRD-871: the pass split names the same sample, so TN_FRAME_BUDGET's gpuMain, gpuShadow and gpuOther fill on the player. +assert.equal(seen.split.frame, seen.frames[0], verdict); +assert(seen.split.main > 0 && seen.split.main < seen.timestamp, verdict); +assert.equal(seen.split.shadow, seen.stages.Shadow ?? 0, verdict); +assert(seen.split.other >= seen.stages.SSGI, verdict); // The sample belongs to a frame the game has already presented, and core reads the same numbers. assert(seen.frames[0] > 0 && seen.frames[0] <= 120, verdict); assert.equal(seen.reading?.frame, seen.frames[0], verdict); From f00af7c4b60fd324a27f5b11323742ef36758782 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:39:01 -0700 Subject: [PATCH 020/130] docs(stress-loop): desktop window shadow-plan rows #347-#348 (PRD-871 Phase 3, inconclusive under host load 14-16) --- docs/stress-loop/ledger.json | 136 +++++++++++++++++++++++++++++++++++ 1 file changed, 136 insertions(+) diff --git a/docs/stress-loop/ledger.json b/docs/stress-loop/ledger.json index 604efbd06d..9843af3beb 100644 --- a/docs/stress-loop/ledger.json +++ b/docs/stress-loop/ledger.json @@ -20395,6 +20395,142 @@ "dpr": 1, "pass": "uncapped", "shadow": "virtual" + }, + { + "id": 347, + "at": "2026-11-11T01:35:13.285Z", + "commit": "core@0.3.13", + "label": "FAIR window shadow-plan (arm=base&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=base&uncapped", + "notes": "fair window shadow-plan: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 1 run(s) (1 void), load peak 15.39, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-shadow-plan-uncapped-threenative-wasm-127-0-0-1-5780-main-html-stress-arm-base.json", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 67.57, + "windows": 13, + "metrics": { + "presentedP50Ms": 12.185, + "presentedP95Ms": 25.11, + "rafStampP95Ms": 16.75, + "presentedP99Ms": 67.595, + "frameP99Ms": 23.48, + "frameWorstP95Ms": 119.88, + "renderSkipPct": 0.56, + "fps": 76.05, + "gpuP50Ms": 4.82, + "gpuP95Ms": 5.11, + "gpuMainMs": 1.02, + "gpuShadowMs": 1.18, + "gpuOtherMs": 2.59, + "renderP50Ms": 10.22, + "updateP50Ms": 1.56, + "frameP50Ms": 12.09, + "frameP95Ms": 16.16, + "mainDraws": 57, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 92, + "loadMs": 532, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 1198.57, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "19d119098f56", + "commit": "7482c204c" + }, + "load": { + "peak": 15.39, + "samples": 10 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" + }, + { + "id": 348, + "at": "2026-11-11T01:35:13.285Z", + "commit": "core@0.3.13", + "label": "FAIR window shadow-plan (arm=head&uncapped)", + "decision": "measure", + "engine": "threenative-wasm", + "variant": "arm=head&uncapped", + "notes": "fair window shadow-plan: clean browser, 1280x720 DPR 1, 80 s, ABBA, uncapped pass, mean of 2 run(s) (0 void), load peak 16.57, runs /home/joao/.cache/tmp-tn/stress-loop/hunt/fair-shadow-plan-uncapped-threenative-wasm-127-0-0-1-5781-main-html-stress-arm-head.json", + "preset": "real", + "layers": ["trees", "rocks", "houses", "props", "crowd", "bodies", "fires", "lights"], + "adapter": "nvidia / turing", + "viewport": "1280x720", + "seconds": 65.06, + "windows": 13, + "metrics": { + "presentedP50Ms": 13.065, + "presentedP95Ms": 30.095, + "rafStampP95Ms": 26.54, + "presentedP99Ms": 46.703, + "frameP99Ms": 35.86, + "frameWorstP95Ms": 45.06, + "renderSkipPct": 0.59, + "fps": 66.605, + "gpuP50Ms": 4.915, + "gpuP95Ms": 5.69, + "gpuMainMs": 1.095, + "gpuShadowMs": 1.175, + "gpuOtherMs": 2.575, + "renderP50Ms": 11.31, + "updateP50Ms": 1.815, + "frameP50Ms": 12.97, + "frameP95Ms": 24.595, + "mainDraws": 57, + "shadowDraws": 15, + "mainTrianglesM": 2.11, + "shadowTrianglesM": null, + "hitchesPerMin": 0, + "longTasks": 41.5, + "loadMs": 646.5, + "bridgeCrossingsP50": 34, + "hostCallsP50": 34, + "gpuUploadKbP50": 1198.475, + "jsAllocKbP50": 0 + }, + "counts": { + "trees": 4000, + "rocks": 6000, + "houses": 120, + "props": 600, + "crowd": 120, + "bodies": 300, + "particles": 18000, + "lights": 16 + }, + "build": { + "kind": "wasm", + "sha256": "47df7c28d872", + "commit": "7482c204c" + }, + "load": { + "peak": 16.57, + "samples": 20 + }, + "dpr": 1, + "pass": "uncapped", + "shadow": "virtual" } ] } From b3c90884b427273c6ebd7445f1800f2c0e2fd2be Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:44:10 -0700 Subject: [PATCH 021/130] fix(starter): a tier raise that failed waits until its cost would fit The backoff of 97c33a831 still retried medium every few cooldowns on the Iris Xe stress lab, and each retry showed the shadows for two windows. The policy now keeps what a failed raise cost as a ratio of the cost before it (medium 28 ms over low 12 ms) and raises from that tier again only when the predicted cost fits the headroom line. A lighter scene still climbs; a scene that cannot hold medium stays at low and reports reason=predicted. PRD-871 Phase 2. Spec: template-quality "should not retry a raise that failed until the cost it measured would fit", red on 97c33a831 (medium after ten minutes at 12 ms), green after; 55 quality and shadow-tier specs pass. --- ...ab-laptop-parity-and-desktop-shadow-cpu.md | 22 ++++++++++++++- .../__tests__/scaffold.spec.ts | 2 +- .../__tests__/template-quality.spec.ts | 24 +++++++--------- .../starter/src/render/adaptiveQuality.ts | 28 ++++++++++++------- 4 files changed, 50 insertions(+), 26 deletions(-) diff --git a/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md b/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md index cb05b1e921..8bcf7bdfc5 100644 --- a/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md +++ b/docs/PRDs/performance/PRD-871-stress-lab-laptop-parity-and-desktop-shadow-cpu.md @@ -127,6 +127,13 @@ Risks: this lamp count. Reopen it when a game ships more than about 64 lamps. - 2026-11-10 (Claude, audit session): a depth prepass is not in scope. The scene pass is 8.6 ms on the Iris Xe after the lamp change, and PRD-766 phase 2 already holds the masked prepass as deferred. +- 2026-11-10 (Claude, PRD-871 session): the native proof of the GPU split is `player-gpu-stages.mjs` on the V8 player, not a + `perf --file` log. The test reads the same `info.engine` fields that core's `gpuFrameSample` reads and asserts them per + frame; a log read would add a game build and prove nothing more. +- 2026-11-10 (Claude, PRD-871 session): the low-tier shadow switch ships in the starter's generated `src/render/postprocessing.ts`, + not in core. Repository rule: what decides how anything looks ships as generated source in `src/render/`; core supplies + only `qualityTiers.shadow(tier).castShadows`. The named override is `setupPost(..., { shadows })`, reported as + `shadows=on|off(tier|override)` on `TN_QUALITY_TIER`. - 2026-11-10 (João): no Midway work in this lane. Midway still ships `autoBatch: false`; that is a game-side note, not a box here. @@ -151,7 +158,7 @@ Risks: **Verification:** a laptop fair window with base and candidate lab builds, arms `?stress&adaptive&arm=base` and `?stress&adaptive&arm=head`, plus a visual judge at each tier the change touches. - [ ] At the `low` tier a game with no shadow wiring of its own draws no shadow pass, and a game that sets its own shadow option keeps it. proof: a core spec red-green, and `gpuShadowMs` under 1 ms on `?stress&adaptive` in the laptop window. -- [ ] `TN_FRAME_BUDGET` carries the GPU frame by pass (shadow, scene, other) on the owned engine. proof: a core spec, and one native desktop run read with `node packages/playtest/dist/runner/cli.js perf --file `. +- [x] `TN_FRAME_BUDGET` carries the GPU frame by pass (shadow, scene, other) on the owned engine. proof: a core spec, and one native desktop run read with `node packages/playtest/dist/runner/cli.js perf --file `. — `4bd398993` (Wasm facade, core) and `52876410f` (V8 player). `renderer.spec.ts` "hands out the engine's own pass split" red before, green after (65 passed). Stress lab `TN_FRAME_BUDGET`: desktop `gpu 4.8 = gpuMain 1.0 + gpuShadow 1.2 + gpuOther 2.6`; laptop at `low` `gpuMain 5.2, gpuShadow 0, gpuOther 5.9`. Native: `player-gpu-stages.mjs` on the V8 player, RTX 2080, PASS (the split names the sampled frame). The `perf --file` reading is replaced by that test (see Decisions). #### Phase 3: the desktop frame loses its shadow planning cost @@ -161,6 +168,10 @@ Risks: **Verification:** `ctest --test-dir build/wasm -L native-engine -R "vsm|render_database|renderer"`, then a paired desktop fair window (`--passes uncapped`, arms `base` and `head`, plus `plainShadow` as the ceiling), a holdout kit, and a visual judge on `?stress&closeup` and `?stress&freezeAt=12` for each change to shadow planning. - [ ] A frame whose static casters did not change spends 0.5 ms or less in `planVirtualPages` on `?stress` (1.64 ms today). proof: a `vsm_atlas_test` case that counts per-caster work on an unchanged frame, red-green; stage split in the ledger row. + Partial, 2026-11-10: `a38190bff` (a moving rigid caster keeps its vertex box; `virtual_shadow_bounds_cache` red-green) and + `7482c204c` (a rig box reads each bone stretch alone): bounds rebuild 1.38 → 1.06 ms a frame by sub-timer. The stage still + reads 1.25-1.6 ms under host load 12-20; the desktop window `shadow-plan` (#347-#348) is inconclusive (one valid base + run). Most of what remains is the walking crowd (see Handover notes). - [ ] No buffer with unchanged content uploads on `?stress&still`. proof: `tnDebug=1` upload counters before and after, in the ledger row notes. - [ ] The `play` frame has a stage split that covers 95% of the frame, and its largest removable term is cut. proof: the split and the paired window in ledger rows; the DevTools trace named in the learnings entry. @@ -170,4 +181,13 @@ Risks: - Lab source: `~/projects/threenative/sandbox/stress-lab` (commits go to `main` with a push). Lab flags: `stress`, `play`, `adaptive`, `plainShadow`, `still`, `closeup`, `freezeAt=`, `noLights`, `noTrees`, `noRocks`, `noCrowd`, `noBodies`, `noFires`, `arm=`. - Probe tools from the audit, kept outside git: `artifacts/stress-lab-audit/tools/` in the worktree, and `~/offload/labs/stress-audit/` on the laptop (`dist-cand3` is the base lab build, `dist-cand4` the lamp range build). `seg-probe.mjs` prints the GPU pass split per URL variant. `cap-probe.mjs` takes the judge captures. - A candidate lab build is a copy of the base `dist` with the two engine `.wasm` files replaced and the `ASM_CONSTS` address patched in the glue chunks. Do not rebuild a folder that a live server serves. +- Phase 3 attribution (2026-11-10, `?stress`, desktop, `TN_FRAME_BUDGET` stages, host load 12-20): frame p50 10.1-12.3 ms; + `wasmPlan` 2.4-3.0 (of it `planVirtualPages` 1.25-1.6), `wasmScene` 2.2-2.8, `jsBefore` 1.4-1.8, `update` 1.4, + `engineJs` 0.9-1.0, `wasmTargets` 0.8-0.9. With `&noCrowd` the frame is 5.6 ms: the 120 walking rigs cost about 4.5-6.5 ms. + Every walker moves each frame, so its bones change in world space even when `updateRate: "auto"` skips its pose. Inside + `planVirtualPages` (temporary sub-timers, not committed): 240 rig draws 0.72 ms, 1,174 bounds lookups 0.35 ms, 11 + instanced draws 0.20 ms, 214 moving rigid draws 0.13 ms. The next lever is per-rig engine work for a rig that moves + rigidly (Unreal keeps bones in component space), not more shadow-plan micro cuts. +- The laptop reaches 100 C between runs; `offload.sh start` refuses then. Retry in a loop with 60 s waits. Head runs with the + same GPU frame differ 50 to 76 fps from throttling: read `gpu` and `shadow` beside `fps`. - Before a headed GPU browser on the desktop: read `nvidia-smi --query-gpu=memory.used --format=csv,noheader` and keep 3 GB free. Check `uptime`: a fair window is void above load 18. diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index c84153db69..c7ac32f9cc 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -173,7 +173,7 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, starter: { shared: "45287923ee472856d7fee5781bf527f953789cb893b951151eaa3d64c1ac539b", - kit: "66bbbe13a8ba71930f412b2f240bf342af2e7fdb6c8aaa51735898bc1b087bd0", + kit: "121be5a90c0ffff17831d4d593a656ce4f21b62250f02027b9d9d7944dcd8fc4", }, "tower-defense": { shared: "9d9bbb42f8a7f0845bf827030f6978aa4feae5ac455e3739ee612e1d18c5bf9b", diff --git a/packages/create-threenative/__tests__/template-quality.spec.ts b/packages/create-threenative/__tests__/template-quality.spec.ts index 38dd510499..bdf7faa2cb 100644 --- a/packages/create-threenative/__tests__/template-quality.spec.ts +++ b/packages/create-threenative/__tests__/template-quality.spec.ts @@ -630,32 +630,28 @@ describe("starter adaptive quality", () => { expect(deadband.tier).toBe("medium"); }); - it("should wait twice as long before retrying a raise the next window undid (PRD-871)", async () => { + it("should not retry a raise that failed until the cost it measured would fit (PRD-871)", async () => { const { createAdaptiveQuality } = await loadAdaptive("starter"); const clock = clockAt(0); const policy = createAdaptiveQuality( { mobile: true }, { cooldownMs: 5000, now: clock.now, startupWindows: 0 }, ); - // low fits with headroom (its shadow pass is off), medium does not: the raise fails. + // low fits with headroom (its shadow pass is off), medium costs 2.25 times as much: the raise fails. clock.advance(6000); expect(feed(policy, 1, 5, { gpuMs: 12 }).tier).toBe("medium"); clock.advance(6000); expect(feed(policy, 6, 2, { gpuMs: 27 }).tier).toBe("low"); - // One cooldown later low still has headroom, but the raise that just failed waits two. - clock.advance(6000); + // 12 ms at low predicts 27 ms at medium: no retry, however long low keeps its headroom. + clock.advance(600_000); const held = feed(policy, 8, 5, { gpuMs: 12 }); - expect(held.tier, "a failed raise is not retried after one cooldown").toBe("low"); - expect(held.reason).toBe("cooldown"); - clock.advance(4001); - expect(feed(policy, 13, 5, { gpuMs: 12 }).tier).toBe("medium"); - // A second failure doubles the wait again, and a stepped-down tier is never held back. + expect(held.tier, "a raise that cannot hold is not retried").toBe("low"); + expect(held.reason).toBe("predicted"); + // A lighter scene (5 ms at low predicts 11.25 ms at medium) raises again. clock.advance(6000); - expect(feed(policy, 18, 2, { gpuMs: 27 }).tier).toBe("low"); - clock.advance(15000); - expect(feed(policy, 20, 5, { gpuMs: 12 }).tier).toBe("low"); - clock.advance(5001); - expect(feed(policy, 25, 5, { gpuMs: 12 }).tier).toBe("medium"); + const raised = feed(policy, 13, 5, { gpuMs: 5 }); + expect(raised.tier).toBe("medium"); + expect(raised.reason).toBe("headroom"); }); it("should keep an explicit tier pinned and still observe it", async () => { diff --git a/packages/create-threenative/templates/starter/src/render/adaptiveQuality.ts b/packages/create-threenative/templates/starter/src/render/adaptiveQuality.ts index cb3ab763e5..83d2b53eeb 100644 --- a/packages/create-threenative/templates/starter/src/render/adaptiveQuality.ts +++ b/packages/create-threenative/templates/starter/src/render/adaptiveQuality.ts @@ -66,11 +66,11 @@ export function createAdaptiveQuality( let lastChange = Number.NEGATIVE_INFINITY; let overloaded = 0; let healthy = 0; - // Raises in a row that the next change undid. Each doubles the wait before the next raise (up to 32 cooldowns), - // so a tier that cannot hold is not retried every cooldown: medium's shadow pass on a tier that fits only without - // it switched the shadows on and off every few seconds (PRD-871). - let failedRaises = 0; - let lastDirection = 0; + // What a raise that failed cost, as a ratio of the cost before it, by the tier it fell back to. A raise from that + // tier waits until the predicted cost fits: retrying on headroom alone switched medium's shadows on and off every + // few seconds where low fits only without them (PRD-871). + const raiseCost = new Map(); + let raisedFrom: { tier: QualityTier; costMs: number } | undefined; const finite = (value: number | undefined): value is number => value !== undefined && Number.isFinite(value); return { @@ -161,17 +161,25 @@ export function createAdaptiveQuality( if (direction === 0) return report("steady"); const next = QUALITY_TIERS[QUALITY_TIERS.indexOf(tier) + direction]; if (next === undefined) return report(direction < 0 ? "at-floor" : "at-ceiling"); + const ratio = raiseCost.get(tier); + if ( + direction > 0 && + ratio !== undefined && + costMs * ratio > budgetMs * (1 - policy.headroom) + ) { + healthy = 0; + return report("predicted"); + } const time = now(); if (!Number.isFinite(time)) throw new Error("quality clock must return finite milliseconds."); - const wait = policy.cooldownMs * (direction > 0 ? 2 ** Math.min(failedRaises, 5) : 1); - if (time - lastChange < wait) { + if (time - lastChange < policy.cooldownMs) { overloaded = 0; healthy = 0; return report("cooldown"); } - if (direction < 0 && lastDirection > 0) failedRaises += 1; - else if (direction > 0 && lastDirection > 0) failedRaises = 0; - lastDirection = direction; + if (direction < 0 && raisedFrom?.tier === next) + raiseCost.set(next, costMs / raisedFrom.costMs); + raisedFrom = direction > 0 ? { tier, costMs } : undefined; tier = next; lastChange = time; overloaded = 0; From a3ee875b22e7beed49013935c777420ffc6442c5 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 18:53:31 -0700 Subject: [PATCH 022/130] fix(renderer): the uniform upload counter counts the bytes written The debug counter uniformUploadBytes added the uniform arena size each frame, while the FM2 upload sends only the blocks that changed. On the stress lab still pose with nothing moving it read 39 KB a frame beside a 516-byte real upload, so it pointed a profile at an upload that does not happen. It now adds the bytes each arena write sends. PRD-871 Phase 3. frame_slab_is_per_program: red before (still frame 6144), green after (still 0, moved 512 of 516 queued bytes); all 136 render database cases pass on the engine-only Dawn build. --- .../src/engine/renderer/renderer.cpp | 1 - .../src/engine/renderer/renderer_frame.cpp | 3 +++ .../native-engine/render_database_test.cpp | 18 ++++++++++++++++++ 3 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/runtime-native/src/engine/renderer/renderer.cpp b/packages/runtime-native/src/engine/renderer/renderer.cpp index 9b8388c92b..26c49d1ff3 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.cpp +++ b/packages/runtime-native/src/engine/renderer/renderer.cpp @@ -1343,7 +1343,6 @@ void Renderer::encodeFrame(FrameJob& job) { waste::debugAdd(waste::counters().renderDraws, lastFrame_.draws); waste::debugAdd(waste::counters().shadowDraws, lastFrame_.shadowDraws); waste::debugAdd(waste::counters().auxPasses, lastFrame_.auxPasses); - waste::debugAdd(waste::counters().uniformUploadBytes, job.uniformBytes); frameMail_ = lastFrame_; } } diff --git a/packages/runtime-native/src/engine/renderer/renderer_frame.cpp b/packages/runtime-native/src/engine/renderer/renderer_frame.cpp index ab6bc2a0e9..b4f493f975 100644 --- a/packages/runtime-native/src/engine/renderer/renderer_frame.cpp +++ b/packages/runtime-native/src/engine/renderer/renderer_frame.cpp @@ -2012,6 +2012,7 @@ uint64_t Renderer::renderPrepared(std::span items, const CameraS if (uniformBytes != 0 && uploadedUniforms_.size() != uniformBytes) { gpu_.writeBuffer(uniformBuffer_, 0, frameUniforms_.data(), uniformBytes); // a grown arena: all of it once uploadedUniforms_.assign(frameUniforms_.begin(), frameUniforms_.end()); + waste::debugAdd(waste::counters().uniformUploadBytes, uniformBytes); } else if (uniformBytes != 0 && std::memcmp(uploadedUniforms_.data(), frameUniforms_.data(), uniformBytes) != 0) { constexpr uint64_t kUniformBlock = kUniformAlign; // one block's granularity // Every block is kUniformAlign bytes, so a run's length is a multiple of 4, as writeBuffer requires. @@ -2037,12 +2038,14 @@ uint64_t Renderer::renderPrepared(std::span items, const CameraS if (dirty * 2 >= uniformBytes) { // mostly dirty: one write is cheaper than the calls gpu_.writeBuffer(uniformBuffer_, 0, frameUniforms_.data(), uniformBytes); uploadedUniforms_.assign(frameUniforms_.begin(), frameUniforms_.end()); + dirty = uniformBytes; } else { for (const auto& [offset, length] : uniformDirtyRanges_) { gpu_.writeBuffer(uniformBuffer_, offset, frameUniforms_.data() + offset, length); std::memcpy(uploadedUniforms_.data() + offset, frameUniforms_.data() + offset, length); } } + waste::debugAdd(waste::counters().uniformUploadBytes, dirty); } job.uniformBytes = frameUniforms_.size(); bool jointBufferMoved = false; diff --git a/packages/runtime-native/tests/native-engine/render_database_test.cpp b/packages/runtime-native/tests/native-engine/render_database_test.cpp index 442ab427f0..ed29c60a6d 100644 --- a/packages/runtime-native/tests/native-engine/render_database_test.cpp +++ b/packages/runtime-native/tests/native-engine/render_database_test.cpp @@ -1442,7 +1442,25 @@ void frameSlabIsPerProgram() { // the GPU holds. A frame whose camera moved still rewrites every slot (the view is in each draw's block), so // its upload stays the arena's size; the still frame is the arena's worth fewer than it. const Frame still = frame(); + // The debug counter names the arena bytes a frame wrote, not the arena's size: a still frame reads + // 0 there. It read the size (39 KB a frame on the stress lab's still pose, PRD-871) beside a 516-byte + // real upload, so a profile chased an upload that did not exist. + waste::setDebugEnabled(true); + const uint64_t counted = waste::counters().uniformUploadBytes.load(); const Frame stillAgain = frame(); + const uint64_t stillCounted = waste::counters().uniformUploadBytes.load() - counted; + pose(0.9); + const uint64_t movedCounted = waste::counters().uniformUploadBytes.load(); + const Frame movedAgain = frame(); + const uint64_t movedCountedBytes = waste::counters().uniformUploadBytes.load() - movedCounted; + pose(0.4); + frame(); + waste::setDebugEnabled(false); + std::printf("frame slab: debug counter still %llu, moved %llu (queue writes %llu) of arena %llu\n", + static_cast(stillCounted), static_cast(movedCountedBytes), + static_cast(movedAgain.upload), static_cast(moved.uniforms)); + CHECK(stillCounted == 0); + CHECK(movedCountedBytes > 0 && movedCountedBytes <= movedAgain.upload); std::printf("frame slab: still frames upload %llu and %llu bytes, a moved camera %llu of %llu\n", static_cast(still.upload), static_cast(stillAgain.upload), static_cast(moved.upload), static_cast(moved.uniforms)); From bd1cd887d270279b623890aec6094d65eab7a92a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 19:00:03 -0700 Subject: [PATCH 023/130] perf(renderer): a still frame sends no output uniform block The output pass wrote its uniform block (exposure, time, screen size) every frame, so a frame with nothing moving still made one writeBuffer call: the last upload of a still frame. The renderer now keeps the bytes the buffer holds and writes only a block that differs; a new buffer clears them. PRD-871 Phase 3. frame_slab_is_per_program: a still frame uploaded 4 bytes (red), now 0 (green). 136 render database cases, 98 renderer/output/ present tests and 14 post tests pass on the engine-only Dawn build. --- packages/runtime-native/src/engine/renderer/renderer.cpp | 1 + packages/runtime-native/src/engine/renderer/renderer.h | 1 + .../runtime-native/src/engine/renderer/renderer_output.cpp | 7 ++++++- .../tests/native-engine/render_database_test.cpp | 2 ++ 4 files changed, 10 insertions(+), 1 deletion(-) diff --git a/packages/runtime-native/src/engine/renderer/renderer.cpp b/packages/runtime-native/src/engine/renderer/renderer.cpp index 26c49d1ff3..8b74f30dda 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.cpp +++ b/packages/runtime-native/src/engine/renderer/renderer.cpp @@ -148,6 +148,7 @@ Renderer::Renderer(WGPUInstance instance, WGPUDevice device, WGPUQueue queue, Ev } gpu_.writeBuffer(outputTriangle_, 0, triangle, sizeof triangle); outputUniforms_ = gpu_.createBuffer(16, WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst); + outputUploaded_.clear(); WGPUSamplerDescriptor nearest = {}; nearest.magFilter = WGPUFilterMode_Nearest; nearest.minFilter = WGPUFilterMode_Nearest; diff --git a/packages/runtime-native/src/engine/renderer/renderer.h b/packages/runtime-native/src/engine/renderer/renderer.h index 8d490ab6f9..80e253d683 100644 --- a/packages/runtime-native/src/engine/renderer/renderer.h +++ b/packages/runtime-native/src/engine/renderer/renderer.h @@ -1463,6 +1463,7 @@ class Renderer { shader::StageModule outputFragment_; Handle outputTriangle_; Handle outputUniforms_; + std::vector outputUploaded_; // the bytes outputUniforms_ holds: an unchanged block is not sent again WGPUSampler outputSampler_ = nullptr; WGPUBindGroup outputGroup_ = nullptr; WGPUBindGroupLayout outputLayout_ = nullptr; diff --git a/packages/runtime-native/src/engine/renderer/renderer_output.cpp b/packages/runtime-native/src/engine/renderer/renderer_output.cpp index 448662a70a..077195bc71 100644 --- a/packages/runtime-native/src/engine/renderer/renderer_output.cpp +++ b/packages/runtime-native/src/engine/renderer/renderer_output.cpp @@ -51,6 +51,7 @@ void Renderer::setOutput(const OutputState& output) { outputPipelineLayout_ = wgpuDeviceCreatePipelineLayout(device_, &pipelineLayoutDesc); gpu_.destroy(outputUniforms_); outputUniforms_ = gpu_.createBuffer(std::max(16, outputFragment_.uniformBlockSize), WGPUBufferUsage_Uniform | WGPUBufferUsage_CopyDst); + outputUploaded_.clear(); } void Renderer::setPostNode(std::shared_ptr post) { @@ -522,7 +523,11 @@ void Renderer::outputPass(WGPUCommandEncoder encoder, WGPUTextureView present, W shader::writeUniform(block, field, value->second); } put(block, outputFragment_, "toneMappingExposure", std::array{output_.toneMappingExposure}); - if (outputFragment_.uniformBlockSize) gpu_.writeBuffer(outputUniforms_, 0, block.data(), outputFragment_.uniformBlockSize); + block.resize(outputFragment_.uniformBlockSize); + if (!block.empty() && block != outputUploaded_) { // a still frame with a fixed exposure sends nothing + gpu_.writeBuffer(outputUniforms_, 0, block.data(), block.size()); + outputUploaded_ = block; + } // The explicit output layout is shared by the offscreen and the presenting pipeline, so one // group serves both and needs no rebuild when the present format changes. WGPUBindGroup& group = outputGroup_; diff --git a/packages/runtime-native/tests/native-engine/render_database_test.cpp b/packages/runtime-native/tests/native-engine/render_database_test.cpp index ed29c60a6d..b73790efe1 100644 --- a/packages/runtime-native/tests/native-engine/render_database_test.cpp +++ b/packages/runtime-native/tests/native-engine/render_database_test.cpp @@ -1466,6 +1466,8 @@ void frameSlabIsPerProgram() { static_cast(moved.upload), static_cast(moved.uniforms)); CHECK(still.upload <= moved.upload - moved.uniforms); CHECK(stillAgain.upload == still.upload); + // Nothing else crosses either: the output pass's exposure block (4 bytes) was rewritten every frame (PRD-871). + CHECK(still.upload == 0); CHECK(moved.upload <= moved.uniforms + still.upload); // (d) the same pose reaches the same pixels however the frame got there, and a light's channel flip moves // as many of them from a still arm as from a moved one: a stale slab would show here. From cac3780a4f4716db8e63a7561b528d1e5eb08f2a Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 19:22:23 -0700 Subject: [PATCH 024/130] fix(look): the low tier draws no screen-space reflections The low preset left ssrEnabled unset, and the default (look layer 8 and up) turned SSR on: the phone tier, documented as "nothing screen-space", ran reflections. Their normal, metalness and roughness reads made the owned engine redraw the whole scene into a second G-buffer pass: 6.7 of 26.7 ms of the stress lab frame on an Iris Xe at low (per-pass GPU timers). Unreal draws none at that level: UE 5.8.3 Engine/Config/BaseScalability.ini:514-515, [ReflectionQuality@0] r.SSR.Quality=0. Medium and high keep their SSR. PRD-871 Phase 2 (AC-1). world-environment-defaults "draws no screen-space reflections on the low tier": red before (stages ssr, bloom, ...), green after; 484 related look, kit and render-chain specs pass. --- .../core/__tests__/world-environment-defaults.spec.ts | 10 ++++++++++ packages/core/src/look/quality.ts | 4 ++++ 2 files changed, 14 insertions(+) diff --git a/packages/core/__tests__/world-environment-defaults.spec.ts b/packages/core/__tests__/world-environment-defaults.spec.ts index 26e2cc7834..8b6cdac4de 100644 --- a/packages/core/__tests__/world-environment-defaults.spec.ts +++ b/packages/core/__tests__/world-environment-defaults.spec.ts @@ -162,6 +162,16 @@ it("never multisamples the world on a cheaper tier than the one above it", () => expect(samples("medium")).toBeLessThanOrEqual(samples("high")); }); +it("draws no screen-space reflections on the low tier, as Unreal's ReflectionQuality 0 (PRD-871)", () => { + // UE 5.8.3 Engine/Config/BaseScalability.ini:514-515: [ReflectionQuality@0] r.SSR.Quality=0. The low tier left + // `ssrEnabled` unset, the default turned it on, and its normal/metalness/roughness reads cost an Iris Xe a full + // second geometry pass: 6.7 ms of a 26.7 ms stress frame. + const { requested, target } = renderer("webgpu", { library: { addToneMapping() {} } }); + apply(qualityPreset("low"), target); + expect(requested[0]?.stages).not.toContain("ssr"); + expect(requested[0]?.stages).toContain("bloom"); +}); + it("does not let an undefined option cancel the preset", () => { const { requested, target } = renderer("webgpu", { library: { addToneMapping() {} } }); apply({ gtaoEnabled: undefined }, target); diff --git a/packages/core/src/look/quality.ts b/packages/core/src/look/quality.ts index 168248ecdc..6f9066e960 100644 --- a/packages/core/src/look/quality.ts +++ b/packages/core/src/look/quality.ts @@ -167,6 +167,10 @@ const low: IWorldEnvironmentOptions = { finalGatherMethod: "none", localExposureEnabled: false, renderChainTier: "low", + // No screen-space reflections: UE 5.8.3 BaseScalability.ini:514-515, [ReflectionQuality@0] r.SSR.Quality=0. Left unset, + // the default turned SSR on, and its normal and material reads redrew the scene into a second G-buffer pass (Iris Xe + // stress lab: 6.7 of 26.7 ms, PRD-871). + ssrEnabled: false, // Single-sample, as medium: an unset count inherits the renderer's 4x MSAA, and the cheapest tier // cost more than medium on a forest (Iris Xe 125 -> 158 ms at the switch, PRD-762). samples: 0, From 99c719d305e057e63c03a7eaf1bf3a1219f5f81d Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 19:22:31 -0700 Subject: [PATCH 025/130] fix(starter): the post file fits the 200-line render cap again 6dd609eca put the low-tier shadow switch inline and took src/render/postprocessing.ts to 212 lines; looks.spec caps a render file below 200. The switch is now tierShadows() in quality.ts (the same rule, override and report), and the post file is 199 lines. looks, starter-shadow-tier, template-quality and scaffold specs pass. --- .../__tests__/scaffold.spec.ts | 2 +- .../starter/src/render/postprocessing.ts | 35 ++++++------------- .../templates/starter/src/render/quality.ts | 13 +++++++ 3 files changed, 25 insertions(+), 25 deletions(-) diff --git a/packages/create-threenative/__tests__/scaffold.spec.ts b/packages/create-threenative/__tests__/scaffold.spec.ts index c7ac32f9cc..68de086db7 100644 --- a/packages/create-threenative/__tests__/scaffold.spec.ts +++ b/packages/create-threenative/__tests__/scaffold.spec.ts @@ -173,7 +173,7 @@ const SCAFFOLD_TREE_HASHES: Readonly> = { }, starter: { shared: "45287923ee472856d7fee5781bf527f953789cb893b951151eaa3d64c1ac539b", - kit: "121be5a90c0ffff17831d4d593a656ce4f21b62250f02027b9d9d7944dcd8fc4", + kit: "9b70f462c27add3f689ea04925631afe92ab296b36309660107b900bf7f40a69", }, "tower-defense": { shared: "9d9bbb42f8a7f0845bf827030f6978aa4feae5ac455e3739ee612e1d18c5bf9b", diff --git a/packages/create-threenative/templates/starter/src/render/postprocessing.ts b/packages/create-threenative/templates/starter/src/render/postprocessing.ts index 1574551e28..a18fb8c1c5 100644 --- a/packages/create-threenative/templates/starter/src/render/postprocessing.ts +++ b/packages/create-threenative/templates/starter/src/render/postprocessing.ts @@ -1,13 +1,10 @@ -import { type OutputRenderer, WorldEnvironment, qualityTiers } from "@threenative/core"; +import { type OutputRenderer, WorldEnvironment } from "@threenative/core"; // Generated for you: ordinary Three.js; ThreeNative does not read this file. -// -// This file wires two things together and decides nothing itself. `quality.ts` owns which stages -// run at which tier and records what each one measured. Core's `WorldEnvironment` builds them and -// prints `TN_WORLD_ENVIRONMENT`, naming every stage as applied or refused **with a reason**. -// -// To make the game cheaper or prettier everywhere, edit `quality.ts`. To force one tier for one run -// (a slow desktop, a capture to compare), pass `setupPost(renderer, scene, camera, { tier: "low" })`. -// `TN_QUALITY_TIER` names the tier that ran either way. +// This file wires two things together and decides nothing itself. `quality.ts` owns which stages run at which +// tier and records what each one measured; core's `WorldEnvironment` builds them and prints `TN_WORLD_ENVIRONMENT`, +// naming every stage as applied or refused **with a reason**. +// To make the game cheaper or prettier everywhere, edit `quality.ts`. To force one tier for one run (a slow desktop, +// a capture to compare), pass `setupPost(renderer, scene, camera, { tier: "low" })`; `TN_QUALITY_TIER` names it. import { type Camera, type DirectionalLight, FloatType, type Scene } from "three"; import { LUTCubeLoader } from "three/addons/loaders/LUTCubeLoader.js"; import { @@ -17,7 +14,7 @@ import { formatQualityAdaptation, } from "./adaptiveQuality.js"; import { type IGradeTable, filterableTable, gradeStages } from "./grade.js"; -import { type QualityTier, gradePreset, qualityPreset } from "./quality.js"; +import { type QualityTier, gradePreset, qualityPreset, tierShadows } from "./quality.js"; import { type TemporalAAProvider, temporalAAStages } from "./temporalAAStage.js"; import { temporalDetailRequested, temporalDetailStages } from "./temporalDetailStage.js"; import type { FogMedium } from "./volumetricFog.js"; @@ -64,12 +61,7 @@ export function setupPost( tier?: QualityTier; /** Scene-owned material assignments follow the same resolved tier. */ onTierChanged?: (tier: QualityTier) => void; - /** - * Whether the sun and the lamps cast shadows. Omit it and the tier decides: `low` casts none, as - * Unreal's `sg.ShadowQuality` 0. Set it and every tier keeps this game's choice; `TN_QUALITY_TIER` - * and `debug().shadows` name which one ran. - */ - shadows?: boolean; + shadows?: boolean; // on or off at every tier; omitted, the tier decides (`tierShadows` in `quality.ts`) /** * The bounded participating medium from `volumetricFog.ts`, built once per graph: a tier change * replaces the graph, and a fog controller owns one graph, so it is released and rebuilt here @@ -91,7 +83,6 @@ export function setupPost( let temporal: TemporalAAProvider | undefined; // The chain's own temporal AA (`temporalAA` in `quality.ts`) reads the engine's live frame, history and jitter. let engineTemporal: (() => Record) | undefined; - const shadowSource = environment.shadows === undefined ? "tier" : "override"; let observation: Record = { tier: policy.tier, source: policy.pinned ? "pinned" : "auto", @@ -102,12 +93,8 @@ export function setupPost( medium?.dispose(); medium = environment.fog?.(); const composed = medium; - // The tier's shadow switch. An integrated GPU lands on `low`, where the shadow pass was the - // largest GPU cost of the frame while the tier table already said "no shadow". - const castShadows = environment.shadows ?? qualityTiers.shadow(policy.tier).castShadows; - const shadowMap = (renderer.raw as { shadowMap?: { enabled: boolean } }).shadowMap; - if (shadowMap !== undefined) shadowMap.enabled = castShadows; - observation = { ...observation, shadows: { enabled: castShadows, source: shadowSource } }; + const shadows = tierShadows(policy.tier, environment.shadows, renderer.raw); + observation = { ...observation, shadows }; const settings = qualityPreset(policy.tier); const detail = temporalDetailRequested(renderer, settings) ? temporalDetailStages() : []; const world = new WorldEnvironment({ @@ -170,7 +157,7 @@ export function setupPost( console.info( `TN_QUALITY_TIER ${policy.tier} mobile=${environment.mobile === true} software=${ environment.software === true - } source=${source} shadows=${(environment.shadows ?? qualityTiers.shadow(policy.tier).castShadows) ? "on" : "off"}(${shadowSource})`, + } source=${source} shadows=${tierShadows(policy.tier, environment.shadows).label}`, ); const controller = { get tier(): QualityTier { diff --git a/packages/create-threenative/templates/starter/src/render/quality.ts b/packages/create-threenative/templates/starter/src/render/quality.ts index 3dab5e3666..1bd8f69982 100644 --- a/packages/create-threenative/templates/starter/src/render/quality.ts +++ b/packages/create-threenative/templates/starter/src/render/quality.ts @@ -52,6 +52,19 @@ export function qualityPreset(tier: string): IWorldEnvironmentOptions { return { ...coreQualityPreset(tier), exposurePolicy: exposureSettings }; } +/** + * Whether a tier casts shadows: the game's `setupPost(..., { shadows })`, else the tier's. `low` casts none, as + * Unreal's `sg.ShadowQuality` 0: its shadow pass was an Iris Xe frame's largest GPU cost (PRD-871). Pass the raw + * renderer to apply it to `shadowMap.enabled`. + */ +export function tierShadows(tier: QualityTier, override?: boolean, raw?: unknown) { + const enabled = override ?? qualityTiers.shadow(tier).castShadows; + const source = override === undefined ? "tier" : "override"; + const shadowMap = (raw as { shadowMap?: { enabled: boolean } } | undefined)?.shadowMap; + if (shadowMap) shadowMap.enabled = enabled; + return { enabled, source, label: `${enabled ? "on" : "off"}(${source})` } as const; +} + /** The grade and grain a tier runs. Throws on a name that is not a tier, for the same reason. */ export function gradePreset(tier: string): IGradeSettings { const preset = GRADE_PRESETS[tier as QualityTier]; From 2b1060f9ec5e47f4db8b971508209cb788ac84f9 Mon Sep 17 00:00:00 2001 From: Joao Paulo Furtado Date: Tue, 10 Nov 2026 19:26:33 -0700 Subject: [PATCH 026/130] docs(prd): performance PRDs match landed work and carry the Unreal 5.8.3 thread and planning guards - Remove docs/PRDs/performance/PRD-828: a stale pre-work copy that #635 re-added. The finished PRD stays in docs/PRDs/done/ (DONE 2026-11-07). - PRD-573: v2.1, v2.2, v2.3, v3.1, v3.3, v5.1 and v5.2 name what landed (L5, FM2, FA1, PRD-828, PRD-613, PRD-752) and what is still open. No box ticked. - PRD-573, PRD-774, PRD-752: Unreal 5.8.3 reference for parallel pose evaluation, command build at add time, the late join, the frame lag limit, deferred release and the flush before a surface change. Each cited line was re-read in the clone on 2026-11-10. - PRD-765: P2 to P1, on PRD-828 follow-up 7 (about 1.2 ms of JS a frame). Checks: check-doc-links (3918 links), unreal-port-status --check up to date, check-doc-links and evidence-citations specs pass. evidence-budget spec fails in this worktree only (no node_modules here; 9 of 9 pass in the main checkout). --- ...tion-interpolates-physics-poses-in-bulk.md | 2 +- ...eal-class-performance-in-release-cycles.md | 19 ++- ...-a-worker-so-the-page-cannot-stall-them.md | 2 + .../PRD-828-js-keeps-only-game-logic.md | 147 ------------------ ...PRD-774-render-plan-off-the-main-thread.md | 24 +++ 5 files changed, 41 insertions(+), 153 deletions(-) delete mode 100644 docs/PRDs/performance/PRD-828-js-keeps-only-game-logic.md diff --git a/docs/PRDs/engine-performance-architecture-2026-10-28/PRD-765-presentation-interpolates-physics-poses-in-bulk.md b/docs/PRDs/engine-performance-architecture-2026-10-28/PRD-765-presentation-interpolates-physics-poses-in-bulk.md index f02683f445..99a67302af 100644 --- a/docs/PRDs/engine-performance-architecture-2026-10-28/PRD-765-presentation-interpolates-physics-poses-in-bulk.md +++ b/docs/PRDs/engine-performance-architecture-2026-10-28/PRD-765-presentation-interpolates-physics-poses-in-bulk.md @@ -1,7 +1,7 @@ # PRD-765 — Presentation interpolates physics poses in bulk **Status:** NOT STARTED -**Priority:** P2 — reduce repeated presentation pose copies for large interpolated populations. +**Priority:** P1 — physics apply and interpolation is the largest JS block left on the stress-lab frame, about 1.2 ms a frame, after [PRD-828](../done/PRD-828-js-keeps-only-game-logic.md) cut crossings from 1,912 to 40 (its follow-up 7, survey of 2026-11-07). Raised from P2 on 2026-11-10 on that reading. Every box below is open, and Phase 1 must confirm the 1.2 ms before any code. **Complexity:** 9 (HIGH) **Owner:** Engine implementation agent **Depends on:** PRD-763 diff --git a/docs/PRDs/performance/PRD-573-unreal-class-performance-in-release-cycles.md b/docs/PRDs/performance/PRD-573-unreal-class-performance-in-release-cycles.md index b14dd03b98..bbbd9aef00 100644 --- a/docs/PRDs/performance/PRD-573-unreal-class-performance-in-release-cycles.md +++ b/docs/PRDs/performance/PRD-573-unreal-class-performance-in-release-cycles.md @@ -239,15 +239,17 @@ R5 CPU budget, 2026-10-12 (perf plus `/proc` stime, R5@4096 1920x1080, RTX 2080 **Status:** PARTIAL - [x] `PropertyBinding` applies without a lock; a binding to a released node is a safe no-op. proof: red-green `native_engine_animation_binding_released` (F `e9d4f1f4c`; `ctest -L native-engine` 290/292, reds only the known `render_vsm`, `update_scaling`); lock self share 3.9% to under 0.7%. Lands with slice 2b. - [ ] `Skeleton::bone` (`animation/skinning/skeleton.h:74`, a lock per bone, twice per bone in the palette build `skeleton.cpp:116-119`) uses a bind-time pointer with an expiry check. proof: red-green `native_engine_skinned_crowd_cpu`, then a `profile:wasm-page` A/B with the `__shared_weak_count::lock` share under 0.7% + Landed, proof not recorded (2026-11-10 review): L5 (`de7584e47`, 2026-11-02) makes `Skeleton::bone(i)` read a cached raw pointer, kept in the stress window with L6 (rows #267-280, frame p50 17.82 to 15.55 ms). It has no expiry check: a game that changes the bone list of a live skeleton needs a rebuild. The box stays open until the named lock-share A/B runs. #### Phase v2.2: One view block per frame **Status:** NOT STARTED Today `renderer.cpp:2095-2101` writes view, projection and a double `normalMatrix(view * model)` into every draw's block. +2026-11-10 review, part landed: FM2 writes view, projection and camera position once into each program's frame slab (`renderer/renderer_frame.cpp:754-769`). Still per draw: a copy of that slab and `normalMatrix(view * model)` (`renderer_frame.cpp:887-890`). So a camera turn still changes the bytes of every draw, which is also what keeps [PRD-754](../Unreal/performance/PRD-754-a-camera-turn-re-records-no-pass.md) from a turn that records no pass. Unreal makes one view block per view per frame (`UE 5.8.3: Engine/Source/Runtime/Renderer/Private/SceneRendering.cpp:2430-2432`). The remaining step is a normal matrix that does not depend on the view, checked for pixel parity against three. - [ ] View, projection and camera constants go into one per-view block; a camera-only change writes no draw block. proof: a red-green bytes-per-frame counter in `native_engine_batched_vs_unbatched` - [ ] Frames match the per-draw path and pass the visual judge; R5 and Midway frame CPU fall with p99 no worse. proof: the same ctest within tolerance, a judge subagent, `bench:engines` and `profile:wasm-page` A/Bs #### Phase v2.3: Hidden rigs update less, with Unreal's safeguards -**Status:** NOT STARTED +**Status:** PARTIAL (2026-11-10 review). Landed: from 32 rigs, each rig measures its animation rate one frame in four (FA1, 2026-11-02, `packages/core/src/render-camera-cull.ts`), and one `tn_anim_tick` call advances the whole crowd in C++ ([PRD-828](../done/PRD-828-js-keeps-only-game-logic.md) Phase 2, 2026-11-07, `animation/tick_batch.cpp`). Open: the boxes below. Unreal's defaults, re-read on 2026-11-10: a rig that is not rendered evaluates one frame in 4, a visible rig drops to one frame in 2 and then 3 below screen-radius-squared 0.24 and 0.12, and skipped frames interpolate only below rate 4 (`UE 5.8.3: Engine/Source/Runtime/Engine/Classes/Engine/EngineTypes.h:2820-2826`). A skipped tick banks its time and the next tick spends it. `AnimationMixer::update` (`mixer.cpp:724`) evaluates every rig every frame; games call it directly, so the rule lives there. Reference: URO (`SkinnedMeshComponent.cpp:283-370`, 1.0 s window `:1864`, `ShiftBucket` stagger, forced update `:6066-6100`). - [ ] Main and shadow passes stamp the drawn frame on the rig root. proof: red-green `native_engine_animation_mixer` - [ ] Hidden rigs evaluate about 1 frame in 4, staggered (at most ceil(N/4) per frame), with clip time and `finished` equal to rate 1. proof: a red-green counter in `native_engine_skinned_crowd_cpu` @@ -260,6 +262,7 @@ Today `getAsync` and a 2-worker pool exist (`pipeline_cache.cpp:191`, `.h:118`), - [x] On native desktop, `compileAsync` builds missing pipelines on the pool and the next `render` compiles none. proof: red-green `native_engine_renderer_pipeline_compile_async` on Dawn Null, 2026-10-09 (F). Lands with slice 5. - [ ] On the web, `compileAsync` uses the async entry and the next frame compiles none. proof: `native_engine_wasm_browser_backend` run in a browser (the stubbed case passes) - [ ] The engine enqueues keys for added content itself, skips a draw whose pipeline is pending, and reports synchronous creates and skipped draws per frame. proof: red-green `native_engine_renderer_pipeline_cache` (synchronous creates after first present = 0) and a `perf.spec.ts` case + Why it matters now (2026-11-10 review): the last camera-turn hitch on the Strata forest is a plan spike, `planDraws` about 10 ms in one frame, from programs and pipelines made the first frame a mesh is seen (`PERFORMANCE-LEARNINGS.md`, 2026-11-04 forest turn entry). Its p50 is 0.07 ms (PRD-774 Decisions), so this box cuts the tail, not the mean. Unreal builds each draw command when the primitive is added, in batches on tasks, and rebuilds it only on a material change (`UE 5.8.3: Engine/Source/Runtime/Renderer/Private/PrimitiveSceneInfo.cpp:51-53`, `r.MeshDrawCommands.CacheMultithreaded` 1). Guard: the result must equal the lazy path, and only a material or light-layout change invalidates it. - [ ] Skipped draws pass the visual judge, and Midway `ready` beats its base. proof: a judge subagent; `profile:wasm-page --load --gpu-calls` ratio below 1.0 #### Phase v2.5: Pipelines ship and persist @@ -286,9 +289,10 @@ The shooter kit's web CPU profile (2026-10-26, NVIDIA, no DevTools, 10 s) still Six hazards block a render thread: non-atomic `nextObjectId`/`hierarchyRevision` and file-scope scratch (`object3d.cpp:67-77`); a snapshot `shared_ptr` release running a destructor off-thread; renderer state set from JS (`v8_main.cpp:228,575`); `renderReflections` mutating live materials (`render_database.cpp:1405`); single-thread loop glue with no frame-in-flight limit (`run.cpp`); the Android surface-released handshake (`run.cpp:260,525`). A render thread alone caps at 0.68–0.79x of the frame, so jobs come first. #### Phase v3.1: Jobs on every core -**Status:** NOT STARTED +**Status:** PARTIAL (2026-11-10 review). Landed: the transform walk runs as jobs, bit-equal to one thread and clean under ThreadSanitizer (`Object3D::updateMatrixWorldVisibleJobs`, `native_engine_matrix_walk_jobs`, PRD-752 Phase 4), and the pool takes up to 8 workers (FT2, `2ec6ed19d`). Open: mixers as jobs (second box) and the render plan, which [PRD-774](./stress-lab-2026-10-30/PRD-774-render-plan-off-the-main-thread.md) Phase 3 owns. - [ ] The frame report splits `renderMs` into prepare and `Renderer::render`, and `perf` prints stages that sum to frame CPU within 5%. proof: a red-green `perf.spec.ts` case - [ ] Mixers and independent transform subtrees run as jobs, bit-equal to one thread, events dispatched after the join, hazard 1 fixed. proof: `native_engine_skinned_crowd_cpu` and `native_engine_static_transform` with 1 and N workers, under ThreadSanitizer + Open half, mixers (2026-11-10 review): `TickBatch::run` advances every rig in one serial loop (`animation/tick_batch.cpp:25`). Unreal evaluates poses on worker tasks by default (`a.ParallelAnimEvaluation` 1, `UE 5.8.3: Engine/Source/Runtime/Engine/Private/Components/SkeletalMeshComponent.cpp:76`) and uses a result only when the mesh, the anim instance and the bone count did not change during the task (`SkeletalMeshComponent.cpp:4980-4993`). Design: the serial part keeps time, weights, stride and finish status, pose sampling and the palette build go to `JobPool::forEach`, and listeners fire after the join in rig order. Size it first: the C++ share of the tick has not been measured since PRD-828 landed. #### Phase v3.2: Recorded passes survive camera motion **Status:** PARTIAL (2026-10-28). The owner chose PRD-754 option 1: three's opaque order stays; native desktop replays the shadow passes from bundles with persistent uniform slots by default (Midway recording plus shadow CPU -17%); the rows wait behind `setStableRows` (native, off); the web engine compiles all of it out (no web gain, 4 MiB budget). The camera-turn goal and the 80% gate are Deferred in PRD-754. @@ -303,7 +307,12 @@ Today the bundle key (`renderer.cpp:2702-2722`) moves with depth order (`sortDra result: PRD-754 Phase 2 box 2 (`cull_keeps_main_bundle` 0 differ; Chrome worker recording CPU ratio 0.45 over 3 pairs) #### Phase v3.3: An opt-in render thread -**Status:** NOT STARTED +**Status:** PARTIAL (2026-11-10 review). Landed: the native player encodes on a `tn-render` thread and presents on a `tn-gpu` thread ([PRD-613](../done/PRD-613-native-render-thread.md), `player/run.cpp:1104-1110`). That is Unreal's RHI thread. Not started: planning off the game thread, which is the boxes below. +What Unreal's split requires (`UE 5.8.3`, re-read 2026-11-10): +- The render thread reads copies, and only dirty components send one. Our scene has no dirty list: a direct field write cannot bump `revision()` (`scene/object3d.h:335`), and JS writes transforms through shared views. The snapshot must double-buffer the transform pages or copy every matrix. Decide that before the first box. +- The lag is stated in frames: the render thread runs at most one frame behind (`r.OneFrameThreadLag` 1, `Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp:2435`). +- A resource dropped in frame N is freed after the frame-end sync of frame N+1 (`Engine/Source/Runtime/Launch/Private/LaunchEngineLoop.cpp:6078-6095`). A JS `dispose()` or a garbage collection must go through the same queue. +- Every render command is flushed before a viewport resize (`Engine/Source/Runtime/SlateRHIRenderer/Private/SlateRHIRenderer.cpp:564`). The same flush is needed before surface loss and device loss. - [ ] `Renderer::render` reads only a `FrameSnapshot` (owned bones, morphs, instance data; deferred release on the game thread), and JS renderer settings pass through a command queue. proof: `native_engine_batched_vs_unbatched` pixel-identical, a bytes-copied counter - [ ] `--render-thread` (frame-in-flight limit 1) matches one thread to the pixel and runs 600 frames with resize, device loss and surface loss free of races. proof: the ctest in both modes and under ThreadSanitizer @@ -328,12 +337,12 @@ TN today: HZB declined ([PRD-489](../done/PRD-489-gpu-scene-occlusion-culling.md The game side reads the DOM directly (23 core files read `document.` or `window.`; audio, pointer lock, gamepads, `localStorage`). A worker keeps frame p50; only Wasm threads can cut it. #### Phase v5.1: Worker mode -**Status:** NOT STARTED +**Status:** PARTIAL (2026-11-10 review): PRD-752 Phases 1 and 2 are done, and the worker page is the default page. Its Phase 3 has one open box, the shooter `performance` scenario on the worker. The boxes below are ticked in PRD-752, not here. - [ ] Core reaches the DOM only through one host-capability layer, and the starter plays through a loopback proxy. proof: a red-green core spec that scans `packages/core/src`; `pnpm test:templates` on the starter - [ ] Behind a flag and without COOP/COEP, the game and engine run in a worker on an `OffscreenCanvas`, the harness captures it, and main-thread busy falls below 0.5x with p50 no worse. proof: `pnpm test:templates` with the flag; `profile:wasm-page` worker against main-thread; a judge subagent #### Phase v5.2: Wasm threads, v5 KPI and gate -**Status:** NOT STARTED +**Status:** PARTIAL (2026-11-10 review): the threaded build, the isolation report and the job pool landed (PRD-752 Phase 4). Open there: the one-thread cost A/B, the pool gain on the shooter, and gate S. - [ ] A `TN_WEB_THREADS` build costs at most 2% on one thread, reports `threads: off` without isolation, and the job pool cuts frame CPU with it. proof: `profile:wasm-page` A/Bs; `native_engine_wasm_browser_backend` for both isolation states - [ ] Stability gate S is green on web with and without isolation headers. proof: the S commands diff --git a/docs/PRDs/performance/PRD-752-web-games-run-in-a-worker-so-the-page-cannot-stall-them.md b/docs/PRDs/performance/PRD-752-web-games-run-in-a-worker-so-the-page-cannot-stall-them.md index e596898e1b..01a972b744 100644 --- a/docs/PRDs/performance/PRD-752-web-games-run-in-a-worker-so-the-page-cannot-stall-them.md +++ b/docs/PRDs/performance/PRD-752-web-games-run-in-a-worker-so-the-page-cannot-stall-them.md @@ -87,6 +87,8 @@ Clean CPU profile of the shooter kit (NVIDIA, no DevTools, 2026-10-26): frame me - 2026-10-27 (threads lane): **the job pool lives in this PRD.** PRD-573 v3.1 had no pool yet, so one `JobPool` (`packages/runtime-native/src/engine/foundation/job_pool.h`) serves native and web; v3.1 adds its jobs to it. Its workers park on a condition variable marked `TN_WORKER_ONLY` (`native_engine_no_blocking_waits`); the caller never waits on the GPU. A second caller, or a nested call, runs its items inline. The web glue reads `HEAPU8` directly, so the join refreshes the caller's heap views when a job grew memory. Isolation is on by default for `engine: "threenative"` (dev, preview, `dist/_headers`); a host without header control serves the single-thread engine unchanged, and `credentialless` is the documented switch for cross-origin assets without CORP. No service worker. The page realm still loads the engine (an existing `three` import), and since the pool starts on need it starts no threads there. - 2026-10-27 (lead): **a live-clock hold counted requests, not ticks.** A wall-clock `advance(1)` returns the ticks the host's pump ran: two per frame at 30 fps. The runner counted each request as one tick, so the starter's 169-tick hold ran about 338 and the player fell into the gap on the worker page. Harness layer: any game under 60 fps on a live clock drifted the same way. The bridge client now returns the count and the hold loop counts it (red-green spec in `runner.spec.ts`). +- 2026-11-10 (Claude, from a read of the `UE 5.8.3` thread split, not an owner decision): **worker mode before a render thread is the right order on the web.** A render thread inside the page thread cannot remove a stall that other page work causes. It cuts the mean, because the frame costs the larger of game time and render time instead of their sum, and it adds a frame of latency. Worker mode cuts the tail, which is this PRD's goal. Three guards from Unreal apply to the worker and to Phase 4. One: the wait on a frame must keep serving the worker's event loop, so poll with a time slice and a timeout and do not block on `Atomics.wait` (Unreal waits in 1 ms slices, `Engine/Source/Runtime/RenderCore/Private/RenderingThread.cpp:1081-1084,1189`). Two: flush pending frames before an `OffscreenCanvas` resize, surface loss or device loss (`Engine/Source/Runtime/SlateRHIRenderer/Private/SlateRHIRenderer.cpp:564`). Three: report frames in flight, because the web `FrameGate` allows 6 (`packages/runtime-native/src/engine/renderer/frame_gate.h:30`) where Unreal allows 1 (`RenderingThread.cpp:2435`). Not verified: whether WebGPU objects can be shared between two workers. If they cannot, a web render thread needs every GPU call in one worker. + ### Phase 1: Loaded-host baseline **Status:** DONE diff --git a/docs/PRDs/performance/PRD-828-js-keeps-only-game-logic.md b/docs/PRDs/performance/PRD-828-js-keeps-only-game-logic.md deleted file mode 100644 index 06dae34463..0000000000 --- a/docs/PRDs/performance/PRD-828-js-keeps-only-game-logic.md +++ /dev/null @@ -1,147 +0,0 @@ -# PRD-828 — JS keeps only game logic: engine work moves into C++ - -**Status:** NOT STARTED -**Priority:** P1 — on the stress lab about 30% of the busy main thread is JS (2026-11-02 profile). The animation tick (about 2.2 ms a frame) and the per-pass uniform sync have no owning PRD. Every box below is open. -**Complexity:** 8 (HIGH) — 11+ implementation files (3), crosses the C++/Wasm ABI and the JS package release boundary (+2), animation listener and wrapper lifetime state (+2), new ABI exports on web and native (+1 compatibility). Risk override: none. -**Owner:** João -**Depends on:** PRD-770 (the fair window, `pnpm stress:loop fair`) and PRD-771 (the stage meter). Both tools ship. Coordinates with PRD-772 (C++ skeleton update and GPU joints), PRD-765 (bulk physics interpolation), PRD-768 (uniform scan scope), PRD-773 and PRD-774 (C++ plan). None of their scope moves here. - -## Context - -Owner, 2026-11-03: "whatever you can move to C++/gpu you move, reduce js<>wasm bridge crossings". -Owner, 2026-11-06: move code execution out of JS into C++ so that as little as possible runs in JS, for significant performance gains. Measure it on the stress benchmark against three.js, and use the benchmark artifacts to pick what moves. - -### What one crossing costs - -- A **facade crossing** (`browser-backend.ts` `invoke`: alloc, `toEngine`, `wrap`, `countCall`) costs about 0.8 µs. `Color.lerp` measured 796-939 ms per 1 M calls, against 4 ms in three.js (PRD-742). -- A **direct Emscripten export** (`module._tnw_web_frame(n)`) costs tens of nanoseconds. -- Rule of this PRD: **one crossing does the work of many objects.** That is a typed-array span, a whole loop in C++, or a GPU pass. Tiny per-call math stays in JS (PRD-742). - -### Evidence 1: the stress-lab CPU profile (measured) - -Artifact: `~/.cache/tmp-tn/stress-loop/hunt/js1102.cpuprofile` and `js1102.json`, 2026-11-02, `?stress`, 15 s, 1,013 frames at 67.5 fps. Main thread busy 14.6 s, so 1% of samples is about 0.144 ms a frame. Report: frame p50 14.09 ms, update p50 2.16 ms, 734 crossings p50, 2,304 KB GPU upload p50. Counts: 4,000 trees, 6,000 rocks, 120 houses, 600 props, 120 crowd, 300 bodies, 18,000 particles, 16 lights. Self time by owner: Wasm 52.8%, facade `web-engine-runtime.js` 14.9%, `core` 9.2%, Emscripten glue 2.8%, three math about 2%. - -Clues ranked by JS time that a move removes: - -| # | Clue (inclusive share, ms a frame) | Call chain in the profile | Where it lives | Owner | -| --- | --- | --- | --- | --- | -| P1 | Animation tick 8.1% (1.17 ms) | `Stress.ts:384` → anim `update` → `#evaluate` 3.5%, `#syncStride` 3.1%, both mostly facade `value`→`invoke` | `packages/core/src/animation.ts:1180-1267,922-988,1498` | **this PRD** | -| P2 | Animation rate bands 7.0% (1.01 ms) | `onRender` → `prepareAnimation` → `#visitAnimation` → `#observeAnimation` (3.3% self JS) | `packages/core/src/render-camera-cull.ts:408-448,622-720` | **this PRD** | -| P3 | Facade value-class crossings 12.4% (1.79 ms), across P1, P5 and P6 | `value` → `invoke` 9.4% | `packages/three-native/src/browser-backend.ts` | PRD-742 for the classes; the loops that call them move here | -| P4 | WebGPU calls from Wasm into JS 10.2% (1.47 ms) | `wasm-to-js` → `writeBuffer` 4.2%, `beginRenderPass` 1.9%, `createCommandEncoder`, `submit`, `executeBundles` | the C++ renderer's per-call `wgpu*` use | **this PRD** (coalesce) | -| P5 | Uniform sync 4.6% (0.66 ms), once per nested render and per compute | `Renderer` → `sync` (3.0%), `compute` → `sync` (1.4%), under core `updateBefore` pass renders (6.0%) | `packages/three-native/src/tsl-uniforms.ts:223-296,430-460` | **this PRD** (write once a frame); PRD-768 keeps the scan scope | -| P6 | Compute-driven systems 5.9% (0.85 ms) | `processRender` → `process` → `compute` 3.1%, `Box3.applyMatrix4` 0.8%, `multiplyMatrices` 0.4% | `packages/core/src/fluid-particles.ts:1181-1215`, `particles.ts:477-582` | **this PRD** | -| P7 | Physics interpolation apply 3.3% (0.48 ms) | `onRender` → `apply` → `value` | `packages/core/src/game-frame-loop.ts` interpolation | PRD-765 | - -PRD-771's stage split agrees (2026-10-30, 14.7 ms frame): jsBefore 2.65, update 2.26, engineJs 0.81 (735 host calls), residual 0.41. That is about 6 ms of JS. - -### Evidence 2: code survey and scouts (counted from code, not measured) - -Three Sonnet surveys and four DeepSeek v4.1 flash max scouts read the code on 2026-11-06. Their reports are in `~/.cache/tmp-tn/prd828/out/{A,B,C,D}.md`. Findings that match a profile clue join the phases below. The scout briefs are DeepSeek output: each one needs a full code review before it is implemented. - -- Scout B confirms P1. A starter rig with 6 clips pays about 52 ABI calls a frame, and a crowd rig pays 2 (`animation.ts:1180-1267`). The C++ mixer already owns actions, weights and time scales (`packages/runtime-native/src/engine/animation/mixer.cpp:317,352,850`). -- Scout D confirms P6. A fluid step issues 25 `renderer.compute()` calls, and each opens its own compute pass (`compute.cpp:126-135`). -- Down-ranked: the 55 `_tnw_web_frame` telemetry reads (`browser-renderer.ts:650-760`) are direct exports, about 3 µs a frame. They are not a lever. - -## Benchmark: the stress lab against three.js - -Every lever is kept or rejected on the stress benchmark that exists. This PRD adds no new meter. - -- **Scene:** `sandbox/stress-lab` (`main.html?stress`). `?load=N` scales density. -- **Arms:** `threenative-wasm` (the default owned engine) against `three-vanilla` (the same scene in plain three.js). `threenative-threejs` (the `engine: "threejs"` override) is an optional third arm. -- **Tool:** `pnpm stress:loop fair threenative-wasm=?stress&arm=