Files
shacraft-core/client/tests/terrain-upload.test.js
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

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);
});