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.
237 lines
12 KiB
JavaScript
237 lines
12 KiB
JavaScript
import test from "node:test";
|
|
import assert from "node:assert/strict";
|
|
import { Renderer } from "../renderer.js";
|
|
|
|
function fixture(t, { uploadCost = 1, allocationCost = 0 } = {}) {
|
|
const clock = { now: 0 };
|
|
t.mock.method(performance, "now", () => clock.now);
|
|
let serial = 0, bound;
|
|
const gl = {
|
|
ARRAY_BUFFER: 1, DYNAMIC_DRAW: 2, STATIC_DRAW: 3, FLOAT: 4,
|
|
buffers: new Map(), uploads: [], allocations: [], deletedBuffers: [], deletedVaos: [],
|
|
createBuffer() { const buffer = { id: ++serial }; this.buffers.set(buffer, new Uint8Array()); return buffer; },
|
|
createVertexArray() { return { id: ++serial }; },
|
|
bindVertexArray() {}, bindBuffer(_target, buffer) { bound = buffer; },
|
|
enableVertexAttribArray() {}, vertexAttribPointer() {},
|
|
bufferData(_target, data, usage) {
|
|
const bytes = typeof data === "number" ? new Uint8Array(data) :
|
|
new Uint8Array(data.buffer, data.byteOffset, data.byteLength).slice();
|
|
this.buffers.set(bound, bytes);
|
|
this.allocations.push({ buffer: bound, byteLength: bytes.byteLength, usage });
|
|
if (typeof data === "number") clock.now += allocationCost;
|
|
},
|
|
bufferSubData(_target, offset, data) {
|
|
const bytes = new Uint8Array(data.buffer, data.byteOffset, data.byteLength);
|
|
assert.ok(offset + bytes.byteLength <= this.buffers.get(bound).byteLength);
|
|
this.buffers.get(bound).set(bytes, offset);
|
|
this.uploads.push({ buffer: bound, offset, byteLength: bytes.byteLength });
|
|
clock.now += uploadCost;
|
|
},
|
|
deleteBuffer(buffer) { this.deletedBuffers.push(buffer); this.buffers.delete(buffer); },
|
|
deleteVertexArray(vao) { this.deletedVaos.push(vao); },
|
|
};
|
|
const renderer = Object.create(Renderer.prototype);
|
|
Object.assign(renderer, { gl, sections: new Map(), dirty: new Set(["0,0,0"]), meshUpload: null,
|
|
meshRebuilds: 0, lightViewBounds: { min: [0, 0, 0], max: [31, 31, 31] } });
|
|
const old = renderer.mesh(new Float32Array(60).fill(7));
|
|
old.transparent = renderer.mesh(new Float32Array(60).fill(8));
|
|
old.tag = "retained identity";
|
|
const other = renderer.mesh(new Float32Array(60).fill(9));
|
|
renderer.sections.set("0,0,0", old);
|
|
renderer.sections.set("1,0,0", other);
|
|
const terrain = { epoch: 1, version: 1, ready: new Map(),
|
|
isCurrent(result) { return result.epoch === this.epoch && result.version === this.version; } };
|
|
renderer.terrain = terrain;
|
|
gl.allocations.length = 0;
|
|
const result = (opaqueVertices = 3000, transparentVertices = 1000, overrides = {}) => {
|
|
const vertices = new Float32Array(opaqueVertices * 20), transparent = new Float32Array(transparentVertices * 20);
|
|
for (let index = 0; index < vertices.length; index++) vertices[index] = index * 0.25;
|
|
for (let index = 0; index < transparent.length; index++) transparent[index] = -index * 0.125;
|
|
return { id: "0,0,0", epoch: terrain.epoch, version: terrain.version, vertices, transparent, ...overrides };
|
|
};
|
|
const queue = (message) => terrain.ready.set(message.id, message);
|
|
const drain = () => {
|
|
for (let frame = 0; frame < 200 && (renderer.meshUpload || terrain.ready.size); frame++)
|
|
renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(renderer.meshUpload, null);
|
|
assert.equal(terrain.ready.size, 0);
|
|
};
|
|
return { renderer, terrain, gl, clock, old, other, result, queue, drain };
|
|
}
|
|
|
|
test("uploads are capped at 64 KiB and both surfaces publish only after completion", (t) => {
|
|
const f = fixture(t), message = f.result();
|
|
const oldBuffer = f.old.buffer, oldTransparent = f.old.transparent, oldVao = f.old.vao;
|
|
f.queue(message);
|
|
f.renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(f.gl.uploads.length, 1);
|
|
assert.equal(f.gl.uploads[0].byteLength, 65_536);
|
|
assert.equal(f.renderer.sections.get(message.id), f.old);
|
|
assert.equal(f.old.buffer, oldBuffer);
|
|
assert.equal(f.old.transparent, oldTransparent);
|
|
assert.deepEqual(f.gl.deletedBuffers, []);
|
|
assert.ok(f.renderer.dirty.has(message.id));
|
|
// Four opaque slices and the first translucent slice still cannot replace
|
|
// the visible mesh, even though its opaque replacement is already complete.
|
|
for (let frame = 0; frame < 4; frame++) f.renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(f.renderer.meshUpload.parts[0].offset, message.vertices.length);
|
|
assert.ok(f.renderer.meshUpload.parts[1].offset < message.transparent.length);
|
|
assert.equal(f.old.buffer, oldBuffer);
|
|
assert.equal(f.old.transparent, oldTransparent);
|
|
f.drain();
|
|
assert.ok(f.gl.uploads.every((upload) => upload.byteLength <= 65_536));
|
|
assert.equal(f.renderer.sections.get(message.id), f.old);
|
|
assert.equal(f.old.tag, "retained identity");
|
|
assert.notEqual(f.old.buffer, oldBuffer);
|
|
assert.notEqual(f.old.vao, oldVao);
|
|
assert.notEqual(f.old.transparent, oldTransparent);
|
|
assert.equal(f.old.count, 3000);
|
|
assert.equal(f.old.transparent.count, 1000);
|
|
assert.deepEqual(f.gl.buffers.get(f.old.buffer), new Uint8Array(message.vertices.buffer));
|
|
assert.deepEqual(f.gl.buffers.get(f.old.transparent.buffer), new Uint8Array(message.transparent.buffer));
|
|
assert.ok(f.gl.deletedBuffers.includes(oldBuffer));
|
|
assert.ok(f.gl.deletedBuffers.includes(oldTransparent.buffer));
|
|
assert.equal(f.renderer.sections.get("1,0,0"), f.other);
|
|
assert.ok(!f.gl.deletedBuffers.includes(f.other.buffer));
|
|
assert.ok(!f.renderer.dirty.has(message.id));
|
|
assert.equal(f.renderer.meshRebuilds, 1);
|
|
});
|
|
|
|
test("a generation change discards partially uploaded buffers and preserves visible geometry", (t) => {
|
|
const f = fixture(t), oldBuffer = f.old.buffer, oldTransparent = f.old.transparent;
|
|
f.queue(f.result(900, 900));
|
|
for (let frame = 0; frame < 3; frame++) f.renderer.applyTerrainMeshes(0.5);
|
|
const staged = f.renderer.meshUpload.parts.map((part) => part.mesh);
|
|
assert.ok(staged.every(Boolean), "both replacement surfaces were allocated");
|
|
assert.equal(f.old.buffer, oldBuffer);
|
|
f.terrain.version++;
|
|
f.renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(f.renderer.meshUpload, null);
|
|
assert.equal(f.renderer.sections.get("0,0,0"), f.old);
|
|
assert.equal(f.old.buffer, oldBuffer);
|
|
assert.equal(f.old.transparent, oldTransparent);
|
|
assert.ok(f.renderer.dirty.has("0,0,0"));
|
|
for (const mesh of staged) {
|
|
assert.ok(f.gl.deletedBuffers.includes(mesh.buffer));
|
|
assert.ok(f.gl.deletedVaos.includes(mesh.vao));
|
|
}
|
|
assert.ok(!f.gl.deletedBuffers.includes(oldBuffer));
|
|
assert.ok(!f.gl.deletedBuffers.includes(oldTransparent.buffer));
|
|
assert.equal(f.renderer.meshRebuilds, 0);
|
|
f.queue(f.result(3, 0));
|
|
f.drain();
|
|
assert.equal(f.old.count, 3);
|
|
assert.equal(f.old.transparent, null);
|
|
assert.equal(f.renderer.meshRebuilds, 1);
|
|
});
|
|
|
|
test("explicit staged-upload disposal is idempotent and owns only replacement resources", (t) => {
|
|
const f = fixture(t);
|
|
f.queue(f.result());
|
|
f.renderer.applyTerrainMeshes(0.5);
|
|
const staged = f.renderer.meshUpload.parts[0].mesh;
|
|
f.renderer.discardMeshUpload();
|
|
f.renderer.discardMeshUpload();
|
|
assert.equal(f.renderer.meshUpload, null);
|
|
assert.equal(f.gl.deletedBuffers.filter((buffer) => buffer === staged.buffer).length, 1);
|
|
assert.equal(f.gl.deletedVaos.filter((vao) => vao === staged.vao).length, 1);
|
|
assert.ok(f.gl.buffers.has(f.old.buffer));
|
|
assert.ok(f.gl.buffers.has(f.old.transparent.buffer));
|
|
});
|
|
|
|
test("empty results remove both old surfaces without allocating empty GPU replacements", (t) => {
|
|
const f = fixture(t), oldTransparent = f.old.transparent;
|
|
f.queue(f.result(0, 0));
|
|
f.drain();
|
|
assert.equal(f.renderer.sections.has("0,0,0"), false);
|
|
assert.ok(f.gl.deletedBuffers.includes(f.old.buffer));
|
|
assert.ok(f.gl.deletedBuffers.includes(oldTransparent.buffer));
|
|
assert.equal(f.gl.allocations.length, 0);
|
|
assert.equal(f.gl.uploads.length, 0);
|
|
assert.equal(f.renderer.sections.get("1,0,0"), f.other);
|
|
assert.equal(f.renderer.meshRebuilds, 1);
|
|
});
|
|
|
|
test("a new translucent-only section retains an empty opaque handle for draw paths", (t) => {
|
|
const f = fixture(t), message = f.result(0, 6, { id: "0,1,0" });
|
|
f.queue(message);
|
|
f.drain();
|
|
const mesh = f.renderer.sections.get(message.id);
|
|
assert.ok(mesh?.vao && mesh.buffer);
|
|
assert.equal(mesh.count, 0);
|
|
assert.equal(mesh.transparent.count, 6);
|
|
assert.deepEqual(f.gl.buffers.get(mesh.transparent.buffer), new Uint8Array(message.transparent.buffer));
|
|
assert.equal(f.renderer.sections.get("0,0,0"), f.old);
|
|
});
|
|
|
|
test("stale or out-of-view results never allocate or replace a mesh", (t) => {
|
|
const f = fixture(t);
|
|
f.queue(f.result(3, 0, { version: 0 }));
|
|
f.renderer.applyTerrainMeshes(0.5);
|
|
f.queue(f.result(3, 0, { id: "8,0,0" }));
|
|
f.renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(f.gl.allocations.length, 0);
|
|
assert.equal(f.gl.uploads.length, 0);
|
|
assert.equal(f.renderer.sections.get("0,0,0"), f.old);
|
|
assert.equal(f.renderer.meshRebuilds, 0);
|
|
assert.ok(f.renderer.dirty.has("0,0,0"));
|
|
});
|
|
|
|
test("the frame budget stops before further upload steps and preserves queued work", (t) => {
|
|
const f = fixture(t);
|
|
f.queue(f.result());
|
|
f.renderer.applyTerrainMeshes(0);
|
|
assert.equal(f.gl.allocations.length, 0);
|
|
assert.equal(f.terrain.ready.size, 1);
|
|
f.renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(f.gl.uploads.length, 1);
|
|
assert.equal(f.renderer.meshUpload.parts[0].offset, 16_384);
|
|
assert.equal(f.renderer.meshUploadMilliseconds, 1);
|
|
f.renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(f.gl.uploads.length, 2);
|
|
assert.equal(f.renderer.meshUpload.parts[0].offset, 32_768);
|
|
});
|
|
|
|
test("an expensive GPU allocation yields before copying vertex data", (t) => {
|
|
const f = fixture(t, { allocationCost: 3 });
|
|
f.queue(f.result());
|
|
f.renderer.applyTerrainMeshes(2);
|
|
assert.ok(f.renderer.meshUpload.parts[0].mesh);
|
|
assert.equal(f.renderer.meshUpload.parts[0].offset, 0);
|
|
assert.equal(f.gl.uploads.length, 0);
|
|
assert.equal(f.renderer.meshUploadMilliseconds, 3);
|
|
f.renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(f.gl.uploads.length, 1);
|
|
assert.equal(f.renderer.sections.get("0,0,0"), f.old);
|
|
});
|
|
|
|
test('preview publication keeps the section dirty for corrected lighting and reports visible geometry', t => {
|
|
const f=fixture(t),published=[];
|
|
f.renderer.onMeshPublished=(id,result)=>published.push({id,preview:result.preview});
|
|
f.queue(f.result(6,0,{preview:true})); f.drain();
|
|
assert.equal(f.old.count,6);
|
|
assert.equal(f.renderer.dirty.has('0,0,0'),true);
|
|
assert.deepEqual(published,[{id:'0,0,0',preview:true}]);
|
|
f.queue(f.result(6,0,{preview:false}));f.drain();
|
|
assert.equal(f.renderer.dirty.has('0,0,0'),false);
|
|
});
|
|
|
|
test('edit uploads survive unrelated terrain versions but reject newer edits before publication',t=>{
|
|
const f=fixture(t),oldBuffer=f.old.buffer;
|
|
const edits={epoch:9,ticket:1,ready:new Map(),isCurrent(r){return r.epoch===this.epoch&&r.editJob===this.ticket;},cancel(){this.ticket++;}};
|
|
f.renderer.edits=edits;
|
|
edits.ready.set('0,0,0',f.result(3000,0,{epoch:9,editJob:1,preview:true}));
|
|
f.renderer.applyTerrainMeshes(0.5);const stage=f.renderer.meshUpload;
|
|
f.terrain.version++;
|
|
f.renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(f.renderer.meshUpload,stage,'a distant chunk update must not discard a local edit');
|
|
edits.ticket++;f.renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(f.renderer.meshUpload,null);assert.equal(f.old.buffer,oldBuffer);
|
|
edits.ready.set('0,0,0',f.result(3,0,{epoch:9,editJob:2,preview:true}));
|
|
for(let frame=0;frame<10&&(f.renderer.meshUpload||edits.ready.size);frame++)f.renderer.applyTerrainMeshes(0.5);
|
|
assert.equal(f.old.count,3);assert.equal(f.renderer.dirty.has('0,0,0'),true);
|
|
f.queue(f.result(3,0));f.drain();
|
|
assert.equal(edits.ticket,3,'final lighting cancels any older preview');
|
|
assert.equal(f.renderer.dirty.has('0,0,0'),false);
|
|
});
|