MVP checks / mvp (push) Canceled after 0s
Add shared Rust/WASM physics, worker meshing and diagnostics, 64-chunk full-height streaming, atlas texture support, and baseline world import. Document the current implementation and include the supplied in-game lobby screenshot.
149 lines
11 KiB
HTML
149 lines
11 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Shacraft terrain streaming benchmark</title>
|
||
<style>
|
||
*{box-sizing:border-box}body{margin:0;background:#e9eee8;color:#19352a;font:14px/1.4 system-ui,sans-serif}
|
||
header,main{width:min(100%,1000px);margin:auto;padding:14px 20px}header{display:flex;align-items:center;gap:14px;flex-wrap:wrap}
|
||
canvas{display:block;width:960px;max-width:100%;height:540px;background:#8aacbc}select,button{font:inherit;padding:6px 10px}
|
||
output{display:block;white-space:pre-wrap;margin:12px 0;font-variant-numeric:tabular-nums}p{color:#486251}button{cursor:pointer}
|
||
</style>
|
||
<header><strong>Terrain streaming</strong><label>Meshing <select id="mode"><option value="worker">Worker</option><option value="baseline">Baseline · main thread</option></select></label><button id="run" disabled>Run 12 seconds</button><span id="status">Preparing scene…</span></header>
|
||
<main><canvas id="world" aria-label="Synthetic terrain streaming scene"></canvas><output id="results">Initial loading and warmup are excluded from measurements.</output><p>64 × 64 visible floor, lamp grid, glass and stairs. Six boundaries at 8 blocks/s. CPU draw measures JavaScript and WebGL submission, not GPU completion. Keep this tab visible.</p></main>
|
||
<script type="module">
|
||
import { Renderer } from "../renderer.js?v=terrain-worker-1";
|
||
import { SectionBlockMap } from "../section-block-map.js";
|
||
import { mergeChunkUpdate } from "../world-view.js";
|
||
import { BlockLightController } from "../block-light-controller.js";
|
||
import { changedLightSections } from "../light-changes.js";
|
||
const $ = id => document.getElementById(id), mode = new URL(location.href).searchParams.get("mode") === "baseline" ? "baseline" : "worker";
|
||
$("mode").value = mode;
|
||
$("mode").onchange = () => { const url = new URL(location.href); url.searchParams.set("mode", $("mode").value); location.href = url; };
|
||
const cube = { min: [0,0,0], max: [1,1,1] }, materials = new Map([
|
||
[1, { state: "minecraft:grass_block", color: [114,157,74], render: [cube] }],
|
||
[2, { state: "minecraft:stone", color: [154,158,153], render: [cube] }],
|
||
[3, { state: "minecraft:sea_lantern", color: [223,243,222], render: [cube], light: 15 }],
|
||
[4, { state: "minecraft:glass", color: [162,219,221], render: [cube], opacity: 0.3 }],
|
||
[5, { state: "minecraft:stone_stairs[facing=east,half=bottom,shape=straight]", color: [169,168,153], render: [
|
||
{min:[0,0,0],max:[1,0.5,1]}, {min:[0.5,0.5,0],max:[1,1,1]}
|
||
] }],
|
||
]);
|
||
// Precompute the synthetic server records before timing; no network or shared world is used.
|
||
const records = new Map(), sectionId = pos => pos.map(v => Math.floor(v / 16)).join(",");
|
||
const put = (x,y,z,block) => { const pos = [x,y,z], id = sectionId(pos); if (!records.has(id)) records.set(id, []); records.get(id).push({pos,block}); };
|
||
for (let x = -32; x < 128; x++) for (let z = -32; z < 32; z++) {
|
||
put(x,-2,z,2); put(x,-1,z,2); put(x,0,z,1);
|
||
if (x % 8 === 0 && z % 8 === 0) put(x,1,z,3);
|
||
else if ((x + 160) % 16 === 4 && (z + 32) % 16 < 5) { put(x,1,z,4); put(x,2,z,4); }
|
||
else if ((x + 160) % 16 === 9 && (z + 32) % 8 < 3) put(x,1,z,5);
|
||
}
|
||
const windowAt = center => {
|
||
const result = new Map(), c = center.map(v => v / 16);
|
||
for (let x=c[0]-2;x<=c[0]+1;x++) for(let y=c[1]-1;y<=c[1]+1;y++) for(let z=c[2]-2;z<=c[2]+1;z++) result.set(`${x},${y},${z}`,[x,y,z]);
|
||
return result;
|
||
};
|
||
const boundsAt = center => ({ min: center.map((v,i) => v-(i===1?16:32)), max: center.map(v => v+31) });
|
||
let renderer, view, phase = "loading", stableSince = null, warmStarted = 0, previousFrame = null, started = 0, autoRun = false;
|
||
let frames = [], draws = [], updates = [], transitionTimings = [], transitions = 0, retainedChecks = 0, retainedFailures = 0, tracked = new Map(), errors = new Set();
|
||
const camera = { eye: [8,4.6,8], yaw: Math.PI / 2, pitch: -0.19 }, emptyDefinitions = new Map();
|
||
const assert = (condition, message) => { if (!condition) { retainedFailures++; throw Error(message); } };
|
||
function fail(error) {
|
||
phase = "error"; document.body.dataset.status = "error"; document.body.dataset.error = error.message;
|
||
$("status").textContent = "Benchmark failed"; $("results").textContent = error.stack || String(error); $("run").disabled = true;
|
||
}
|
||
function prepare(repeat = false) {
|
||
frames=[]; draws=[]; updates=[]; transitionTimings=[]; transitions=0; retainedChecks=0; retainedFailures=0; tracked.clear(); errors.clear(); previousFrame=null;
|
||
view = { world: "isolated-streaming-fixture", revision: 1, viewCenter: [0,0,0], viewBounds: boundsAt([0,0,0]), blocks: new SectionBlockMap() };
|
||
for (const id of windowAt(view.viewCenter).keys()) for (const {pos,block} of records.get(id)||[]) view.blocks.set(pos.join(","),block);
|
||
camera.eye[0]=8; renderer.setLightBounds(view.viewBounds); renderer.replace(view.blocks,materials);
|
||
phase="warming"; autoRun=repeat; stableSince=null; warmStarted=performance.now(); $("run").disabled=true;
|
||
document.body.dataset.status=phase; document.body.dataset.mode=mode; delete document.body.dataset.results;
|
||
$("status").textContent="Loading meshes and warming up…";
|
||
}
|
||
function begin(time) {
|
||
phase="running"; started=time; previousFrame=time; document.body.dataset.status=phase;
|
||
$("run").disabled=true; $("mode").disabled=true; $("results").textContent="Measuring 12 seconds of streaming…";
|
||
}
|
||
function transition(center) {
|
||
const start=performance.now();
|
||
const old=windowAt(view.viewCenter), next=windowAt(center), unload=[...old].filter(([id])=>!next.has(id)).map(([,pos])=>pos);
|
||
const sections=[...next].filter(([id])=>!old.has(id)).map(([id,section])=>({section,blocks:records.get(id)||[]}));
|
||
const bounds=boundsAt(center), blocks=view.blocks, before=new Map(renderer.sections), distance=Math.abs(center[0]-view.viewCenter[0])/16;
|
||
const message={world:view.world,revision:view.revision,from_center:[...view.viewCenter],view_center:center,view_min:bounds.min,view_max:bounds.max,unload,sections};
|
||
const mergeStart=performance.now(), result=mergeChunkUpdate(view,message,{includeUnloadedChanges:false}), merged=performance.now();
|
||
assert(result.status==="applied","Chunk transition rejected");
|
||
assert(view.blocks===blocks,"World map identity changed");
|
||
renderer.setLightBounds(view.viewBounds); const bounded=performance.now();
|
||
renderer.change(result.changes,[...sections.map(s=>s.section),...unload],sections); const changed=performance.now();
|
||
renderer.unloadSections(unload); const unloaded=performance.now();
|
||
for(const pos of unload) tracked.delete(pos.join(","));
|
||
for(const [id,mesh] of before) if(next.has(id)) { retainedChecks++; assert(renderer.sections.get(id)===mesh,`Retained mesh ${id} replaced during stream`); }
|
||
transitions+=distance; document.body.dataset.transitions=String(transitions);
|
||
const round=value=>Math.round(value*100)/100;
|
||
transitionTimings.push({center:[...center],enteringBlocks:sections.reduce((n,s)=>n+s.blocks.length,0),changes:result.changes.length,
|
||
prepareMs:round(mergeStart-start),mergeMs:round(merged-mergeStart),boundsMs:round(bounded-merged),changeMs:round(changed-bounded),
|
||
unloadMs:round(unloaded-changed),assertionsMs:round(performance.now()-unloaded),fullUpdateMs:round(performance.now()-start)});
|
||
}
|
||
function checkRetained() {
|
||
for(const [id,mesh] of tracked) { retainedChecks++; assert(renderer.sections.get(id)===mesh,`Retained mesh ${id} identity changed during asynchronous rebuild`); }
|
||
for(const [id,mesh] of renderer.sections) tracked.set(id,mesh);
|
||
}
|
||
const summarize = values => {
|
||
const sorted=[...values].sort((a,b)=>a-b), q=p=>Math.round((sorted[Math.min(sorted.length-1,Math.floor(sorted.length*p))]||0)*100)/100;
|
||
return {p50:q(0.5),p95:q(0.95),max:q(1)};
|
||
};
|
||
function finish(time) {
|
||
assert(transitions>=4,"Fewer than four boundaries were crossed");
|
||
const result={mode,actualMode:renderer.terrain?"worker":"baseline",durationMs:Math.round(time-started),frames:frames.length,
|
||
frameMs:summarize(frames),cpuDrawMs:summarize(draws),cpuUpdateMs:summarize(updates),over25msFrames:frames.filter(v=>v>25).length,
|
||
transitions,transitionTimings,meshResets:renderer.meshResets,retainedChecks,retainedFailures,mapIdentityPreserved:renderer.blocks===view.blocks,
|
||
glError:[...errors],pendingSections:renderer.dirty.size,canvas:[renderer.canvas.width,renderer.canvas.height],devicePixelRatio};
|
||
document.body.dataset.results=JSON.stringify(result); document.body.dataset.glError=errors.size?[...errors].join(","):"0";
|
||
document.body.dataset.meshResets=String(renderer.meshResets); document.body.dataset.retainedFailures=String(retainedFailures);
|
||
phase="done"; document.body.dataset.status=phase; $("status").textContent="Complete"; $("results").textContent=JSON.stringify(result,null,2);
|
||
$("run").disabled=false; $("run").textContent="Reset and run again"; $("mode").disabled=false;
|
||
}
|
||
function idle() {
|
||
const t=renderer.terrain;
|
||
return renderer.blockLightField && renderer.blockLighting.status==="ready" && !renderer.dirty.size && !renderer.meshUpload &&
|
||
(!t || (!t.busy && !t.pending && !t.preparing && !t.ready.size));
|
||
}
|
||
function frame(time) {
|
||
try {
|
||
if(document.hidden && phase==="running") throw Error("Tab was hidden during measurement; reload and keep it visible.");
|
||
const measuring=phase==="running", updateStart=performance.now();
|
||
if(measuring) {
|
||
frames.push(time-previousFrame); previousFrame=time; camera.eye[0]=8+Math.min(12,(time-started)/1000)*8;
|
||
const x=Math.floor(camera.eye[0]/16)*16; if(x!==view.viewCenter[0]) transition([x,0,0]);
|
||
}
|
||
const drawStart=performance.now(); renderer.draw(camera,[],[],emptyDefinitions); const drawEnd=performance.now();
|
||
if(measuring) { updates.push(drawStart-updateStart); draws.push(drawEnd-drawStart); checkRetained(); }
|
||
const error=renderer.gl.getError(); if(error!==renderer.gl.NO_ERROR) errors.add(`0x${error.toString(16)}`);
|
||
if(phase==="warming") {
|
||
if(mode==="worker"&&!renderer.terrain) throw Error("Worker mode fell back; use Baseline explicitly for this environment.");
|
||
if(time-warmStarted>90000) throw Error("Initial meshes did not drain within 90 seconds.");
|
||
if(idle()) stableSince??=time; else stableSince=null;
|
||
if(stableSince!==null&&time-stableSince>=750) {
|
||
tracked=new Map(renderer.sections);
|
||
if(autoRun) begin(time); else {phase="ready"; document.body.dataset.status=phase; $("run").disabled=false; $("status").textContent="Ready · initial meshes drained";}
|
||
}
|
||
} else if(measuring) { $("status").textContent=`${Math.min(12,(time-started)/1000).toFixed(1)} / 12 s · ${transitions} boundaries`; if(time-started>=12000) finish(time); }
|
||
requestAnimationFrame(frame);
|
||
} catch(error) {fail(error);}
|
||
}
|
||
try {
|
||
renderer=new Renderer($("world"));
|
||
if(mode==="baseline") {
|
||
renderer.terrain?.dispose(); renderer.terrain=null;
|
||
renderer.blockLighting=new BlockLightController(field=>{
|
||
for(const id of changedLightSections(renderer.blockLightField,field,renderer.sections.keys())) renderer.dirty.add(id);
|
||
renderer.blockLightField=field;
|
||
});
|
||
}
|
||
$("run").onclick=()=>{if(phase==="ready") begin(performance.now()); else if(phase==="done") prepare(true);};
|
||
prepare(); requestAnimationFrame(frame);
|
||
} catch(error) {fail(error);}
|
||
</script>
|
||
</html>
|