Files
shacraft-core/client/tests/terrain-streaming-smoke.html
Emil c7e86663d8
MVP checks / mvp (push) Canceled after 0s
Expand voxel gameplay, lighting, full-height streaming and world imports
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.
2026-09-17 02:10:53 +03:00

149 lines
11 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!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>